mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-20 03:46:38 +00:00
Compare commits
86 Commits
1.15.0
...
new-pricin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce74489df5 | ||
|
|
342b188fae | ||
|
|
fa6fee7b55 | ||
|
|
c53d5a4d7d | ||
|
|
521e905724 | ||
|
|
4623090050 | ||
|
|
dd9e5cc541 | ||
|
|
626be6a347 | ||
|
|
56327ed503 | ||
|
|
9ff863db5e | ||
|
|
e2ac6e6d4d | ||
|
|
df4101875a | ||
|
|
3f5c788d48 | ||
|
|
94ac3ec76e | ||
|
|
af7263a0b1 | ||
|
|
035396f95c | ||
|
|
f318f6304b | ||
|
|
9d0ff472e5 | ||
|
|
d27482e812 | ||
|
|
69c2212ea0 | ||
|
|
10be9bcd56 | ||
|
|
f531def0d2 | ||
|
|
ed40eae655 | ||
|
|
ba5ae6ed04 | ||
|
|
0a6301697e | ||
|
|
13b4fc6725 | ||
|
|
a095dddd01 | ||
|
|
1b5cfaa49b | ||
|
|
66f3fabbae | ||
|
|
0be8fb7931 | ||
|
|
431e6ffaae | ||
|
|
7d8185e0ee | ||
|
|
dff45748bd | ||
|
|
e6464929ff | ||
|
|
122053939d | ||
|
|
300b4a3706 | ||
|
|
81ef2db7f8 | ||
|
|
c41e8be3e8 | ||
|
|
41bab0ce0b | ||
|
|
5f26b9eeea | ||
|
|
1cca69ad23 | ||
|
|
410ed3949b | ||
|
|
efc6ef3075 | ||
|
|
e101ac341b | ||
|
|
6cfc7b7c69 | ||
|
|
313acabc86 | ||
|
|
34cced872f | ||
|
|
ac09e3aaf9 | ||
|
|
a8f6b6c1da | ||
|
|
f899326189 | ||
|
|
165bbd3584 | ||
|
|
ffb253e0e9 | ||
|
|
e5e9fe456f | ||
|
|
c63589b204 | ||
|
|
11408c2656 | ||
|
|
7d4aed8819 | ||
|
|
508369a59d | ||
|
|
26a91cd5e1 | ||
|
|
48dd4d5913 | ||
|
|
72d46b7352 | ||
|
|
4613aae47d | ||
|
|
1bc4480d84 | ||
|
|
b5d76f73e8 | ||
|
|
a5c7913e77 | ||
|
|
34b914f509 | ||
|
|
5a3d75ca12 | ||
|
|
158d7b23d8 | ||
|
|
bf5dd3b0a1 | ||
|
|
f5f757e4bd | ||
|
|
5ad564d21b | ||
|
|
8f8775cb93 | ||
|
|
37695827aa | ||
|
|
7a72d209ea | ||
|
|
f2ba4b270f | ||
|
|
873408270e | ||
|
|
8fec8f35bc | ||
|
|
141c846fe2 | ||
|
|
1497469016 | ||
|
|
e356a6d33b | ||
|
|
12aea2901d | ||
|
|
5ff56467ea | ||
|
|
3a8718a4b0 | ||
|
|
37c4a7b690 | ||
|
|
b735e7c34d | ||
|
|
5f85c3b3b8 | ||
|
|
5d9cb9fa21 |
14
.github/dependabot.yml
vendored
14
.github/dependabot.yml
vendored
@@ -44,19 +44,9 @@ updates:
|
|||||||
schedule:
|
schedule:
|
||||||
interval: "daily"
|
interval: "daily"
|
||||||
groups:
|
groups:
|
||||||
dev-patch-updates:
|
patch-updates:
|
||||||
dependency-type: "development"
|
|
||||||
update-types:
|
update-types:
|
||||||
- "patch"
|
- "patch"
|
||||||
dev-minor-updates:
|
minor-updates:
|
||||||
dependency-type: "development"
|
|
||||||
update-types:
|
|
||||||
- "minor"
|
|
||||||
prod-patch-updates:
|
|
||||||
dependency-type: "production"
|
|
||||||
update-types:
|
|
||||||
- "patch"
|
|
||||||
prod-minor-updates:
|
|
||||||
dependency-type: "production"
|
|
||||||
update-types:
|
update-types:
|
||||||
- "minor"
|
- "minor"
|
||||||
73
.github/workflows/cicd.yml
vendored
73
.github/workflows/cicd.yml
vendored
@@ -482,14 +482,77 @@ jobs:
|
|||||||
echo "==> cosign sign (key) --recursive ${REF}"
|
echo "==> cosign sign (key) --recursive ${REF}"
|
||||||
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}"
|
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}"
|
||||||
|
|
||||||
|
# Retry wrapper for verification to handle registry propagation delays
|
||||||
|
retry_verify() {
|
||||||
|
local cmd="$1"
|
||||||
|
local attempts=6
|
||||||
|
local delay=5
|
||||||
|
local i=1
|
||||||
|
until eval "$cmd"; do
|
||||||
|
if [ $i -ge $attempts ]; then
|
||||||
|
echo "Verification failed after $attempts attempts"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "Verification not yet available. Retry $i/$attempts after ${delay}s..."
|
||||||
|
sleep $delay
|
||||||
|
i=$((i+1))
|
||||||
|
delay=$((delay*2))
|
||||||
|
# Cap the delay to avoid very long waits
|
||||||
|
if [ $delay -gt 60 ]; then delay=60; fi
|
||||||
|
done
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
echo "==> cosign verify (public key) ${REF}"
|
echo "==> cosign verify (public key) ${REF}"
|
||||||
cosign verify --key env://COSIGN_PUBLIC_KEY "${REF}" -o text
|
if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${REF}' -o text"; then
|
||||||
|
VERIFIED_INDEX=true
|
||||||
|
else
|
||||||
|
VERIFIED_INDEX=false
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> cosign verify (keyless policy) ${REF}"
|
echo "==> cosign verify (keyless policy) ${REF}"
|
||||||
cosign verify \
|
if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${REF}' -o text"; then
|
||||||
--certificate-oidc-issuer "${issuer}" \
|
VERIFIED_INDEX_KEYLESS=true
|
||||||
--certificate-identity-regexp "${id_regex}" \
|
else
|
||||||
"${REF}" -o text
|
VERIFIED_INDEX_KEYLESS=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If index verification fails, attempt to verify child platform manifests
|
||||||
|
if [ "${VERIFIED_INDEX}" != "true" ] || [ "${VERIFIED_INDEX_KEYLESS}" != "true" ]; then
|
||||||
|
echo "Index verification not available; attempting child manifest verification for ${BASE_IMAGE}:${IMAGE_TAG}"
|
||||||
|
CHILD_VERIFIED=false
|
||||||
|
|
||||||
|
for ARCH in arm64 amd64; do
|
||||||
|
CHILD_TAG="${IMAGE_TAG}-${ARCH}"
|
||||||
|
echo "Resolving child digest for ${BASE_IMAGE}:${CHILD_TAG}"
|
||||||
|
CHILD_DIGEST="$(skopeo inspect --retry-times 3 docker://${BASE_IMAGE}:${CHILD_TAG} | jq -r '.Digest' || true)"
|
||||||
|
if [ -n "${CHILD_DIGEST}" ] && [ "${CHILD_DIGEST}" != "null" ]; then
|
||||||
|
CHILD_REF="${BASE_IMAGE}@${CHILD_DIGEST}"
|
||||||
|
echo "==> cosign verify (public key) child ${CHILD_REF}"
|
||||||
|
if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${CHILD_REF}' -o text"; then
|
||||||
|
CHILD_VERIFIED=true
|
||||||
|
echo "Public key verification succeeded for child ${CHILD_REF}"
|
||||||
|
else
|
||||||
|
echo "Public key verification failed for child ${CHILD_REF}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> cosign verify (keyless policy) child ${CHILD_REF}"
|
||||||
|
if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${CHILD_REF}' -o text"; then
|
||||||
|
CHILD_VERIFIED=true
|
||||||
|
echo "Keyless verification succeeded for child ${CHILD_REF}"
|
||||||
|
else
|
||||||
|
echo "Keyless verification failed for child ${CHILD_REF}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "No child digest found for ${BASE_IMAGE}:${CHILD_TAG}; skipping"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "${CHILD_VERIFIED}" != "true" ]; then
|
||||||
|
echo "Failed to verify index and no child manifests verified for ${BASE_IMAGE}:${IMAGE_TAG}"
|
||||||
|
exit 10
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}"
|
echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}"
|
||||||
done
|
done
|
||||||
|
|||||||
2
.github/workflows/linting.yml
vendored
2
.github/workflows/linting.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
|||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '24'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|||||||
10
.github/workflows/test.yml
vendored
10
.github/workflows/test.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
|||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '24'
|
||||||
|
|
||||||
- name: Copy config file
|
- name: Copy config file
|
||||||
run: cp config/config.example.yml config/config.yml
|
run: cp config/config.example.yml config/config.yml
|
||||||
@@ -34,7 +34,7 @@ jobs:
|
|||||||
run: npm run set:oss
|
run: npm run set:oss
|
||||||
|
|
||||||
- name: Generate database migrations
|
- name: Generate database migrations
|
||||||
run: npm run db:sqlite:generate
|
run: npm run db:generate
|
||||||
|
|
||||||
- name: Apply database migrations
|
- name: Apply database migrations
|
||||||
run: npm run db:sqlite:push
|
run: npm run db:sqlite:push
|
||||||
@@ -64,9 +64,6 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
- name: Copy config file
|
|
||||||
run: cp config/config.example.yml config/config.yml
|
|
||||||
|
|
||||||
- name: Build Docker image sqlite
|
- name: Build Docker image sqlite
|
||||||
run: make dev-build-sqlite
|
run: make dev-build-sqlite
|
||||||
|
|
||||||
@@ -76,8 +73,5 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
- name: Copy config file
|
|
||||||
run: cp config/config.example.yml config/config.yml
|
|
||||||
|
|
||||||
- name: Build Docker image pg
|
- name: Build Docker image pg
|
||||||
run: make dev-build-pg
|
run: make dev-build-pg
|
||||||
|
|||||||
57
Dockerfile
57
Dockerfile
@@ -1,21 +1,11 @@
|
|||||||
FROM node:24-alpine AS builder
|
FROM node:24-alpine AS builder
|
||||||
|
|
||||||
# OCI Image Labels - Build Args for dynamic values
|
|
||||||
ARG VERSION="dev"
|
|
||||||
ARG REVISION=""
|
|
||||||
ARG CREATED=""
|
|
||||||
ARG LICENSE="AGPL-3.0"
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ARG BUILD=oss
|
ARG BUILD=oss
|
||||||
ARG DATABASE=sqlite
|
ARG DATABASE=sqlite
|
||||||
|
|
||||||
# Derive title and description based on BUILD type
|
RUN apk add --no-cache python3 make g++
|
||||||
ARG IMAGE_TITLE="Pangolin"
|
|
||||||
ARG IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere"
|
|
||||||
|
|
||||||
RUN apk add --no-cache curl tzdata python3 make g++
|
|
||||||
|
|
||||||
# COPY package.json package-lock.json ./
|
# COPY package.json package-lock.json ./
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
@@ -23,41 +13,31 @@ RUN npm ci
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN echo "export * from \"./$DATABASE\";" > server/db/index.ts
|
RUN if [ "$BUILD" = "oss" ]; then rm -rf server/private; fi && \
|
||||||
RUN echo "export const driver: \"pg\" | \"sqlite\" = \"$DATABASE\";" >> server/db/index.ts
|
npm run set:$DATABASE && \
|
||||||
|
npm run set:$BUILD && \
|
||||||
RUN echo "export const build = \"$BUILD\" as \"saas\" | \"enterprise\" | \"oss\";" > server/build.ts
|
npm run db:generate && \
|
||||||
|
npm run build && \
|
||||||
# Copy the appropriate TypeScript configuration based on build type
|
npm run build:cli
|
||||||
RUN if [ "$BUILD" = "oss" ]; then cp tsconfig.oss.json tsconfig.json; \
|
|
||||||
elif [ "$BUILD" = "saas" ]; then cp tsconfig.saas.json tsconfig.json; \
|
|
||||||
elif [ "$BUILD" = "enterprise" ]; then cp tsconfig.enterprise.json tsconfig.json; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
# if the build is oss then remove the server/private directory
|
|
||||||
RUN if [ "$BUILD" = "oss" ]; then rm -rf server/private; fi
|
|
||||||
|
|
||||||
RUN if [ "$DATABASE" = "pg" ]; then npx drizzle-kit generate --dialect postgresql --schema ./server/db/pg/schema --out init; else npx drizzle-kit generate --dialect $DATABASE --schema ./server/db/$DATABASE/schema --out init; fi
|
|
||||||
|
|
||||||
RUN mkdir -p dist
|
|
||||||
RUN npm run next:build
|
|
||||||
RUN node esbuild.mjs -e server/index.ts -o dist/server.mjs -b $BUILD
|
|
||||||
RUN if [ "$DATABASE" = "pg" ]; then \
|
|
||||||
node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs; \
|
|
||||||
else \
|
|
||||||
node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
# test to make sure the build output is there and error if not
|
# test to make sure the build output is there and error if not
|
||||||
RUN test -f dist/server.mjs
|
RUN test -f dist/server.mjs
|
||||||
|
|
||||||
RUN npm run build:cli
|
|
||||||
|
|
||||||
# Prune dev dependencies and clean up to prepare for copy to runner
|
# Prune dev dependencies and clean up to prepare for copy to runner
|
||||||
RUN npm prune --omit=dev && npm cache clean --force
|
RUN npm prune --omit=dev && npm cache clean --force
|
||||||
|
|
||||||
FROM node:24-alpine AS runner
|
FROM node:24-alpine AS runner
|
||||||
|
|
||||||
|
# OCI Image Labels - Build Args for dynamic values
|
||||||
|
ARG VERSION="dev"
|
||||||
|
ARG REVISION=""
|
||||||
|
ARG CREATED=""
|
||||||
|
ARG LICENSE="AGPL-3.0"
|
||||||
|
|
||||||
|
# Derive title and description based on BUILD type
|
||||||
|
ARG IMAGE_TITLE="Pangolin"
|
||||||
|
ARG IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere"
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Only curl and tzdata needed at runtime - no build tools!
|
# Only curl and tzdata needed at runtime - no build tools!
|
||||||
@@ -66,11 +46,10 @@ RUN apk add --no-cache curl tzdata
|
|||||||
# Copy pre-built node_modules from builder (already pruned to production only)
|
# Copy pre-built node_modules from builder (already pruned to production only)
|
||||||
# This includes the compiled native modules like better-sqlite3
|
# This includes the compiled native modules like better-sqlite3
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
|
||||||
COPY --from=builder /app/.next/standalone ./
|
COPY --from=builder /app/.next/standalone ./
|
||||||
COPY --from=builder /app/.next/static ./.next/static
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
COPY --from=builder /app/init ./dist/init
|
COPY --from=builder /app/server/migrations ./dist/init
|
||||||
COPY --from=builder /app/package.json ./package.json
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
|
||||||
COPY ./cli/wrapper.sh /usr/local/bin/pangctl
|
COPY ./cli/wrapper.sh /usr/local/bin/pangctl
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ services:
|
|||||||
POSTGRES_DB: postgres # Default database name
|
POSTGRES_DB: postgres # Default database name
|
||||||
POSTGRES_USER: postgres # Default user
|
POSTGRES_USER: postgres # Default user
|
||||||
POSTGRES_PASSWORD: password # Default password (change for production!)
|
POSTGRES_PASSWORD: password # Default password (change for production!)
|
||||||
volumes:
|
# volumes:
|
||||||
- ./config/postgres:/var/lib/postgresql/data
|
# - ./config/postgres:/var/lib/postgresql/data
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432" # Map host port 5432 to container port 5432
|
- "5432:5432" # Map host port 5432 to container port 5432
|
||||||
restart: no
|
restart: no
|
||||||
|
|||||||
14
drizzle.config.ts
Normal file
14
drizzle.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const schema = [path.join("server", "db", "pg", "schema")];
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
dialect: "postgresql",
|
||||||
|
schema: schema,
|
||||||
|
out: path.join("server", "migrations"),
|
||||||
|
verbose: true,
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL as string
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -6,6 +6,12 @@ import path from "path";
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
// import { glob } from "glob";
|
// import { glob } from "glob";
|
||||||
|
|
||||||
|
// Read default build type from server/build.ts
|
||||||
|
let build = "oss";
|
||||||
|
const buildFile = fs.readFileSync(path.resolve("server/build.ts"), "utf8");
|
||||||
|
const m = buildFile.match(/export\s+const\s+build\s*=\s*["'](oss|saas|enterprise)["']/);
|
||||||
|
if (m) build = m[1];
|
||||||
|
|
||||||
const banner = `
|
const banner = `
|
||||||
// patch __dirname
|
// patch __dirname
|
||||||
// import { fileURLToPath } from "url";
|
// import { fileURLToPath } from "url";
|
||||||
@@ -37,7 +43,7 @@ const argv = yargs(hideBin(process.argv))
|
|||||||
describe: "Build type (oss, saas, enterprise)",
|
describe: "Build type (oss, saas, enterprise)",
|
||||||
type: "string",
|
type: "string",
|
||||||
choices: ["oss", "saas", "enterprise"],
|
choices: ["oss", "saas", "enterprise"],
|
||||||
default: "oss"
|
default: build
|
||||||
})
|
})
|
||||||
.help()
|
.help()
|
||||||
.alias("help", "h").argv;
|
.alias("help", "h").argv;
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"math/rand"
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -592,17 +593,12 @@ func showSetupTokenInstructions(containerType SupportedContainer, dashboardDomai
|
|||||||
}
|
}
|
||||||
|
|
||||||
func generateRandomSecretKey() string {
|
func generateRandomSecretKey() string {
|
||||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
secret := make([]byte, 32)
|
||||||
const length = 32
|
_, err := rand.Read(secret)
|
||||||
|
if err != nil {
|
||||||
var seededRand *rand.Rand = rand.New(
|
panic(fmt.Sprintf("Failed to generate random secret key: %v", err))
|
||||||
rand.NewSource(time.Now().UnixNano()))
|
|
||||||
|
|
||||||
b := make([]byte, length)
|
|
||||||
for i := range b {
|
|
||||||
b[i] = charset[seededRand.Intn(len(charset))]
|
|
||||||
}
|
}
|
||||||
return string(b)
|
return base64.StdEncoding.EncodeToString(secret)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getPublicIP() string {
|
func getPublicIP() string {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
"componentsMember": "You're a member of {count, plural, =0 {no organization} one {one organization} other {# organizations}}.",
|
"componentsMember": "You're a member of {count, plural, =0 {no organization} one {one organization} other {# organizations}}.",
|
||||||
"componentsInvalidKey": "Invalid or expired license keys detected. Follow license terms to continue using all features.",
|
"componentsInvalidKey": "Invalid or expired license keys detected. Follow license terms to continue using all features.",
|
||||||
"dismiss": "Dismiss",
|
"dismiss": "Dismiss",
|
||||||
|
"subscriptionViolationMessage": "You're beyond your limits for your current plan. Correct the problem by removing sites, users, or other resources to stay within your plan.",
|
||||||
|
"subscriptionViolationViewBilling": "View billing",
|
||||||
"componentsLicenseViolation": "License Violation: This server is using {usedSites} sites which exceeds its licensed limit of {maxSites} sites. Follow license terms to continue using all features.",
|
"componentsLicenseViolation": "License Violation: This server is using {usedSites} sites which exceeds its licensed limit of {maxSites} sites. Follow license terms to continue using all features.",
|
||||||
"componentsSupporterMessage": "Thank you for supporting Pangolin as a {tier}!",
|
"componentsSupporterMessage": "Thank you for supporting Pangolin as a {tier}!",
|
||||||
"inviteErrorNotValid": "We're sorry, but it looks like the invite you're trying to access has not been accepted or is no longer valid.",
|
"inviteErrorNotValid": "We're sorry, but it looks like the invite you're trying to access has not been accepted or is no longer valid.",
|
||||||
@@ -55,7 +57,7 @@
|
|||||||
"siteDescription": "Create and manage sites to enable connectivity to private networks",
|
"siteDescription": "Create and manage sites to enable connectivity to private networks",
|
||||||
"sitesBannerTitle": "Connect Any Network",
|
"sitesBannerTitle": "Connect Any Network",
|
||||||
"sitesBannerDescription": "A site is a connection to a remote network that allows Pangolin to provide access to resources, whether public or private, to users anywhere. Install the site network connector (Newt) anywhere you can run a binary or container to establish the connection.",
|
"sitesBannerDescription": "A site is a connection to a remote network that allows Pangolin to provide access to resources, whether public or private, to users anywhere. Install the site network connector (Newt) anywhere you can run a binary or container to establish the connection.",
|
||||||
"sitesBannerButtonText": "Install Site",
|
"sitesBannerButtonText": "Install Site Connector",
|
||||||
"approvalsBannerTitle": "Approve or Deny Device Access",
|
"approvalsBannerTitle": "Approve or Deny Device Access",
|
||||||
"approvalsBannerDescription": "Review and approve or deny device access requests from users. When device approvals are required, users must get admin approval before their devices can connect to your organization's resources.",
|
"approvalsBannerDescription": "Review and approve or deny device access requests from users. When device approvals are required, users must get admin approval before their devices can connect to your organization's resources.",
|
||||||
"approvalsBannerButtonText": "Learn More",
|
"approvalsBannerButtonText": "Learn More",
|
||||||
@@ -79,8 +81,8 @@
|
|||||||
"siteConfirmCopy": "I have copied the config",
|
"siteConfirmCopy": "I have copied the config",
|
||||||
"searchSitesProgress": "Search sites...",
|
"searchSitesProgress": "Search sites...",
|
||||||
"siteAdd": "Add Site",
|
"siteAdd": "Add Site",
|
||||||
"siteInstallNewt": "Install Newt",
|
"siteInstallNewt": "Install Site",
|
||||||
"siteInstallNewtDescription": "Get Newt running on your system",
|
"siteInstallNewtDescription": "Install the site connector for your system",
|
||||||
"WgConfiguration": "WireGuard Configuration",
|
"WgConfiguration": "WireGuard Configuration",
|
||||||
"WgConfigurationDescription": "Use the following configuration to connect to the network",
|
"WgConfigurationDescription": "Use the following configuration to connect to the network",
|
||||||
"operatingSystem": "Operating System",
|
"operatingSystem": "Operating System",
|
||||||
@@ -1404,10 +1406,10 @@
|
|||||||
"billingUsageLimitsOverview": "Usage Limits Overview",
|
"billingUsageLimitsOverview": "Usage Limits Overview",
|
||||||
"billingMonitorUsage": "Monitor your usage against configured limits. If you need limits increased please contact us support@pangolin.net.",
|
"billingMonitorUsage": "Monitor your usage against configured limits. If you need limits increased please contact us support@pangolin.net.",
|
||||||
"billingDataUsage": "Data Usage",
|
"billingDataUsage": "Data Usage",
|
||||||
"billingOnlineTime": "Site Online Time",
|
"billingSites": "Sites",
|
||||||
"billingUsers": "Active Users",
|
"billingUsers": "Users",
|
||||||
"billingDomains": "Active Domains",
|
"billingDomains": "Domains",
|
||||||
"billingRemoteExitNodes": "Active Self-hosted Nodes",
|
"billingRemoteExitNodes": "Remote Nodes",
|
||||||
"billingNoLimitConfigured": "No limit configured",
|
"billingNoLimitConfigured": "No limit configured",
|
||||||
"billingEstimatedPeriod": "Estimated Billing Period",
|
"billingEstimatedPeriod": "Estimated Billing Period",
|
||||||
"billingIncludedUsage": "Included Usage",
|
"billingIncludedUsage": "Included Usage",
|
||||||
@@ -1432,10 +1434,18 @@
|
|||||||
"billingFailedToGetPortalUrl": "Failed to get portal URL",
|
"billingFailedToGetPortalUrl": "Failed to get portal URL",
|
||||||
"billingPortalError": "Portal Error",
|
"billingPortalError": "Portal Error",
|
||||||
"billingDataUsageInfo": "You're charged for all data transferred through your secure tunnels when connected to the cloud. This includes both incoming and outgoing traffic across all your sites. When you reach your limit, your sites will disconnect until you upgrade your plan or reduce usage. Data is not charged when using nodes.",
|
"billingDataUsageInfo": "You're charged for all data transferred through your secure tunnels when connected to the cloud. This includes both incoming and outgoing traffic across all your sites. When you reach your limit, your sites will disconnect until you upgrade your plan or reduce usage. Data is not charged when using nodes.",
|
||||||
"billingOnlineTimeInfo": "You're charged based on how long your sites stay connected to the cloud. For example, 44,640 minutes equals one site running 24/7 for a full month. When you reach your limit, your sites will disconnect until you upgrade your plan or reduce usage. Time is not charged when using nodes.",
|
"billingSInfo": "How many sites you can use",
|
||||||
"billingUsersInfo": "You're charged for each user in the organization. Billing is calculated daily based on the number of active user accounts in your org.",
|
"billingUsersInfo": "How many users you can use",
|
||||||
"billingDomainInfo": "You're charged for each domain in the organization. Billing is calculated daily based on the number of active domain accounts in your org.",
|
"billingDomainInfo": "How many domains you can use",
|
||||||
"billingRemoteExitNodesInfo": "You're charged for each managed Node in the organization. Billing is calculated daily based on the number of active managed Nodes in your org.",
|
"billingRemoteExitNodesInfo": "How many remote nodes you can use",
|
||||||
|
"billingLicenseKeys": "License Keys",
|
||||||
|
"billingLicenseKeysDescription": "Manage your license key subscriptions",
|
||||||
|
"billingLicenseSubscription": "License Subscription",
|
||||||
|
"billingInactive": "Inactive",
|
||||||
|
"billingLicenseItem": "License Item",
|
||||||
|
"billingQuantity": "Quantity",
|
||||||
|
"billingTotal": "total",
|
||||||
|
"billingModifyLicenses": "Modify License Subscription",
|
||||||
"domainNotFound": "Domain Not Found",
|
"domainNotFound": "Domain Not Found",
|
||||||
"domainNotFoundDescription": "This resource is disabled because the domain no longer exists our system. Please set a new domain for this resource.",
|
"domainNotFoundDescription": "This resource is disabled because the domain no longer exists our system. Please set a new domain for this resource.",
|
||||||
"failed": "Failed",
|
"failed": "Failed",
|
||||||
@@ -1512,6 +1522,32 @@
|
|||||||
"resourcePortRequired": "Port number is required for non-HTTP resources",
|
"resourcePortRequired": "Port number is required for non-HTTP resources",
|
||||||
"resourcePortNotAllowed": "Port number should not be set for HTTP resources",
|
"resourcePortNotAllowed": "Port number should not be set for HTTP resources",
|
||||||
"billingPricingCalculatorLink": "Pricing Calculator",
|
"billingPricingCalculatorLink": "Pricing Calculator",
|
||||||
|
"billingYourPlan": "Your Plan",
|
||||||
|
"billingViewOrModifyPlan": "View or modify your current plan",
|
||||||
|
"billingViewPlanDetails": "View Plan Details",
|
||||||
|
"billingUsageAndLimits": "Usage and Limits",
|
||||||
|
"billingViewUsageAndLimits": "View your plan's limits and current usage",
|
||||||
|
"billingCurrentUsage": "Current Usage",
|
||||||
|
"billingMaximumLimits": "Maximum Limits",
|
||||||
|
"billingRemoteNodes": "Remote Nodes",
|
||||||
|
"billingUnlimited": "Unlimited",
|
||||||
|
"billingPaidLicenseKeys": "Paid License Keys",
|
||||||
|
"billingManageLicenseSubscription": "Manage your subscription for paid self-hosted license keys",
|
||||||
|
"billingCurrentKeys": "Current Keys",
|
||||||
|
"billingModifyCurrentPlan": "Modify Current Plan",
|
||||||
|
"billingConfirmUpgrade": "Confirm Upgrade",
|
||||||
|
"billingConfirmDowngrade": "Confirm Downgrade",
|
||||||
|
"billingConfirmUpgradeDescription": "You are about to upgrade your plan. Review the new limits and pricing below.",
|
||||||
|
"billingConfirmDowngradeDescription": "You are about to downgrade your plan. Review the new limits and pricing below.",
|
||||||
|
"billingPlanIncludes": "Plan Includes",
|
||||||
|
"billingProcessing": "Processing...",
|
||||||
|
"billingConfirmUpgradeButton": "Confirm Upgrade",
|
||||||
|
"billingConfirmDowngradeButton": "Confirm Downgrade",
|
||||||
|
"billingLimitViolationWarning": "Usage Exceeds New Plan Limits",
|
||||||
|
"billingLimitViolationDescription": "Your current usage exceeds the limits of this plan. After downgrading, all actions will be disabled until you reduce usage within the new limits. Please review the features below that are currently over the limits. Limits in violation:",
|
||||||
|
"billingFeatureLossWarning": "Feature Availability Notice",
|
||||||
|
"billingFeatureLossDescription": "By downgrading, features not available in the new plan will be automatically disabled. Some settings and configurations may be lost. Please review the pricing matrix to understand which features will no longer be available.",
|
||||||
|
"billingUsageExceedsLimit": "Current usage ({current}) exceeds limit ({limit})",
|
||||||
"signUpTerms": {
|
"signUpTerms": {
|
||||||
"IAgreeToThe": "I agree to the",
|
"IAgreeToThe": "I agree to the",
|
||||||
"termsOfService": "terms of service",
|
"termsOfService": "terms of service",
|
||||||
@@ -1536,8 +1572,8 @@
|
|||||||
"addressDescription": "The internal address of the client. Must fall within the organization's subnet.",
|
"addressDescription": "The internal address of the client. Must fall within the organization's subnet.",
|
||||||
"selectSites": "Select sites",
|
"selectSites": "Select sites",
|
||||||
"sitesDescription": "The client will have connectivity to the selected sites",
|
"sitesDescription": "The client will have connectivity to the selected sites",
|
||||||
"clientInstallOlm": "Install Olm",
|
"clientInstallOlm": "Install Machine Client",
|
||||||
"clientInstallOlmDescription": "Get Olm running on your system",
|
"clientInstallOlmDescription": "Install the machine client for your system",
|
||||||
"clientOlmCredentials": "Credentials",
|
"clientOlmCredentials": "Credentials",
|
||||||
"clientOlmCredentialsDescription": "This is how the client will authenticate with the server",
|
"clientOlmCredentialsDescription": "This is how the client will authenticate with the server",
|
||||||
"olmEndpoint": "Endpoint",
|
"olmEndpoint": "Endpoint",
|
||||||
@@ -1926,6 +1962,13 @@
|
|||||||
"orgAuthBackToSignIn": "Back to standard sign in",
|
"orgAuthBackToSignIn": "Back to standard sign in",
|
||||||
"orgAuthNoAccount": "Don't have an account?",
|
"orgAuthNoAccount": "Don't have an account?",
|
||||||
"subscriptionRequiredToUse": "A subscription is required to use this feature.",
|
"subscriptionRequiredToUse": "A subscription is required to use this feature.",
|
||||||
|
"mustUpgradeToUse": "You must upgrade your subscription to use this feature.",
|
||||||
|
"subscriptionRequiredTierToUse": "This feature requires <tierLink>{tier}</tierLink> or higher.",
|
||||||
|
"upgradeToTierToUse": "Upgrade to <tierLink>{tier}</tierLink> or higher to use this feature.",
|
||||||
|
"subscriptionTierTier1": "Home",
|
||||||
|
"subscriptionTierTier2": "Team",
|
||||||
|
"subscriptionTierTier3": "Business",
|
||||||
|
"subscriptionTierEnterprise": "Enterprise",
|
||||||
"idpDisabled": "Identity providers are disabled.",
|
"idpDisabled": "Identity providers are disabled.",
|
||||||
"orgAuthPageDisabled": "Organization auth page is disabled.",
|
"orgAuthPageDisabled": "Organization auth page is disabled.",
|
||||||
"domainRestartedDescription": "Domain verification restarted successfully",
|
"domainRestartedDescription": "Domain verification restarted successfully",
|
||||||
@@ -2113,6 +2156,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"newPricingLicenseForm": {
|
||||||
|
"title": "Get a license",
|
||||||
|
"description": "Choose a plan and tell us how you plan to use Pangolin.",
|
||||||
|
"chooseTier": "Choose your plan",
|
||||||
|
"viewPricingLink": "See pricing, features, and limits",
|
||||||
|
"tiers": {
|
||||||
|
"starter": {
|
||||||
|
"title": "Starter",
|
||||||
|
"description": "Enterprise features, 25 users, 25 sites, and community support."
|
||||||
|
},
|
||||||
|
"scale": {
|
||||||
|
"title": "Scale",
|
||||||
|
"description": "Enterprise features, 50 users, 50 sites, and priority support."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"personalUseOnly": "Personal use only (free license — no checkout)",
|
||||||
|
"buttons": {
|
||||||
|
"continueToCheckout": "Continue to Checkout"
|
||||||
|
},
|
||||||
|
"toasts": {
|
||||||
|
"checkoutError": {
|
||||||
|
"title": "Checkout error",
|
||||||
|
"description": "Could not start checkout. Please try again."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"priority": "Priority",
|
"priority": "Priority",
|
||||||
"priorityDescription": "Higher priority routes are evaluated first. Priority = 100 means automatic ordering (system decides). Use another number to enforce manual priority.",
|
"priorityDescription": "Higher priority routes are evaluated first. Priority = 100 means automatic ordering (system decides). Use another number to enforce manual priority.",
|
||||||
"instanceName": "Instance Name",
|
"instanceName": "Instance Name",
|
||||||
@@ -2212,6 +2281,7 @@
|
|||||||
"actionLogsDescription": "View a history of actions performed in this organization",
|
"actionLogsDescription": "View a history of actions performed in this organization",
|
||||||
"accessLogsDescription": "View access auth requests for resources in this organization",
|
"accessLogsDescription": "View access auth requests for resources in this organization",
|
||||||
"licenseRequiredToUse": "An Enterprise license is required to use this feature.",
|
"licenseRequiredToUse": "An Enterprise license is required to use this feature.",
|
||||||
|
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature.",
|
||||||
"certResolver": "Certificate Resolver",
|
"certResolver": "Certificate Resolver",
|
||||||
"certResolverDescription": "Select the certificate resolver to use for this resource.",
|
"certResolverDescription": "Select the certificate resolver to use for this resource.",
|
||||||
"selectCertResolver": "Select Certificate Resolver",
|
"selectCertResolver": "Select Certificate Resolver",
|
||||||
|
|||||||
2194
package-lock.json
generated
2194
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@@ -12,24 +12,26 @@
|
|||||||
"license": "SEE LICENSE IN LICENSE AND README.md",
|
"license": "SEE LICENSE IN LICENSE AND README.md",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "NODE_ENV=development ENVIRONMENT=dev tsx watch server/index.ts",
|
"dev": "NODE_ENV=development ENVIRONMENT=dev tsx watch server/index.ts",
|
||||||
|
"dev:check": "npx tsc --noEmit && npm run format:check",
|
||||||
|
"dev:setup": "cp config/config.example.yml config/config.yml && npm run set:oss && npm run set:sqlite && npm run db:generate && npm run db:sqlite:push",
|
||||||
"db:pg:generate": "drizzle-kit generate --config=./drizzle.pg.config.ts",
|
"db:pg:generate": "drizzle-kit generate --config=./drizzle.pg.config.ts",
|
||||||
"db:sqlite:generate": "drizzle-kit generate --config=./drizzle.sqlite.config.ts",
|
"db:sqlite:generate": "drizzle-kit generate --config=./drizzle.sqlite.config.ts",
|
||||||
"db:pg:push": "npx tsx server/db/pg/migrate.ts",
|
"db:pg:push": "npx tsx server/db/pg/migrate.ts",
|
||||||
"db:sqlite:push": "npx tsx server/db/sqlite/migrate.ts",
|
"db:sqlite:push": "npx tsx server/db/sqlite/migrate.ts",
|
||||||
"db:sqlite:studio": "drizzle-kit studio --config=./drizzle.sqlite.config.ts",
|
|
||||||
"db:pg:studio": "drizzle-kit studio --config=./drizzle.pg.config.ts",
|
"db:pg:studio": "drizzle-kit studio --config=./drizzle.pg.config.ts",
|
||||||
|
"db:sqlite:studio": "drizzle-kit studio --config=./drizzle.sqlite.config.ts",
|
||||||
"db:clear-migrations": "rm -rf server/migrations",
|
"db:clear-migrations": "rm -rf server/migrations",
|
||||||
"set:oss": "echo 'export const build = \"oss\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.oss.json tsconfig.json",
|
"set:oss": "echo 'export const build = \"oss\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.oss.json tsconfig.json",
|
||||||
"set:saas": "echo 'export const build = \"saas\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.saas.json tsconfig.json",
|
"set:saas": "echo 'export const build = \"saas\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.saas.json tsconfig.json",
|
||||||
"set:enterprise": "echo 'export const build = \"enterprise\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.enterprise.json tsconfig.json",
|
"set:enterprise": "echo 'export const build = \"enterprise\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.enterprise.json tsconfig.json",
|
||||||
"set:sqlite": "echo 'export * from \"./sqlite\";\nexport const driver: \"pg\" | \"sqlite\" = \"sqlite\";' > server/db/index.ts",
|
"set:sqlite": "echo 'export * from \"./sqlite\";\nexport const driver: \"pg\" | \"sqlite\" = \"sqlite\";' > server/db/index.ts && cp drizzle.sqlite.config.ts drizzle.config.ts && cp server/setup/migrationsSqlite.ts server/setup/migrations.ts",
|
||||||
"set:pg": "echo 'export * from \"./pg\";\nexport const driver: \"pg\" | \"sqlite\" = \"pg\";' > server/db/index.ts",
|
"set:pg": "echo 'export * from \"./pg\";\nexport const driver: \"pg\" | \"sqlite\" = \"pg\";' > server/db/index.ts && cp drizzle.pg.config.ts drizzle.config.ts && cp server/setup/migrationsPg.ts server/setup/migrations.ts",
|
||||||
"next:build": "next build",
|
"build:next": "next build",
|
||||||
"build:sqlite": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs",
|
"build": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrations.ts -o dist/migrations.mjs",
|
||||||
"build:pg": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs",
|
|
||||||
"start": "ENVIRONMENT=prod node dist/migrations.mjs && ENVIRONMENT=prod NODE_ENV=development node --enable-source-maps dist/server.mjs",
|
"start": "ENVIRONMENT=prod node dist/migrations.mjs && ENVIRONMENT=prod NODE_ENV=development node --enable-source-maps dist/server.mjs",
|
||||||
"email": "email dev --dir server/emails/templates --port 3005",
|
"email": "email dev --dir server/emails/templates --port 3005",
|
||||||
"build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs",
|
"build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
"format": "prettier --write ."
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -75,9 +77,7 @@
|
|||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"cmdk": "1.1.1",
|
"cmdk": "1.1.1",
|
||||||
"cookie": "1.1.1",
|
|
||||||
"cookie-parser": "1.4.7",
|
"cookie-parser": "1.4.7",
|
||||||
"cookies": "0.9.1",
|
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"crypto-js": "4.2.0",
|
"crypto-js": "4.2.0",
|
||||||
"d3": "7.9.0",
|
"d3": "7.9.0",
|
||||||
@@ -90,7 +90,6 @@
|
|||||||
"glob": "13.0.0",
|
"glob": "13.0.0",
|
||||||
"helmet": "8.1.0",
|
"helmet": "8.1.0",
|
||||||
"http-errors": "2.0.1",
|
"http-errors": "2.0.1",
|
||||||
"i": "0.3.7",
|
|
||||||
"input-otp": "1.4.2",
|
"input-otp": "1.4.2",
|
||||||
"ioredis": "5.9.2",
|
"ioredis": "5.9.2",
|
||||||
"jmespath": "0.16.0",
|
"jmespath": "0.16.0",
|
||||||
@@ -104,10 +103,7 @@
|
|||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"nextjs-toploader": "3.9.17",
|
"nextjs-toploader": "3.9.17",
|
||||||
"node-cache": "5.1.2",
|
"node-cache": "5.1.2",
|
||||||
"node-fetch": "3.3.2",
|
|
||||||
"nodemailer": "7.0.11",
|
"nodemailer": "7.0.11",
|
||||||
"npm": "11.7.0",
|
|
||||||
"nprogress": "0.2.0",
|
|
||||||
"oslo": "1.2.1",
|
"oslo": "1.2.1",
|
||||||
"pg": "8.17.1",
|
"pg": "8.17.1",
|
||||||
"posthog-node": "5.23.0",
|
"posthog-node": "5.23.0",
|
||||||
@@ -118,7 +114,6 @@
|
|||||||
"react-easy-sort": "1.8.0",
|
"react-easy-sort": "1.8.0",
|
||||||
"react-hook-form": "7.71.1",
|
"react-hook-form": "7.71.1",
|
||||||
"react-icons": "5.5.0",
|
"react-icons": "5.5.0",
|
||||||
"rebuild": "0.1.2",
|
|
||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
"reodotdev": "1.0.0",
|
"reodotdev": "1.0.0",
|
||||||
"resend": "6.8.0",
|
"resend": "6.8.0",
|
||||||
|
|||||||
@@ -82,11 +82,14 @@ export const subscriptions = pgTable("subscriptions", {
|
|||||||
canceledAt: bigint("canceledAt", { mode: "number" }),
|
canceledAt: bigint("canceledAt", { mode: "number" }),
|
||||||
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
|
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
|
||||||
updatedAt: bigint("updatedAt", { mode: "number" }),
|
updatedAt: bigint("updatedAt", { mode: "number" }),
|
||||||
billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" })
|
version: integer("version"),
|
||||||
|
billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }),
|
||||||
|
type: varchar("type", { length: 50 }) // tier1, tier2, tier3, or license
|
||||||
});
|
});
|
||||||
|
|
||||||
export const subscriptionItems = pgTable("subscriptionItems", {
|
export const subscriptionItems = pgTable("subscriptionItems", {
|
||||||
subscriptionItemId: serial("subscriptionItemId").primaryKey(),
|
subscriptionItemId: serial("subscriptionItemId").primaryKey(),
|
||||||
|
stripeSubscriptionItemId: varchar("stripeSubscriptionItemId", { length: 255 }),
|
||||||
subscriptionId: varchar("subscriptionId", { length: 255 })
|
subscriptionId: varchar("subscriptionId", { length: 255 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => subscriptions.subscriptionId, {
|
.references(() => subscriptions.subscriptionId, {
|
||||||
@@ -94,6 +97,7 @@ export const subscriptionItems = pgTable("subscriptionItems", {
|
|||||||
}),
|
}),
|
||||||
planId: varchar("planId", { length: 255 }).notNull(),
|
planId: varchar("planId", { length: 255 }).notNull(),
|
||||||
priceId: varchar("priceId", { length: 255 }),
|
priceId: varchar("priceId", { length: 255 }),
|
||||||
|
featureId: varchar("featureId", { length: 255 }),
|
||||||
meterId: varchar("meterId", { length: 255 }),
|
meterId: varchar("meterId", { length: 255 }),
|
||||||
unitAmount: real("unitAmount"),
|
unitAmount: real("unitAmount"),
|
||||||
tiers: text("tiers"),
|
tiers: text("tiers"),
|
||||||
@@ -136,6 +140,7 @@ export const limits = pgTable("limits", {
|
|||||||
})
|
})
|
||||||
.notNull(),
|
.notNull(),
|
||||||
value: real("value"),
|
value: real("value"),
|
||||||
|
override: boolean("override").default(false),
|
||||||
description: text("description")
|
description: text("description")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,9 @@ export const subscriptions = sqliteTable("subscriptions", {
|
|||||||
canceledAt: integer("canceledAt"),
|
canceledAt: integer("canceledAt"),
|
||||||
createdAt: integer("createdAt").notNull(),
|
createdAt: integer("createdAt").notNull(),
|
||||||
updatedAt: integer("updatedAt"),
|
updatedAt: integer("updatedAt"),
|
||||||
billingCycleAnchor: integer("billingCycleAnchor")
|
version: integer("version"),
|
||||||
|
billingCycleAnchor: integer("billingCycleAnchor"),
|
||||||
|
type: text("type") // tier1, tier2, tier3, or license
|
||||||
});
|
});
|
||||||
|
|
||||||
export const subscriptionItems = sqliteTable("subscriptionItems", {
|
export const subscriptionItems = sqliteTable("subscriptionItems", {
|
||||||
@@ -84,6 +86,7 @@ export const subscriptionItems = sqliteTable("subscriptionItems", {
|
|||||||
}),
|
}),
|
||||||
planId: text("planId").notNull(),
|
planId: text("planId").notNull(),
|
||||||
priceId: text("priceId"),
|
priceId: text("priceId"),
|
||||||
|
featureId: text("featureId"),
|
||||||
meterId: text("meterId"),
|
meterId: text("meterId"),
|
||||||
unitAmount: real("unitAmount"),
|
unitAmount: real("unitAmount"),
|
||||||
tiers: text("tiers"),
|
tiers: text("tiers"),
|
||||||
@@ -126,6 +129,7 @@ export const limits = sqliteTable("limits", {
|
|||||||
})
|
})
|
||||||
.notNull(),
|
.notNull(),
|
||||||
value: real("value"),
|
value: real("value"),
|
||||||
|
override: integer("override", { mode: "boolean" }).default(false),
|
||||||
description: text("description")
|
description: text("description")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
118
server/emails/templates/EnterpriseEditionKeyGenerated.tsx
Normal file
118
server/emails/templates/EnterpriseEditionKeyGenerated.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
|
||||||
|
import { themeColors } from "./lib/theme";
|
||||||
|
import {
|
||||||
|
EmailContainer,
|
||||||
|
EmailFooter,
|
||||||
|
EmailGreeting,
|
||||||
|
EmailHeading,
|
||||||
|
EmailInfoSection,
|
||||||
|
EmailLetterHead,
|
||||||
|
EmailSection,
|
||||||
|
EmailSignature,
|
||||||
|
EmailText
|
||||||
|
} from "./components/Email";
|
||||||
|
import CopyCodeBox from "./components/CopyCodeBox";
|
||||||
|
import ButtonLink from "./components/ButtonLink";
|
||||||
|
|
||||||
|
type EnterpriseEditionKeyGeneratedProps = {
|
||||||
|
keyValue: string;
|
||||||
|
personalUseOnly: boolean;
|
||||||
|
users: number;
|
||||||
|
sites: number;
|
||||||
|
modifySubscriptionLink?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EnterpriseEditionKeyGenerated = ({
|
||||||
|
keyValue,
|
||||||
|
personalUseOnly,
|
||||||
|
users,
|
||||||
|
sites,
|
||||||
|
modifySubscriptionLink
|
||||||
|
}: EnterpriseEditionKeyGeneratedProps) => {
|
||||||
|
const previewText = personalUseOnly
|
||||||
|
? "Your Enterprise Edition key for personal use is ready"
|
||||||
|
: "Thank you for your purchase — your Enterprise Edition key is ready";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
<Tailwind config={themeColors}>
|
||||||
|
<Body className="font-sans bg-gray-50">
|
||||||
|
<EmailContainer>
|
||||||
|
<EmailLetterHead />
|
||||||
|
|
||||||
|
<EmailGreeting>Hi there,</EmailGreeting>
|
||||||
|
|
||||||
|
{personalUseOnly ? (
|
||||||
|
<EmailText>
|
||||||
|
Your Enterprise Edition license key has been
|
||||||
|
generated. Qualifying users can use the
|
||||||
|
Enterprise Edition for free for{" "}
|
||||||
|
<strong>personal use only</strong>.
|
||||||
|
</EmailText>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<EmailText>
|
||||||
|
Thank you for your purchase. Your Enterprise
|
||||||
|
Edition license key is ready. Below are the
|
||||||
|
terms of your license.
|
||||||
|
</EmailText>
|
||||||
|
<EmailInfoSection
|
||||||
|
title="License details"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: "Licensed users",
|
||||||
|
value: users
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Licensed sites",
|
||||||
|
value: sites
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{modifySubscriptionLink && (
|
||||||
|
<EmailSection>
|
||||||
|
<ButtonLink
|
||||||
|
href={modifySubscriptionLink}
|
||||||
|
>
|
||||||
|
Modify subscription
|
||||||
|
</ButtonLink>
|
||||||
|
</EmailSection>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EmailSection>
|
||||||
|
<EmailText>Your license key:</EmailText>
|
||||||
|
<CopyCodeBox
|
||||||
|
text={keyValue}
|
||||||
|
hint="Copy this key and use it when activating Enterprise Edition on your Pangolin host."
|
||||||
|
/>
|
||||||
|
</EmailSection>
|
||||||
|
|
||||||
|
<EmailText>
|
||||||
|
If you need to purchase additional license keys or
|
||||||
|
modify your existing license, please reach out to
|
||||||
|
our support team at{" "}
|
||||||
|
<a
|
||||||
|
href="mailto:support@pangolin.net"
|
||||||
|
className="text-primary font-medium"
|
||||||
|
>
|
||||||
|
support@pangolin.net
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</EmailText>
|
||||||
|
|
||||||
|
<EmailFooter>
|
||||||
|
<EmailSignature />
|
||||||
|
</EmailFooter>
|
||||||
|
</EmailContainer>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EnterpriseEditionKeyGenerated;
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
export default function CopyCodeBox({ text }: { text: string }) {
|
const DEFAULT_HINT = "Copy and paste this code when prompted";
|
||||||
|
|
||||||
|
export default function CopyCodeBox({
|
||||||
|
text,
|
||||||
|
hint
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
hint?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="inline-block">
|
<div className="inline-block">
|
||||||
<div className="bg-gray-50 border border-gray-200 rounded-lg px-6 py-4 mx-auto">
|
<div className="bg-gray-50 border border-gray-200 rounded-lg px-6 py-4 mx-auto">
|
||||||
@@ -8,9 +16,7 @@ export default function CopyCodeBox({ text }: { text: string }) {
|
|||||||
{text}
|
{text}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 mt-2">
|
<p className="text-xs text-gray-500 mt-2">{hint ?? DEFAULT_HINT}</p>
|
||||||
Copy and paste this code when prompted
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,11 +105,13 @@ function getOpenApiDocumentation() {
|
|||||||
servers: [{ url: "/v1" }]
|
servers: [{ url: "/v1" }]
|
||||||
});
|
});
|
||||||
|
|
||||||
// convert to yaml and save to file
|
if (!process.env.DISABLE_GEN_OPENAPI) {
|
||||||
const outputPath = path.join(APP_PATH, "openapi.yaml");
|
// convert to yaml and save to file
|
||||||
const yamlOutput = yaml.dump(generated);
|
const outputPath = path.join(APP_PATH, "openapi.yaml");
|
||||||
fs.writeFileSync(outputPath, yamlOutput, "utf8");
|
const yamlOutput = yaml.dump(generated);
|
||||||
logger.info(`OpenAPI documentation saved to ${outputPath}`);
|
fs.writeFileSync(outputPath, yamlOutput, "utf8");
|
||||||
|
logger.info(`OpenAPI documentation saved to ${outputPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
return generated;
|
return generated;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,41 @@
|
|||||||
import Stripe from "stripe";
|
|
||||||
|
|
||||||
export enum FeatureId {
|
export enum FeatureId {
|
||||||
SITE_UPTIME = "siteUptime",
|
|
||||||
USERS = "users",
|
USERS = "users",
|
||||||
|
SITES = "sites",
|
||||||
EGRESS_DATA_MB = "egressDataMb",
|
EGRESS_DATA_MB = "egressDataMb",
|
||||||
DOMAINS = "domains",
|
DOMAINS = "domains",
|
||||||
REMOTE_EXIT_NODES = "remoteExitNodes"
|
REMOTE_EXIT_NODES = "remoteExitNodes",
|
||||||
|
TIER1 = "tier1"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FeatureMeterIds: Record<FeatureId, string> = {
|
export async function getFeatureDisplayName(featureId: FeatureId): Promise<string> {
|
||||||
[FeatureId.SITE_UPTIME]: "mtr_61Srrej5wUJuiTWgo41D3Ee2Ir7WmDLU",
|
switch (featureId) {
|
||||||
[FeatureId.USERS]: "mtr_61SrreISyIWpwUNGR41D3Ee2Ir7WmQro",
|
case FeatureId.USERS:
|
||||||
[FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW",
|
return "Users";
|
||||||
[FeatureId.DOMAINS]: "mtr_61Ss9nIKDNMw0LDRU41D3Ee2Ir7WmRPU",
|
case FeatureId.SITES:
|
||||||
[FeatureId.REMOTE_EXIT_NODES]: "mtr_61T86UXnfxTVXy9sD41D3Ee2Ir7WmFTE"
|
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> = {
|
export const FeatureMeterIdsSandbox: Partial<Record<FeatureId, string>> = {
|
||||||
[FeatureId.SITE_UPTIME]: "mtr_test_61Snh3cees4w60gv841DCpkOb237BDEu",
|
// [FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ"
|
||||||
[FeatureId.USERS]: "mtr_test_61Sn5fLtq1gSfRkyA41DCpkOb237B6au",
|
|
||||||
[FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ",
|
|
||||||
[FeatureId.DOMAINS]: "mtr_test_61SsA8qrdAlgPpFRQ41DCpkOb237BGts",
|
|
||||||
[FeatureId.REMOTE_EXIT_NODES]: "mtr_test_61T86Vqmwa3D9ra3341DCpkOb237B94K"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getFeatureMeterId(featureId: FeatureId): string {
|
export function getFeatureMeterId(featureId: FeatureId): string | undefined {
|
||||||
if (
|
if (
|
||||||
process.env.ENVIRONMENT == "prod" &&
|
process.env.ENVIRONMENT == "prod" &&
|
||||||
process.env.SANDBOX_MODE !== "true"
|
process.env.SANDBOX_MODE !== "true"
|
||||||
@@ -43,45 +54,81 @@ export function getFeatureIdByMetricId(
|
|||||||
)?.[0];
|
)?.[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FeaturePriceSet = {
|
export type FeaturePriceSet = Partial<Record<FeatureId, string>>;
|
||||||
[key in Exclude<FeatureId, FeatureId.DOMAINS>]: string;
|
|
||||||
} & {
|
export const homeLabFeaturePriceSet: FeaturePriceSet = {
|
||||||
[FeatureId.DOMAINS]?: string; // Optional since domains are not billed
|
[FeatureId.TIER1]: "price_1SzVE3D3Ee2Ir7Wm6wT5Dl3G"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const standardFeaturePriceSet: FeaturePriceSet = {
|
export const homeLabFeaturePriceSetSandbox: FeaturePriceSet = {
|
||||||
// Free tier matches the freeLimitSet
|
[FeatureId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT"
|
||||||
[FeatureId.SITE_UPTIME]: "price_1RrQc4D3Ee2Ir7WmaJGZ3MtF",
|
|
||||||
[FeatureId.USERS]: "price_1RrQeJD3Ee2Ir7WmgveP3xea",
|
|
||||||
[FeatureId.EGRESS_DATA_MB]: "price_1RrQXFD3Ee2Ir7WmvGDlgxQk",
|
|
||||||
// [FeatureId.DOMAINS]: "price_1Rz3tMD3Ee2Ir7Wm5qLeASzC",
|
|
||||||
[FeatureId.REMOTE_EXIT_NODES]: "price_1S46weD3Ee2Ir7Wm94KEHI4h"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const standardFeaturePriceSetSandbox: FeaturePriceSet = {
|
export function getHomeLabFeaturePriceSet(): FeaturePriceSet {
|
||||||
// Free tier matches the freeLimitSet
|
|
||||||
[FeatureId.SITE_UPTIME]: "price_1RefFBDCpkOb237BPrKZ8IEU",
|
|
||||||
[FeatureId.USERS]: "price_1ReNa4DCpkOb237Bc67G5muF",
|
|
||||||
[FeatureId.EGRESS_DATA_MB]: "price_1Rfp9LDCpkOb237BwuN5Oiu0",
|
|
||||||
// [FeatureId.DOMAINS]: "price_1Ryi88DCpkOb237B2D6DM80b",
|
|
||||||
[FeatureId.REMOTE_EXIT_NODES]: "price_1RyiZvDCpkOb237BXpmoIYJL"
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getStandardFeaturePriceSet(): FeaturePriceSet {
|
|
||||||
if (
|
if (
|
||||||
process.env.ENVIRONMENT == "prod" &&
|
process.env.ENVIRONMENT == "prod" &&
|
||||||
process.env.SANDBOX_MODE !== "true"
|
process.env.SANDBOX_MODE !== "true"
|
||||||
) {
|
) {
|
||||||
return standardFeaturePriceSet;
|
return homeLabFeaturePriceSet;
|
||||||
} else {
|
} else {
|
||||||
return standardFeaturePriceSetSandbox;
|
return homeLabFeaturePriceSetSandbox;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLineItems(
|
export const tier2FeaturePriceSet: FeaturePriceSet = {
|
||||||
featurePriceSet: FeaturePriceSet
|
[FeatureId.USERS]: "price_1SzVCcD3Ee2Ir7Wmn6U3KvPN"
|
||||||
): Stripe.Checkout.SessionCreateParams.LineItem[] {
|
};
|
||||||
return Object.entries(featurePriceSet).map(([featureId, priceId]) => ({
|
|
||||||
price: priceId
|
export const tier2FeaturePriceSetSandbox: FeaturePriceSet = {
|
||||||
}));
|
[FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR"
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getStarterFeaturePriceSet(): 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 getScaleFeaturePriceSet(): 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 = [
|
||||||
|
getHomeLabFeaturePriceSet(),
|
||||||
|
getStarterFeaturePriceSet(),
|
||||||
|
getScaleFeaturePriceSet()
|
||||||
|
];
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
37
server/lib/billing/licenses.ts
Normal file
37
server/lib/billing/licenses.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export enum LicenseId {
|
||||||
|
SMALL_LICENSE = "small_license",
|
||||||
|
BIG_LICENSE = "big_license"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LicensePriceSet = {
|
||||||
|
[key in LicenseId]: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const licensePriceSet: LicensePriceSet = {
|
||||||
|
// Free license matches the freeLimitSet
|
||||||
|
[LicenseId.SMALL_LICENSE]: "price_1SxKHiD3Ee2Ir7WmvtEh17A8",
|
||||||
|
[LicenseId.BIG_LICENSE]: "price_1SxKHiD3Ee2Ir7WmMUiP0H6Y"
|
||||||
|
};
|
||||||
|
|
||||||
|
export const licensePriceSetSandbox: LicensePriceSet = {
|
||||||
|
// Free license matches the freeLimitSet
|
||||||
|
// when matching license the keys closer to 0 index are matched first so list the licenses in descending order of value
|
||||||
|
[LicenseId.SMALL_LICENSE]: "price_1SxDwuDCpkOb237Bz0yTiOgN",
|
||||||
|
[LicenseId.BIG_LICENSE]: "price_1SxDy0DCpkOb237BWJxrxYkl"
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getLicensePriceSet(
|
||||||
|
environment?: string,
|
||||||
|
sandbox_mode?: boolean
|
||||||
|
): LicensePriceSet {
|
||||||
|
if (
|
||||||
|
(process.env.ENVIRONMENT == "prod" &&
|
||||||
|
process.env.SANDBOX_MODE !== "true") ||
|
||||||
|
(environment === "prod" && sandbox_mode !== true)
|
||||||
|
) {
|
||||||
|
// THIS GETS LOADED CLIENT SIDE AND SERVER SIDE
|
||||||
|
return licensePriceSet;
|
||||||
|
} else {
|
||||||
|
return licensePriceSetSandbox;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,50 +1,67 @@
|
|||||||
import { FeatureId } from "./features";
|
import { FeatureId } from "./features";
|
||||||
|
|
||||||
export type LimitSet = {
|
export type LimitSet = Partial<{
|
||||||
[key in FeatureId]: {
|
[key in FeatureId]: {
|
||||||
value: number | null; // null indicates no limit
|
value: number | null; // null indicates no limit
|
||||||
description?: string;
|
description?: string;
|
||||||
};
|
};
|
||||||
};
|
}>;
|
||||||
|
|
||||||
export const sandboxLimitSet: LimitSet = {
|
export const sandboxLimitSet: LimitSet = {
|
||||||
[FeatureId.SITE_UPTIME]: { value: 2880, description: "Sandbox limit" }, // 1 site up for 2 days
|
|
||||||
[FeatureId.USERS]: { value: 1, description: "Sandbox limit" },
|
[FeatureId.USERS]: { value: 1, description: "Sandbox limit" },
|
||||||
[FeatureId.EGRESS_DATA_MB]: { value: 1000, description: "Sandbox limit" }, // 1 GB
|
[FeatureId.SITES]: { value: 1, description: "Sandbox limit" },
|
||||||
[FeatureId.DOMAINS]: { value: 0, 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 = {
|
export const freeLimitSet: LimitSet = {
|
||||||
[FeatureId.SITE_UPTIME]: { value: 46080, description: "Free tier limit" }, // 1 site up for 32 days
|
[FeatureId.USERS]: { value: 5, description: "Starter limit" },
|
||||||
[FeatureId.USERS]: { value: 3, description: "Free tier limit" },
|
[FeatureId.SITES]: { value: 5, description: "Starter limit" },
|
||||||
[FeatureId.EGRESS_DATA_MB]: {
|
[FeatureId.DOMAINS]: { value: 5, description: "Starter limit" },
|
||||||
value: 25000,
|
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Starter limit" },
|
||||||
description: "Free tier limit"
|
|
||||||
}, // 25 GB
|
|
||||||
[FeatureId.DOMAINS]: { value: 3, description: "Free tier limit" },
|
|
||||||
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Free tier limit" }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const subscribedLimitSet: LimitSet = {
|
export const tier1LimitSet: LimitSet = {
|
||||||
[FeatureId.SITE_UPTIME]: {
|
[FeatureId.USERS]: { value: 7, description: "Home limit" },
|
||||||
value: 2232000,
|
[FeatureId.SITES]: { value: 10, description: "Home limit" },
|
||||||
description: "Contact us to increase soft limit."
|
[FeatureId.DOMAINS]: { value: 10, description: "Home limit" },
|
||||||
}, // 50 sites up for 31 days
|
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const tier2LimitSet: LimitSet = {
|
||||||
[FeatureId.USERS]: {
|
[FeatureId.USERS]: {
|
||||||
value: 150,
|
value: 100,
|
||||||
description: "Contact us to increase soft limit."
|
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]: {
|
[FeatureId.DOMAINS]: {
|
||||||
value: 25,
|
value: 50,
|
||||||
description: "Contact us to increase soft limit."
|
description: "Team limit"
|
||||||
},
|
},
|
||||||
[FeatureId.REMOTE_EXIT_NODES]: {
|
[FeatureId.REMOTE_EXIT_NODES]: {
|
||||||
value: 5,
|
value: 3,
|
||||||
description: "Contact us to increase soft limit."
|
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 { and, eq } from "drizzle-orm";
|
||||||
import { LimitSet } from "./limitSet";
|
import { LimitSet } from "./limitSet";
|
||||||
import { FeatureId } from "./features";
|
import { FeatureId } from "./features";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
class LimitService {
|
class LimitService {
|
||||||
async applyLimitSetToOrg(orgId: string, limitSet: LimitSet): Promise<void> {
|
async applyLimitSetToOrg(orgId: string, limitSet: LimitSet): Promise<void> {
|
||||||
@@ -13,6 +14,21 @@ class LimitService {
|
|||||||
for (const [featureId, entry] of limitEntries) {
|
for (const [featureId, entry] of limitEntries) {
|
||||||
const limitId = `${orgId}-${featureId}`;
|
const limitId = `${orgId}-${featureId}`;
|
||||||
const { value, description } = entry;
|
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
|
await trx
|
||||||
.insert(limits)
|
.insert(limits)
|
||||||
.values({ limitId, orgId, featureId, value, description });
|
.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 { eq, sql, and } from "drizzle-orm";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||||
import * as fs from "fs/promises";
|
|
||||||
import * as path from "path";
|
|
||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
usage,
|
usage,
|
||||||
@@ -32,11 +30,7 @@ interface StripeEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function noop() {
|
export function noop() {
|
||||||
if (
|
if (build !== "saas") {
|
||||||
build !== "saas" ||
|
|
||||||
!process.env.S3_BUCKET ||
|
|
||||||
!process.env.LOCAL_FILE_PATH
|
|
||||||
) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -44,31 +38,40 @@ export function noop() {
|
|||||||
|
|
||||||
export class UsageService {
|
export class UsageService {
|
||||||
private bucketName: string | undefined;
|
private bucketName: string | undefined;
|
||||||
private currentEventFile: string | null = null;
|
private events: StripeEvent[] = [];
|
||||||
private currentFileStartTime: number = 0;
|
private lastUploadTime: number = Date.now();
|
||||||
private eventsDir: string | undefined;
|
private isUploading: boolean = false;
|
||||||
private uploadingFiles: Set<string> = new Set();
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (noop()) {
|
if (noop()) {
|
||||||
return;
|
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.bucketName = process.env.S3_BUCKET || undefined;
|
||||||
this.initializeEventsDirectory().then(() => {
|
|
||||||
this.uploadPendingEventFilesOnStartup();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Periodically check for old event files to upload
|
// // Periodically check and upload events
|
||||||
setInterval(() => {
|
// setInterval(() => {
|
||||||
this.uploadOldEventFiles().catch((err) => {
|
// this.checkAndUploadEvents().catch((err) => {
|
||||||
logger.error("Error in periodic event file upload:", err);
|
// logger.error("Error in periodic event upload:", err);
|
||||||
});
|
// });
|
||||||
}, 30000); // every 30 seconds
|
// }, 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
|
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(
|
public async add(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
featureId: FeatureId,
|
featureId: FeatureId,
|
||||||
@@ -206,7 +130,9 @@ export class UsageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log event for Stripe
|
// 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;
|
return usage || null;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -286,7 +212,7 @@ export class UsageService {
|
|||||||
return new Date(date * 1000).toISOString().split("T")[0];
|
return new Date(date * 1000).toISOString().split("T")[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateDaily(
|
async updateCount(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
featureId: FeatureId,
|
featureId: FeatureId,
|
||||||
value?: number,
|
value?: number,
|
||||||
@@ -312,8 +238,6 @@ export class UsageService {
|
|||||||
value = this.truncateValue(value);
|
value = this.truncateValue(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = this.getTodayDateString();
|
|
||||||
|
|
||||||
let currentUsage: Usage | null = null;
|
let currentUsage: Usage | null = null;
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
@@ -327,66 +251,34 @@ export class UsageService {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (currentUsage) {
|
if (currentUsage) {
|
||||||
const lastUpdateDate = this.getDateString(
|
await trx
|
||||||
currentUsage.updatedAt
|
.update(usage)
|
||||||
);
|
.set({
|
||||||
const currentRunningTotal = currentUsage.latestValue;
|
instantaneousValue: value,
|
||||||
const lastDailyValue = currentUsage.instantaneousValue || 0;
|
updatedAt: Math.floor(Date.now() / 1000)
|
||||||
|
})
|
||||||
if (value == undefined || value === null) {
|
.where(eq(usage.usageId, usageId));
|
||||||
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));
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// First record for this meter
|
// First record for this meter
|
||||||
const meterId = getFeatureMeterId(featureId);
|
const meterId = getFeatureMeterId(featureId);
|
||||||
const truncatedValue = this.truncateValue(value || 0);
|
|
||||||
await trx.insert(usage).values({
|
await trx.insert(usage).values({
|
||||||
usageId,
|
usageId,
|
||||||
featureId,
|
featureId,
|
||||||
orgId,
|
orgId,
|
||||||
meterId,
|
meterId,
|
||||||
instantaneousValue: truncatedValue,
|
instantaneousValue: value || 0,
|
||||||
latestValue: truncatedValue,
|
latestValue: value || 0,
|
||||||
updatedAt: Math.floor(Date.now() / 1000)
|
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) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Failed to update daily usage for ${orgId}/${featureId}:`,
|
`Failed to update count usage for ${orgId}/${featureId}:`,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -450,121 +342,58 @@ export class UsageService {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.writeEventToFile(event);
|
this.addEventToMemory(event);
|
||||||
await this.checkAndUploadFile();
|
await this.checkAndUploadEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async writeEventToFile(event: StripeEvent): Promise<void> {
|
private addEventToMemory(event: StripeEvent): void {
|
||||||
if (!this.eventsDir || !this.bucketName) {
|
if (!this.bucketName) {
|
||||||
logger.warn(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.currentEventFile) {
|
this.events.push(event);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async checkAndUploadFile(): Promise<void> {
|
private async checkAndUploadEvents(): Promise<void> {
|
||||||
if (!this.currentEventFile) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const fileAge = now - this.currentFileStartTime;
|
const timeSinceLastUpload = now - this.lastUploadTime;
|
||||||
|
|
||||||
// Check if file is at least 1 minute old
|
// Check if at least 1 minute has passed since last upload
|
||||||
if (fileAge >= 60000) {
|
if (timeSinceLastUpload >= 60000 && this.events.length > 0) {
|
||||||
// 60 seconds
|
await this.uploadEventsToS3();
|
||||||
await this.uploadFileToS3();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async uploadFileToS3(): Promise<void> {
|
private async uploadEventsToS3(): Promise<void> {
|
||||||
if (!this.bucketName || !this.eventsDir) {
|
if (!this.bucketName) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
"Stripe local file path or bucket name is not configured, skipping S3 upload."
|
"S3 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`
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark file as being uploaded
|
if (this.events.length === 0) {
|
||||||
this.uploadingFiles.add(fileName);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already uploading
|
||||||
|
if (this.isUploading) {
|
||||||
|
logger.debug("Already uploading events, skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isUploading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if file exists before trying to read it
|
// Take a snapshot of current events and clear the array
|
||||||
try {
|
const eventsToUpload = [...this.events];
|
||||||
await fs.access(filePath);
|
this.events = [];
|
||||||
} catch (error) {
|
this.lastUploadTime = Date.now();
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if file exists and has content
|
const fileName = this.generateEventFileName();
|
||||||
const fileContent = await fs.readFile(filePath, "utf-8");
|
const fileContent = JSON.stringify(eventsToUpload, null, 2);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload to S3
|
// Upload to S3
|
||||||
const uploadCommand = new PutObjectCommand({
|
const uploadCommand = new PutObjectCommand({
|
||||||
@@ -576,29 +405,15 @@ export class UsageService {
|
|||||||
|
|
||||||
await s3Client.send(uploadCommand);
|
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(
|
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) {
|
} 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 {
|
} finally {
|
||||||
// Always remove from uploading set
|
this.isUploading = false;
|
||||||
this.uploadingFiles.delete(fileName);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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> {
|
public async forceUpload(): Promise<void> {
|
||||||
await this.uploadFileToS3();
|
if (this.events.length > 0) {
|
||||||
}
|
// Force upload regardless of time
|
||||||
|
this.lastUploadTime = 0; // Reset to force upload
|
||||||
/**
|
await this.uploadEventsToS3();
|
||||||
* 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async checkLimitSet(
|
public async checkLimitSet(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
kickSites = false,
|
|
||||||
featureId?: FeatureId,
|
featureId?: FeatureId,
|
||||||
usage?: Usage,
|
usage?: Usage,
|
||||||
trx: Transaction | typeof db = db
|
trx: Transaction | typeof db = db
|
||||||
@@ -879,58 +581,6 @@ export class UsageService {
|
|||||||
break; // Exit early if any limit is exceeded
|
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) {
|
} catch (error) {
|
||||||
logger.error(`Error checking limits for org ${orgId}:`, 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 { hashPassword } from "@server/auth/password";
|
||||||
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
import { build } from "@server/build";
|
import { tierMatrix } from "../billing/tierMatrix";
|
||||||
|
|
||||||
export type ProxyResourcesResults = {
|
export type ProxyResourcesResults = {
|
||||||
proxyResource: Resource;
|
proxyResource: Resource;
|
||||||
@@ -212,7 +212,7 @@ export async function updateProxyResources(
|
|||||||
} else {
|
} else {
|
||||||
// Update existing resource
|
// Update existing resource
|
||||||
|
|
||||||
const isLicensed = await isLicensedOrSubscribed(orgId);
|
const isLicensed = await isLicensedOrSubscribed(orgId, tierMatrix.maintencePage);
|
||||||
if (!isLicensed) {
|
if (!isLicensed) {
|
||||||
resourceData.maintenance = undefined;
|
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) {
|
if (!isLicensed) {
|
||||||
resourceData.maintenance = undefined;
|
resourceData.maintenance = undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { sendTerminateClient } from "@server/routers/client/terminate";
|
|||||||
import { and, eq, notInArray, type InferInsertModel } from "drizzle-orm";
|
import { and, eq, notInArray, type InferInsertModel } from "drizzle-orm";
|
||||||
import { rebuildClientAssociationsFromClient } from "./rebuildClientAssociations";
|
import { rebuildClientAssociationsFromClient } from "./rebuildClientAssociations";
|
||||||
import { OlmErrorCodes } from "@server/routers/olm/error";
|
import { OlmErrorCodes } from "@server/routers/olm/error";
|
||||||
|
import { tierMatrix } from "./billing/tierMatrix";
|
||||||
|
|
||||||
export async function calculateUserClientsForOrgs(
|
export async function calculateUserClientsForOrgs(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -189,7 +190,8 @@ export async function calculateUserClientsForOrgs(
|
|||||||
const niceId = await getUniqueClientName(orgId);
|
const niceId = await getUniqueClientName(orgId);
|
||||||
|
|
||||||
const isOrgLicensed = await isLicensedOrSubscribed(
|
const isOrgLicensed = await isLicensedOrSubscribed(
|
||||||
userOrg.orgId
|
userOrg.orgId,
|
||||||
|
tierMatrix.deviceApprovals
|
||||||
);
|
);
|
||||||
const requireApproval =
|
const requireApproval =
|
||||||
build !== "oss" &&
|
build !== "oss" &&
|
||||||
|
|||||||
@@ -107,6 +107,11 @@ export class Config {
|
|||||||
process.env.MAXMIND_ASN_PATH = parsedConfig.server.maxmind_asn_path;
|
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;
|
this.rawConfig = parsedConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ export async function createUserAccountOrg(
|
|||||||
const customerId = await createCustomer(orgId, userEmail);
|
const customerId = await createCustomer(orgId, userEmail);
|
||||||
|
|
||||||
if (customerId) {
|
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 {
|
return {
|
||||||
|
|||||||
3
server/lib/getEnvOrYaml.ts
Normal file
3
server/lib/getEnvOrYaml.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => {
|
||||||
|
return process.env[envVar] ?? valFromYaml;
|
||||||
|
};
|
||||||
@@ -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;
|
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;
|
||||||
|
}
|
||||||
@@ -3,13 +3,10 @@ import yaml from "js-yaml";
|
|||||||
import { configFilePath1, configFilePath2 } from "./consts";
|
import { configFilePath1, configFilePath2 } from "./consts";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import stoi from "./stoi";
|
import stoi from "./stoi";
|
||||||
|
import { getEnvOrYaml } from "./getEnvOrYaml";
|
||||||
|
|
||||||
const portSchema = z.number().positive().gt(0).lte(65535);
|
const portSchema = z.number().positive().gt(0).lte(65535);
|
||||||
|
|
||||||
const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => {
|
|
||||||
return process.env[envVar] ?? valFromYaml;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const configSchema = z
|
export const configSchema = z
|
||||||
.object({
|
.object({
|
||||||
app: z
|
app: z
|
||||||
@@ -311,7 +308,10 @@ export const configSchema = z
|
|||||||
.object({
|
.object({
|
||||||
smtp_host: z.string().optional(),
|
smtp_host: z.string().optional(),
|
||||||
smtp_port: portSchema.optional(),
|
smtp_port: portSchema.optional(),
|
||||||
smtp_user: z.string().optional(),
|
smtp_user: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform(getEnvOrYaml("EMAIL_SMTP_USER")),
|
||||||
smtp_pass: z
|
smtp_pass: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -331,7 +331,8 @@ export const configSchema = z
|
|||||||
disable_local_sites: z.boolean().optional(),
|
disable_local_sites: z.boolean().optional(),
|
||||||
disable_basic_wireguard_sites: z.boolean().optional(),
|
disable_basic_wireguard_sites: z.boolean().optional(),
|
||||||
disable_config_managed_domains: 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(),
|
.optional(),
|
||||||
dns: z
|
dns: z
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ export type LicenseStatus = {
|
|||||||
isLicenseValid: boolean; // Is the license key valid?
|
isLicenseValid: boolean; // Is the license key valid?
|
||||||
hostId: string; // Host ID
|
hostId: string; // Host ID
|
||||||
tier?: LicenseKeyTier;
|
tier?: LicenseKeyTier;
|
||||||
|
maxSites?: number;
|
||||||
|
usedSites?: number;
|
||||||
|
maxUsers?: number;
|
||||||
|
usedUsers?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LicenseKeyCache = {
|
export type LicenseKeyCache = {
|
||||||
@@ -22,12 +26,14 @@ export type LicenseKeyCache = {
|
|||||||
type?: LicenseKeyType;
|
type?: LicenseKeyType;
|
||||||
tier?: LicenseKeyTier;
|
tier?: LicenseKeyTier;
|
||||||
terminateAt?: Date;
|
terminateAt?: Date;
|
||||||
|
quantity?: number;
|
||||||
|
quantity_2?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class License {
|
export class License {
|
||||||
private serverSecret!: string;
|
private serverSecret!: string;
|
||||||
|
|
||||||
constructor(private hostMeta: HostMeta) {}
|
constructor(private hostMeta: HostMeta) { }
|
||||||
|
|
||||||
public async check(): Promise<LicenseStatus> {
|
public async check(): Promise<LicenseStatus> {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -29,3 +29,4 @@ export * from "./verifyUserIsOrgOwner";
|
|||||||
export * from "./verifySiteResourceAccess";
|
export * from "./verifySiteResourceAccess";
|
||||||
export * from "./logActionAudit";
|
export * from "./logActionAudit";
|
||||||
export * from "./verifyOlmAccess";
|
export * from "./verifyOlmAccess";
|
||||||
|
export * from "./verifyLimits";
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { apiKeyOrg } from "@server/db";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import logger from "@server/logger";
|
|
||||||
|
|
||||||
export async function verifyApiKeyOrgAccess(
|
export async function verifyApiKeyOrgAccess(
|
||||||
req: Request,
|
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,36 +11,59 @@
|
|||||||
* This file is not licensed under the AGPLv3.
|
* This file is not licensed under the AGPLv3.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getTierPriceSet } from "@server/lib/billing/tiers";
|
|
||||||
import { getOrgSubscriptionData } from "#private/routers/billing/getOrgSubscription";
|
|
||||||
import { build } from "@server/build";
|
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(
|
export async function getOrgTierData(
|
||||||
orgId: string
|
orgId: string
|
||||||
): Promise<{ tier: string | null; active: boolean }> {
|
): Promise<{ tier: Tier | null; active: boolean }> {
|
||||||
let tier = null;
|
let tier: Tier | null = null;
|
||||||
let active = false;
|
let active = false;
|
||||||
|
|
||||||
if (build !== "saas") {
|
if (build !== "saas") {
|
||||||
return { tier, active };
|
return { tier, active };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { subscription, items } = await getOrgSubscriptionData(orgId);
|
try {
|
||||||
|
// Get customer for org
|
||||||
|
const [customer] = await db
|
||||||
|
.select()
|
||||||
|
.from(customers)
|
||||||
|
.where(eq(customers.orgId, orgId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
if (items && items.length > 0) {
|
if (customer) {
|
||||||
const tierPriceSet = getTierPriceSet();
|
// Query for active subscriptions that are not license type
|
||||||
// Iterate through tiers in order (earlier keys are higher tiers)
|
const [subscription] = await db
|
||||||
for (const [tierId, priceId] of Object.entries(tierPriceSet)) {
|
.select()
|
||||||
// Check if any subscription item matches this tier's price ID
|
.from(subscriptions)
|
||||||
const matchingItem = items.find((item) => item.priceId === priceId);
|
.where(
|
||||||
if (matchingItem) {
|
and(
|
||||||
tier = tierId;
|
eq(subscriptions.customerId, customer.customerId),
|
||||||
break;
|
eq(subscriptions.status, "active"),
|
||||||
|
ne(subscriptions.type, "license")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If org not found or error occurs, return null tier and inactive
|
||||||
|
// This is acceptable behavior as per the function signature
|
||||||
}
|
}
|
||||||
if (subscription && subscription.status === "active") {
|
|
||||||
active = true;
|
|
||||||
}
|
|
||||||
return { tier, active };
|
return { tier, active };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import * as fs from "fs";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import cache from "@server/lib/cache";
|
import cache from "@server/lib/cache";
|
||||||
|
|
||||||
let encryptionKeyPath = "";
|
|
||||||
let encryptionKeyHex = "";
|
let encryptionKeyHex = "";
|
||||||
let encryptionKey: Buffer;
|
let encryptionKey: Buffer;
|
||||||
function loadEncryptData() {
|
function loadEncryptData() {
|
||||||
@@ -27,15 +26,7 @@ function loadEncryptData() {
|
|||||||
return; // already loaded
|
return; // already loaded
|
||||||
}
|
}
|
||||||
|
|
||||||
encryptionKeyPath = config.getRawPrivateConfig().server.encryption_key_path;
|
encryptionKeyHex = config.getRawPrivateConfig().server.encryption_key;
|
||||||
|
|
||||||
if (!fs.existsSync(encryptionKeyPath)) {
|
|
||||||
throw new Error(
|
|
||||||
"Encryption key file not found. Please generate one first."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
encryptionKeyHex = fs.readFileSync(encryptionKeyPath, "utf8").trim();
|
|
||||||
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
|
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,6 @@
|
|||||||
|
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { db, Org, orgs, ResourceSession, sessions, users } from "@server/db";
|
import { db, Org, orgs, ResourceSession, sessions, users } from "@server/db";
|
||||||
import { getOrgTierData } from "#private/lib/billing";
|
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
import license from "#private/license/license";
|
import license from "#private/license/license";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
@@ -80,6 +78,8 @@ export async function checkOrgAccessPolicy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: check that the org is subscribed
|
||||||
|
|
||||||
// get the needed data
|
// get the needed data
|
||||||
|
|
||||||
if (!props.org) {
|
if (!props.org) {
|
||||||
|
|||||||
@@ -125,16 +125,6 @@ export class PrivateConfig {
|
|||||||
this.rawPrivateConfig.server.reo_client_id;
|
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) {
|
if (this.rawPrivateConfig.flags.use_pangolin_dns) {
|
||||||
process.env.USE_PANGOLIN_DNS =
|
process.env.USE_PANGOLIN_DNS =
|
||||||
this.rawPrivateConfig.flags.use_pangolin_dns.toString();
|
this.rawPrivateConfig.flags.use_pangolin_dns.toString();
|
||||||
|
|||||||
@@ -13,17 +13,19 @@
|
|||||||
|
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import license from "#private/license/license";
|
import license from "#private/license/license";
|
||||||
import { getOrgTierData } from "#private/lib/billing";
|
import { isSubscribed } from "#private/lib/isSubscribed";
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
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") {
|
if (build === "enterprise") {
|
||||||
return await license.isUnlocked();
|
return await license.isUnlocked();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (build === "saas") {
|
if (build === "saas") {
|
||||||
const { tier } = await getOrgTierData(orgId);
|
return isSubscribed(orgId, tiers);
|
||||||
return tier === TierId.STANDARD;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
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;
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { privateConfigFilePath1 } from "@server/lib/consts";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { colorsSchema } from "@server/lib/colorsSchema";
|
import { colorsSchema } from "@server/lib/colorsSchema";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import { getEnvOrYaml } from "@server/lib/getEnvOrYaml";
|
||||||
|
|
||||||
const portSchema = z.number().positive().gt(0).lte(65535);
|
const portSchema = z.number().positive().gt(0).lte(65535);
|
||||||
|
|
||||||
@@ -32,19 +33,29 @@ export const privateConfigSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
server: z
|
server: z
|
||||||
.object({
|
.object({
|
||||||
encryption_key_path: z
|
encryption_key: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.default("./config/encryption.pem")
|
.transform(getEnvOrYaml("SERVER_ENCRYPTION_KEY")),
|
||||||
.pipe(z.string().min(8)),
|
resend_api_key: z
|
||||||
resend_api_key: z.string().optional(),
|
.string()
|
||||||
reo_client_id: z.string().optional(),
|
.optional()
|
||||||
fossorial_api_key: z.string().optional()
|
.transform(getEnvOrYaml("RESEND_API_KEY")),
|
||||||
|
reo_client_id: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform(getEnvOrYaml("REO_CLIENT_ID")),
|
||||||
|
fossorial_api: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("https://api.fossorial.io"),
|
||||||
|
fossorial_api_key: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform(getEnvOrYaml("FOSSORIAL_API_KEY"))
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.default({
|
.prefault({}),
|
||||||
encryption_key_path: "./config/encryption.pem"
|
|
||||||
}),
|
|
||||||
redis: z
|
redis: z
|
||||||
.object({
|
.object({
|
||||||
host: z.string(),
|
host: z.string(),
|
||||||
@@ -84,7 +95,7 @@ export const privateConfigSchema = z.object({
|
|||||||
.object({
|
.object({
|
||||||
enable_redis: z.boolean().optional().default(false),
|
enable_redis: z.boolean().optional().default(false),
|
||||||
use_pangolin_dns: 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().default(false),
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({}),
|
.prefault({}),
|
||||||
@@ -157,11 +168,17 @@ export const privateConfigSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
stripe: z
|
stripe: z
|
||||||
.object({
|
.object({
|
||||||
secret_key: z.string(),
|
secret_key: z
|
||||||
webhook_secret: z.string(),
|
.string()
|
||||||
s3Bucket: z.string(),
|
.optional()
|
||||||
s3Region: z.string().default("us-east-1"),
|
.transform(getEnvOrYaml("STRIPE_SECRET_KEY")),
|
||||||
localFilePath: z.string()
|
webhook_secret: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform(getEnvOrYaml("STRIPE_WEBHOOK_SECRET")),
|
||||||
|
// s3Bucket: z.string(),
|
||||||
|
// s3Region: z.string().default("us-east-1"),
|
||||||
|
// localFilePath: z.string().optional()
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,12 +11,12 @@
|
|||||||
* This file is not licensed under the AGPLv3.
|
* This file is not licensed under the AGPLv3.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { db, HostMeta } from "@server/db";
|
import { db, HostMeta, sites, users } from "@server/db";
|
||||||
import { hostMeta, licenseKey } from "@server/db";
|
import { hostMeta, licenseKey } from "@server/db";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import NodeCache from "node-cache";
|
import NodeCache from "node-cache";
|
||||||
import { validateJWT } from "./licenseJwt";
|
import { validateJWT } from "./licenseJwt";
|
||||||
import { eq } from "drizzle-orm";
|
import { count, eq } from "drizzle-orm";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { encrypt, decrypt } from "@server/lib/crypto";
|
import { encrypt, decrypt } from "@server/lib/crypto";
|
||||||
import {
|
import {
|
||||||
@@ -54,6 +54,7 @@ type TokenPayload = {
|
|||||||
type: LicenseKeyType;
|
type: LicenseKeyType;
|
||||||
tier: LicenseKeyTier;
|
tier: LicenseKeyTier;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
|
quantity_2: number;
|
||||||
terminateAt: string; // ISO
|
terminateAt: string; // ISO
|
||||||
iat: number; // Issued at
|
iat: number; // Issued at
|
||||||
};
|
};
|
||||||
@@ -140,10 +141,20 @@ LQIDAQAB
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Count used sites and users for license comparison
|
||||||
|
const [siteCountRes] = await db
|
||||||
|
.select({ value: count() })
|
||||||
|
.from(sites);
|
||||||
|
const [userCountRes] = await db
|
||||||
|
.select({ value: count() })
|
||||||
|
.from(users);
|
||||||
|
|
||||||
const status: LicenseStatus = {
|
const status: LicenseStatus = {
|
||||||
hostId: this.hostMeta.hostMetaId,
|
hostId: this.hostMeta.hostMetaId,
|
||||||
isHostLicensed: true,
|
isHostLicensed: true,
|
||||||
isLicenseValid: false
|
isLicenseValid: false,
|
||||||
|
usedSites: siteCountRes?.value ?? 0,
|
||||||
|
usedUsers: userCountRes?.value ?? 0
|
||||||
};
|
};
|
||||||
|
|
||||||
this.checkInProgress = true;
|
this.checkInProgress = true;
|
||||||
@@ -151,6 +162,8 @@ LQIDAQAB
|
|||||||
try {
|
try {
|
||||||
if (!this.doRecheck && this.statusCache.has(this.statusKey)) {
|
if (!this.doRecheck && this.statusCache.has(this.statusKey)) {
|
||||||
const res = this.statusCache.get("status") as LicenseStatus;
|
const res = this.statusCache.get("status") as LicenseStatus;
|
||||||
|
res.usedSites = status.usedSites;
|
||||||
|
res.usedUsers = status.usedUsers;
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
logger.debug("Checking license status...");
|
logger.debug("Checking license status...");
|
||||||
@@ -193,7 +206,9 @@ LQIDAQAB
|
|||||||
type: payload.type,
|
type: payload.type,
|
||||||
tier: payload.tier,
|
tier: payload.tier,
|
||||||
iat: new Date(payload.iat * 1000),
|
iat: new Date(payload.iat * 1000),
|
||||||
terminateAt: new Date(payload.terminateAt)
|
terminateAt: new Date(payload.terminateAt),
|
||||||
|
quantity: payload.quantity,
|
||||||
|
quantity_2: payload.quantity_2
|
||||||
});
|
});
|
||||||
|
|
||||||
if (payload.type === "host") {
|
if (payload.type === "host") {
|
||||||
@@ -292,6 +307,8 @@ LQIDAQAB
|
|||||||
cached.tier = payload.tier;
|
cached.tier = payload.tier;
|
||||||
cached.iat = new Date(payload.iat * 1000);
|
cached.iat = new Date(payload.iat * 1000);
|
||||||
cached.terminateAt = new Date(payload.terminateAt);
|
cached.terminateAt = new Date(payload.terminateAt);
|
||||||
|
cached.quantity = payload.quantity;
|
||||||
|
cached.quantity_2 = payload.quantity_2;
|
||||||
|
|
||||||
// Encrypt the updated token before storing
|
// Encrypt the updated token before storing
|
||||||
const encryptedKey = encrypt(
|
const encryptedKey = encrypt(
|
||||||
@@ -317,7 +334,7 @@ LQIDAQAB
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute host status
|
// Compute host status: quantity = users, quantity_2 = sites
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const cached = newCache.get(key.licenseKey)!;
|
const cached = newCache.get(key.licenseKey)!;
|
||||||
|
|
||||||
@@ -329,6 +346,28 @@ LQIDAQAB
|
|||||||
if (!cached.valid) {
|
if (!cached.valid) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only consider quantity if defined and >= 0 (quantity = users, quantity_2 = sites)
|
||||||
|
if (
|
||||||
|
cached.quantity_2 !== undefined &&
|
||||||
|
cached.quantity_2 >= 0
|
||||||
|
) {
|
||||||
|
status.maxSites =
|
||||||
|
(status.maxSites ?? 0) + cached.quantity_2;
|
||||||
|
}
|
||||||
|
if (cached.quantity !== undefined && cached.quantity >= 0) {
|
||||||
|
status.maxUsers = (status.maxUsers ?? 0) + cached.quantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate license if over user or site limits
|
||||||
|
if (
|
||||||
|
(status.maxSites !== undefined &&
|
||||||
|
(status.usedSites ?? 0) > status.maxSites) ||
|
||||||
|
(status.maxUsers !== undefined &&
|
||||||
|
(status.usedUsers ?? 0) > status.maxUsers)
|
||||||
|
) {
|
||||||
|
status.isLicenseValid = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate old cache and set new cache
|
// Invalidate old cache and set new cache
|
||||||
@@ -502,7 +541,7 @@ LQIDAQAB
|
|||||||
// Calculate exponential backoff delay
|
// Calculate exponential backoff delay
|
||||||
const retryDelay = Math.floor(
|
const retryDelay = Math.floor(
|
||||||
initialRetryDelay *
|
initialRetryDelay *
|
||||||
Math.pow(exponentialFactor, attempt - 1)
|
Math.pow(exponentialFactor, attempt - 1)
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|||||||
@@ -16,46 +16,61 @@ import createHttpError from "http-errors";
|
|||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { getOrgTierData } from "#private/lib/billing";
|
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();
|
return next();
|
||||||
}
|
} catch (e) {
|
||||||
|
|
||||||
const orgId = req.params.orgId || req.body.orgId || req.query.orgId || req.userOrgId;
|
|
||||||
|
|
||||||
if (!orgId) {
|
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
"Organization ID is required to verify subscription"
|
"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 type { Request, Response, NextFunction } from "express";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { getOrgTierData } from "#private/lib/billing";
|
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
import {
|
import {
|
||||||
approvals,
|
approvals,
|
||||||
clients,
|
clients,
|
||||||
@@ -221,19 +219,6 @@ export async function listApprovals(
|
|||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
if (build === "saas") {
|
|
||||||
const { tier } = await getOrgTierData(orgId);
|
|
||||||
const subscribed = tier === TierId.STANDARD;
|
|
||||||
if (!subscribed) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.FORBIDDEN,
|
|
||||||
"This organization's current plan does not support this feature."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const approvalsList = await queryApprovals(
|
const approvalsList = await queryApprovals(
|
||||||
orgId.toString(),
|
orgId.toString(),
|
||||||
limit,
|
limit,
|
||||||
|
|||||||
@@ -17,10 +17,7 @@ import createHttpError from "http-errors";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
import { build } from "@server/build";
|
|
||||||
import { approvals, clients, db, orgs, type Approval } from "@server/db";
|
import { approvals, clients, db, orgs, type Approval } from "@server/db";
|
||||||
import { getOrgTierData } from "#private/lib/billing";
|
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import { and, eq, type InferInsertModel } from "drizzle-orm";
|
import { and, eq, type InferInsertModel } from "drizzle-orm";
|
||||||
import type { NextFunction, Request, Response } from "express";
|
import type { NextFunction, Request, Response } from "express";
|
||||||
@@ -64,20 +61,6 @@ export async function processPendingApproval(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { orgId, approvalId } = parsedParams.data;
|
const { orgId, approvalId } = parsedParams.data;
|
||||||
|
|
||||||
if (build === "saas") {
|
|
||||||
const { tier } = await getOrgTierData(orgId);
|
|
||||||
const subscribed = tier === TierId.STANDARD;
|
|
||||||
if (!subscribed) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.FORBIDDEN,
|
|
||||||
"This organization's current plan does not support this feature."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateData = parsedBody.data;
|
const updateData = parsedBody.data;
|
||||||
|
|
||||||
const approval = await db
|
const approval = await db
|
||||||
|
|||||||
@@ -13,4 +13,3 @@
|
|||||||
|
|
||||||
export * from "./transferSession";
|
export * from "./transferSession";
|
||||||
export * from "./getSessionTransferToken";
|
export * from "./getSessionTransferToken";
|
||||||
export * from "./quickStart";
|
|
||||||
|
|||||||
@@ -1,585 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { NextFunction, Request, Response } from "express";
|
|
||||||
import {
|
|
||||||
account,
|
|
||||||
db,
|
|
||||||
domainNamespaces,
|
|
||||||
domains,
|
|
||||||
exitNodes,
|
|
||||||
newts,
|
|
||||||
newtSessions,
|
|
||||||
orgs,
|
|
||||||
passwordResetTokens,
|
|
||||||
Resource,
|
|
||||||
resourcePassword,
|
|
||||||
resourcePincode,
|
|
||||||
resources,
|
|
||||||
resourceWhitelist,
|
|
||||||
roleResources,
|
|
||||||
roles,
|
|
||||||
roleSites,
|
|
||||||
sites,
|
|
||||||
targetHealthCheck,
|
|
||||||
targets,
|
|
||||||
userResources,
|
|
||||||
userSites
|
|
||||||
} from "@server/db";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { users } from "@server/db";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import response from "@server/lib/response";
|
|
||||||
import { SqliteError } from "better-sqlite3";
|
|
||||||
import { eq, and, sql } from "drizzle-orm";
|
|
||||||
import moment from "moment";
|
|
||||||
import { generateId } from "@server/auth/sessions/app";
|
|
||||||
import config from "@server/lib/config";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { hashPassword } from "@server/auth/password";
|
|
||||||
import { UserType } from "@server/types/UserTypes";
|
|
||||||
import { createUserAccountOrg } from "@server/lib/createUserAccountOrg";
|
|
||||||
import { sendEmail } from "@server/emails";
|
|
||||||
import WelcomeQuickStart from "@server/emails/templates/WelcomeQuickStart";
|
|
||||||
import { alphabet, generateRandomString } from "oslo/crypto";
|
|
||||||
import { createDate, TimeSpan } from "oslo";
|
|
||||||
import { getUniqueResourceName, getUniqueSiteName } from "@server/db/names";
|
|
||||||
import { pickPort } from "@server/routers/target/helpers";
|
|
||||||
import { addTargets } from "@server/routers/newt/targets";
|
|
||||||
import { isTargetValid } from "@server/lib/validators";
|
|
||||||
import { listExitNodes } from "#private/lib/exitNodes";
|
|
||||||
|
|
||||||
const bodySchema = z.object({
|
|
||||||
email: z.email().toLowerCase(),
|
|
||||||
ip: z.string().refine(isTargetValid),
|
|
||||||
method: z.enum(["http", "https"]),
|
|
||||||
port: z.int().min(1).max(65535),
|
|
||||||
pincode: z
|
|
||||||
.string()
|
|
||||||
.regex(/^\d{6}$/)
|
|
||||||
.optional(),
|
|
||||||
password: z.string().min(4).max(100).optional(),
|
|
||||||
enableWhitelist: z.boolean().optional().default(true),
|
|
||||||
animalId: z.string() // This is actually the secret key for the backend
|
|
||||||
});
|
|
||||||
|
|
||||||
export type QuickStartBody = z.infer<typeof bodySchema>;
|
|
||||||
|
|
||||||
export type QuickStartResponse = {
|
|
||||||
newtId: string;
|
|
||||||
newtSecret: string;
|
|
||||||
resourceUrl: string;
|
|
||||||
completeSignUpLink: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEMO_UBO_KEY = "b460293f-347c-4b30-837d-4e06a04d5a22";
|
|
||||||
|
|
||||||
export async function quickStart(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<any> {
|
|
||||||
const parsedBody = bodySchema.safeParse(req.body);
|
|
||||||
|
|
||||||
if (!parsedBody.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedBody.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
email,
|
|
||||||
ip,
|
|
||||||
method,
|
|
||||||
port,
|
|
||||||
pincode,
|
|
||||||
password,
|
|
||||||
enableWhitelist,
|
|
||||||
animalId
|
|
||||||
} = parsedBody.data;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const tokenValidation = validateTokenOnApi(animalId);
|
|
||||||
|
|
||||||
if (!tokenValidation.isValid) {
|
|
||||||
logger.warn(
|
|
||||||
`Quick start failed for ${email} token ${animalId}: ${tokenValidation.message}`
|
|
||||||
);
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Invalid or expired token"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (animalId === DEMO_UBO_KEY) {
|
|
||||||
if (email !== "mehrdad@getubo.com") {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Invalid email for demo Ubo key"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existing] = await db
|
|
||||||
.select()
|
|
||||||
.from(users)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(users.email, email),
|
|
||||||
eq(users.type, UserType.Internal)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
// delete the user if it already exists
|
|
||||||
await db.delete(users).where(eq(users.userId, existing.userId));
|
|
||||||
const orgId = `org_${existing.userId}`;
|
|
||||||
await db.delete(orgs).where(eq(orgs.orgId, orgId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const tempPassword = generateId(15);
|
|
||||||
const passwordHash = await hashPassword(tempPassword);
|
|
||||||
const userId = generateId(15);
|
|
||||||
|
|
||||||
// TODO: see if that user already exists?
|
|
||||||
|
|
||||||
// Create the sandbox user
|
|
||||||
const existing = await db
|
|
||||||
.select()
|
|
||||||
.from(users)
|
|
||||||
.where(
|
|
||||||
and(eq(users.email, email), eq(users.type, UserType.Internal))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existing && existing.length > 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"A user with that email address already exists"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let newtId: string;
|
|
||||||
let secret: string;
|
|
||||||
let fullDomain: string;
|
|
||||||
let resource: Resource;
|
|
||||||
let completeSignUpLink: string;
|
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
|
||||||
await trx.insert(users).values({
|
|
||||||
userId: userId,
|
|
||||||
type: UserType.Internal,
|
|
||||||
username: email,
|
|
||||||
email: email,
|
|
||||||
passwordHash,
|
|
||||||
dateCreated: moment().toISOString()
|
|
||||||
});
|
|
||||||
|
|
||||||
// create user"s account
|
|
||||||
await trx.insert(account).values({
|
|
||||||
userId
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const { success, error, org } = await createUserAccountOrg(
|
|
||||||
userId,
|
|
||||||
email
|
|
||||||
);
|
|
||||||
if (!success) {
|
|
||||||
if (error) {
|
|
||||||
throw new Error(error);
|
|
||||||
}
|
|
||||||
throw new Error("Failed to create user account and organization");
|
|
||||||
}
|
|
||||||
if (!org) {
|
|
||||||
throw new Error("Failed to create user account and organization");
|
|
||||||
}
|
|
||||||
|
|
||||||
const orgId = org.orgId;
|
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
|
||||||
const token = generateRandomString(
|
|
||||||
8,
|
|
||||||
alphabet("0-9", "A-Z", "a-z")
|
|
||||||
);
|
|
||||||
|
|
||||||
await trx
|
|
||||||
.delete(passwordResetTokens)
|
|
||||||
.where(eq(passwordResetTokens.userId, userId));
|
|
||||||
|
|
||||||
const tokenHash = await hashPassword(token);
|
|
||||||
|
|
||||||
await trx.insert(passwordResetTokens).values({
|
|
||||||
userId: userId,
|
|
||||||
email: email,
|
|
||||||
tokenHash,
|
|
||||||
expiresAt: createDate(new TimeSpan(7, "d")).getTime()
|
|
||||||
});
|
|
||||||
|
|
||||||
// // Create the sandbox newt
|
|
||||||
// const newClientAddress = await getNextAvailableClientSubnet(orgId);
|
|
||||||
// if (!newClientAddress) {
|
|
||||||
// throw new Error("No available subnet found");
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const clientAddress = newClientAddress.split("/")[0];
|
|
||||||
|
|
||||||
newtId = generateId(15);
|
|
||||||
secret = generateId(48);
|
|
||||||
|
|
||||||
// Create the sandbox site
|
|
||||||
const siteNiceId = await getUniqueSiteName(orgId);
|
|
||||||
const siteName = `First Site`;
|
|
||||||
|
|
||||||
// pick a random exit node
|
|
||||||
const exitNodesList = await listExitNodes(orgId);
|
|
||||||
|
|
||||||
// select a random exit node
|
|
||||||
const randomExitNode =
|
|
||||||
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
|
|
||||||
|
|
||||||
if (!randomExitNode) {
|
|
||||||
throw new Error("No exit nodes available");
|
|
||||||
}
|
|
||||||
|
|
||||||
const [newSite] = await trx
|
|
||||||
.insert(sites)
|
|
||||||
.values({
|
|
||||||
orgId,
|
|
||||||
exitNodeId: randomExitNode.exitNodeId,
|
|
||||||
name: siteName,
|
|
||||||
niceId: siteNiceId,
|
|
||||||
// address: clientAddress,
|
|
||||||
type: "newt",
|
|
||||||
dockerSocketEnabled: true
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
const siteId = newSite.siteId;
|
|
||||||
|
|
||||||
const adminRole = await trx
|
|
||||||
.select()
|
|
||||||
.from(roles)
|
|
||||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (adminRole.length === 0) {
|
|
||||||
throw new Error("Admin role not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
await trx.insert(roleSites).values({
|
|
||||||
roleId: adminRole[0].roleId,
|
|
||||||
siteId: newSite.siteId
|
|
||||||
});
|
|
||||||
|
|
||||||
if (req.user && req.userOrgRoleId != adminRole[0].roleId) {
|
|
||||||
// make sure the user can access the site
|
|
||||||
await trx.insert(userSites).values({
|
|
||||||
userId: req.user?.userId!,
|
|
||||||
siteId: newSite.siteId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// add the peer to the exit node
|
|
||||||
const secretHash = await hashPassword(secret!);
|
|
||||||
|
|
||||||
await trx.insert(newts).values({
|
|
||||||
newtId: newtId!,
|
|
||||||
secretHash,
|
|
||||||
siteId: newSite.siteId,
|
|
||||||
dateCreated: moment().toISOString()
|
|
||||||
});
|
|
||||||
|
|
||||||
const [randomNamespace] = await trx
|
|
||||||
.select()
|
|
||||||
.from(domainNamespaces)
|
|
||||||
.orderBy(sql`RANDOM()`)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!randomNamespace) {
|
|
||||||
throw new Error("No domain namespace available");
|
|
||||||
}
|
|
||||||
|
|
||||||
const [randomNamespaceDomain] = await trx
|
|
||||||
.select()
|
|
||||||
.from(domains)
|
|
||||||
.where(eq(domains.domainId, randomNamespace.domainId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!randomNamespaceDomain) {
|
|
||||||
throw new Error("No domain found for the namespace");
|
|
||||||
}
|
|
||||||
|
|
||||||
const resourceNiceId = await getUniqueResourceName(orgId);
|
|
||||||
|
|
||||||
// Create sandbox resource
|
|
||||||
const subdomain = `${resourceNiceId}-${generateId(5)}`;
|
|
||||||
fullDomain = `${subdomain}.${randomNamespaceDomain.baseDomain}`;
|
|
||||||
|
|
||||||
const resourceName = `First Resource`;
|
|
||||||
|
|
||||||
const newResource = await trx
|
|
||||||
.insert(resources)
|
|
||||||
.values({
|
|
||||||
niceId: resourceNiceId,
|
|
||||||
fullDomain,
|
|
||||||
domainId: randomNamespaceDomain.domainId,
|
|
||||||
orgId,
|
|
||||||
name: resourceName,
|
|
||||||
subdomain,
|
|
||||||
http: true,
|
|
||||||
protocol: "tcp",
|
|
||||||
ssl: true,
|
|
||||||
sso: false,
|
|
||||||
emailWhitelistEnabled: enableWhitelist
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
await trx.insert(roleResources).values({
|
|
||||||
roleId: adminRole[0].roleId,
|
|
||||||
resourceId: newResource[0].resourceId
|
|
||||||
});
|
|
||||||
|
|
||||||
if (req.user && req.userOrgRoleId != adminRole[0].roleId) {
|
|
||||||
// make sure the user can access the resource
|
|
||||||
await trx.insert(userResources).values({
|
|
||||||
userId: req.user?.userId!,
|
|
||||||
resourceId: newResource[0].resourceId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
resource = newResource[0];
|
|
||||||
|
|
||||||
// Create the sandbox target
|
|
||||||
const { internalPort, targetIps } = await pickPort(siteId!, trx);
|
|
||||||
|
|
||||||
if (!internalPort) {
|
|
||||||
throw new Error("No available internal port");
|
|
||||||
}
|
|
||||||
|
|
||||||
const newTarget = await trx
|
|
||||||
.insert(targets)
|
|
||||||
.values({
|
|
||||||
resourceId: resource.resourceId,
|
|
||||||
siteId: siteId!,
|
|
||||||
internalPort,
|
|
||||||
ip,
|
|
||||||
method,
|
|
||||||
port,
|
|
||||||
enabled: true
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
const newHealthcheck = await trx
|
|
||||||
.insert(targetHealthCheck)
|
|
||||||
.values({
|
|
||||||
targetId: newTarget[0].targetId,
|
|
||||||
hcEnabled: false
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
// add the new target to the targetIps array
|
|
||||||
targetIps.push(`${ip}/32`);
|
|
||||||
|
|
||||||
const [newt] = await trx
|
|
||||||
.select()
|
|
||||||
.from(newts)
|
|
||||||
.where(eq(newts.siteId, siteId!))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
await addTargets(
|
|
||||||
newt.newtId,
|
|
||||||
newTarget,
|
|
||||||
newHealthcheck,
|
|
||||||
resource.protocol
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set resource pincode if provided
|
|
||||||
if (pincode) {
|
|
||||||
await trx
|
|
||||||
.delete(resourcePincode)
|
|
||||||
.where(
|
|
||||||
eq(resourcePincode.resourceId, resource!.resourceId)
|
|
||||||
);
|
|
||||||
|
|
||||||
const pincodeHash = await hashPassword(pincode);
|
|
||||||
|
|
||||||
await trx.insert(resourcePincode).values({
|
|
||||||
resourceId: resource!.resourceId,
|
|
||||||
pincodeHash,
|
|
||||||
digitLength: 6
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set resource password if provided
|
|
||||||
if (password) {
|
|
||||||
await trx
|
|
||||||
.delete(resourcePassword)
|
|
||||||
.where(
|
|
||||||
eq(resourcePassword.resourceId, resource!.resourceId)
|
|
||||||
);
|
|
||||||
|
|
||||||
const passwordHash = await hashPassword(password);
|
|
||||||
|
|
||||||
await trx.insert(resourcePassword).values({
|
|
||||||
resourceId: resource!.resourceId,
|
|
||||||
passwordHash
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set resource OTP if whitelist is enabled
|
|
||||||
if (enableWhitelist) {
|
|
||||||
await trx.insert(resourceWhitelist).values({
|
|
||||||
email,
|
|
||||||
resourceId: resource!.resourceId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
completeSignUpLink = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?quickstart=true&email=${email}&token=${token}`;
|
|
||||||
|
|
||||||
// Store token for email outside transaction
|
|
||||||
await sendEmail(
|
|
||||||
WelcomeQuickStart({
|
|
||||||
username: email,
|
|
||||||
link: completeSignUpLink,
|
|
||||||
fallbackLink: `${config.getRawConfig().app.dashboard_url}/auth/reset-password?quickstart=true&email=${email}`,
|
|
||||||
resourceMethod: method,
|
|
||||||
resourceHostname: ip,
|
|
||||||
resourcePort: port,
|
|
||||||
resourceUrl: `https://${fullDomain}`,
|
|
||||||
cliCommand: `newt --id ${newtId} --secret ${secret}`
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
to: email,
|
|
||||||
from: config.getNoReplyEmail(),
|
|
||||||
subject: `Access your Pangolin dashboard and resources`
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return response<QuickStartResponse>(res, {
|
|
||||||
data: {
|
|
||||||
newtId: newtId!,
|
|
||||||
newtSecret: secret!,
|
|
||||||
resourceUrl: `https://${fullDomain!}`,
|
|
||||||
completeSignUpLink: completeSignUpLink!
|
|
||||||
},
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Quick start completed successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") {
|
|
||||||
if (config.getRawConfig().app.log_failed_attempts) {
|
|
||||||
logger.info(
|
|
||||||
`Account already exists with that email. Email: ${email}. IP: ${req.ip}.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"A user with that email address already exists"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
logger.error(e);
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Failed to do quick start"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const BACKEND_SECRET_KEY = "4f9b6000-5d1a-11f0-9de7-ff2cc032f501";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates a token received from the frontend.
|
|
||||||
* @param {string} token The validation token from the request.
|
|
||||||
* @returns {{ isValid: boolean; message: string }} An object indicating if the token is valid.
|
|
||||||
*/
|
|
||||||
const validateTokenOnApi = (
|
|
||||||
token: string
|
|
||||||
): { isValid: boolean; message: string } => {
|
|
||||||
if (token === DEMO_UBO_KEY) {
|
|
||||||
// Special case for demo UBO key
|
|
||||||
return { isValid: true, message: "Demo UBO key is valid." };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return { isValid: false, message: "Error: No token provided." };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Decode the base64 string
|
|
||||||
const decodedB64 = atob(token);
|
|
||||||
|
|
||||||
// 2. Reverse the character code manipulation
|
|
||||||
const deobfuscated = decodedB64
|
|
||||||
.split("")
|
|
||||||
.map((char) => String.fromCharCode(char.charCodeAt(0) - 5)) // Reverse the shift
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
// 3. Split the data to get the original secret and timestamp
|
|
||||||
const parts = deobfuscated.split("|");
|
|
||||||
if (parts.length !== 2) {
|
|
||||||
throw new Error("Invalid token format.");
|
|
||||||
}
|
|
||||||
const receivedKey = parts[0];
|
|
||||||
const tokenTimestamp = parseInt(parts[1], 10);
|
|
||||||
|
|
||||||
// 4. Check if the secret key matches
|
|
||||||
if (receivedKey !== BACKEND_SECRET_KEY) {
|
|
||||||
return { isValid: false, message: "Invalid token: Key mismatch." };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Check if the timestamp is recent (e.g., within 30 seconds) to prevent replay attacks
|
|
||||||
const now = Date.now();
|
|
||||||
const timeDifference = now - tokenTimestamp;
|
|
||||||
|
|
||||||
if (timeDifference > 30000) {
|
|
||||||
// 30 seconds
|
|
||||||
return { isValid: false, message: "Invalid token: Expired." };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (timeDifference < 0) {
|
|
||||||
// Timestamp is in the future
|
|
||||||
return {
|
|
||||||
isValid: false,
|
|
||||||
message: "Invalid token: Timestamp is in the future."
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// If all checks pass, the token is valid
|
|
||||||
return { isValid: true, message: "Token is valid!" };
|
|
||||||
} catch (error) {
|
|
||||||
// This will catch errors from atob (if not valid base64) or other issues.
|
|
||||||
return {
|
|
||||||
isValid: false,
|
|
||||||
message: `Error: ${(error as Error).message}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
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 {
|
||||||
|
getHomeLabFeaturePriceSet,
|
||||||
|
getScaleFeaturePriceSet,
|
||||||
|
getStarterFeaturePriceSet,
|
||||||
|
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 = getHomeLabFeaturePriceSet();
|
||||||
|
} else if (tier === "tier2") {
|
||||||
|
targetPriceSet = getStarterFeaturePriceSet();
|
||||||
|
} else if (tier === "tier3") {
|
||||||
|
targetPriceSet = getScaleFeaturePriceSet();
|
||||||
|
} else {
|
||||||
|
return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid tier"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current subscription items from our database
|
||||||
|
const currentItems = await db
|
||||||
|
.select()
|
||||||
|
.from(subscriptionItems)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
subscriptionItems.subscriptionId,
|
||||||
|
subscription.subscriptionId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentItems.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"No subscription items found"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve the full subscription from Stripe to get item IDs
|
||||||
|
const stripeSubscription = await stripe!.subscriptions.retrieve(
|
||||||
|
subscription.subscriptionId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Determine if we're switching between different products
|
||||||
|
// 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,13 +22,22 @@ import logger from "@server/logger";
|
|||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import stripe from "#private/lib/stripe";
|
import stripe from "#private/lib/stripe";
|
||||||
import { getLineItems, getStandardFeaturePriceSet } from "@server/lib/billing";
|
import {
|
||||||
import { getTierPriceSet, TierId } from "@server/lib/billing/tiers";
|
getHomeLabFeaturePriceSet,
|
||||||
|
getScaleFeaturePriceSet,
|
||||||
|
getStarterFeaturePriceSet
|
||||||
|
} from "@server/lib/billing";
|
||||||
|
import { getLineItems } from "@server/lib/billing/getLineItems";
|
||||||
|
import Stripe from "stripe";
|
||||||
|
|
||||||
const createCheckoutSessionSchema = z.strictObject({
|
const createCheckoutSessionSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createCheckoutSessionBodySchema = z.strictObject({
|
||||||
|
tier: z.enum(["tier1", "tier2", "tier3"])
|
||||||
|
});
|
||||||
|
|
||||||
export async function createCheckoutSession(
|
export async function createCheckoutSession(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
@@ -47,6 +56,18 @@ export async function createCheckoutSession(
|
|||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
|
const parsedBody = createCheckoutSessionBodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tier } = parsedBody.data;
|
||||||
|
|
||||||
// check if we already have a customer for this org
|
// check if we already have a customer for this org
|
||||||
const [customer] = await db
|
const [customer] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -65,20 +86,26 @@ export async function createCheckoutSession(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const standardTierPrice = getTierPriceSet()[TierId.STANDARD];
|
let lineItems: Stripe.Checkout.SessionCreateParams.LineItem[];
|
||||||
|
if (tier === "tier1") {
|
||||||
|
lineItems = await getLineItems(getHomeLabFeaturePriceSet(), orgId);
|
||||||
|
} else if (tier === "tier2") {
|
||||||
|
lineItems = await getLineItems(getStarterFeaturePriceSet(), orgId);
|
||||||
|
} else if (tier === "tier3") {
|
||||||
|
lineItems = await getLineItems(getScaleFeaturePriceSet(), orgId);
|
||||||
|
} else {
|
||||||
|
return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid plan"));
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Line items: ${JSON.stringify(lineItems)}`);
|
||||||
|
|
||||||
const session = await stripe!.checkout.sessions.create({
|
const session = await stripe!.checkout.sessions.create({
|
||||||
client_reference_id: orgId, // So we can look it up the org later on the webhook
|
client_reference_id: orgId, // So we can look it up the org later on the webhook
|
||||||
billing_address_collection: "required",
|
billing_address_collection: "required",
|
||||||
line_items: [
|
line_items: lineItems,
|
||||||
{
|
|
||||||
price: standardTierPrice, // Use the standard tier
|
|
||||||
quantity: 1
|
|
||||||
},
|
|
||||||
...getLineItems(getStandardFeaturePriceSet())
|
|
||||||
], // Start with the standard feature set that matches the free limits
|
|
||||||
customer: customer.customerId,
|
customer: customer.customerId,
|
||||||
mode: "subscription",
|
mode: "subscription",
|
||||||
|
allow_promotion_codes: true,
|
||||||
success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing?success=true&session_id={CHECKOUT_SESSION_ID}`,
|
success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing?success=true&session_id={CHECKOUT_SESSION_ID}`,
|
||||||
cancel_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing?canceled=true`
|
cancel_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing?canceled=true`
|
||||||
});
|
});
|
||||||
@@ -87,7 +114,7 @@ export async function createCheckoutSession(
|
|||||||
data: session.url,
|
data: session.url,
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Organization created successfully",
|
message: "Checkout session created successfully",
|
||||||
status: HttpCode.CREATED
|
status: HttpCode.CREATED
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
297
server/private/routers/billing/featureLifecycle.ts
Normal file
297
server/private/routers/billing/featureLifecycle.ts
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
/*
|
||||||
|
* 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";
|
||||||
|
|
||||||
|
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`
|
||||||
|
);
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// 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 { fromZodError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { GetOrgSubscriptionResponse } from "@server/routers/billing/types";
|
import { GetOrgSubscriptionResponse } from "@server/routers/billing/types";
|
||||||
|
import { usageService } from "@server/lib/billing/usageService";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
// Import tables for billing
|
// Import tables for billing
|
||||||
import {
|
import {
|
||||||
@@ -37,18 +39,7 @@ const getOrgSchema = z.strictObject({
|
|||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
});
|
});
|
||||||
|
|
||||||
registry.registerPath({
|
export async function getOrgSubscriptions(
|
||||||
method: "get",
|
|
||||||
path: "/org/{orgId}/billing/subscription",
|
|
||||||
description: "Get an organization",
|
|
||||||
tags: [OpenAPITags.Org],
|
|
||||||
request: {
|
|
||||||
params: getOrgSchema
|
|
||||||
},
|
|
||||||
responses: {}
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function getOrgSubscription(
|
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction
|
next: NextFunction
|
||||||
@@ -66,12 +57,9 @@ export async function getOrgSubscription(
|
|||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
let subscriptionData = null;
|
let subscriptions = null;
|
||||||
let itemsData: SubscriptionItem[] = [];
|
|
||||||
try {
|
try {
|
||||||
const { subscription, items } = await getOrgSubscriptionData(orgId);
|
subscriptions = await getOrgSubscriptionsData(orgId);
|
||||||
subscriptionData = subscription;
|
|
||||||
itemsData = items;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as Error).message === "Not found") {
|
if ((err as Error).message === "Not found") {
|
||||||
return next(
|
return next(
|
||||||
@@ -84,10 +72,19 @@ export async function getOrgSubscription(
|
|||||||
throw err;
|
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, {
|
return response<GetOrgSubscriptionResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
subscription: subscriptionData,
|
subscriptions,
|
||||||
items: itemsData
|
...(build === "saas" ? { limitsExceeded } : {})
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
@@ -102,9 +99,9 @@ export async function getOrgSubscription(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOrgSubscriptionData(
|
export async function getOrgSubscriptionsData(
|
||||||
orgId: string
|
orgId: string
|
||||||
): Promise<{ subscription: Subscription | null; items: SubscriptionItem[] }> {
|
): Promise<Array<{ subscription: Subscription; items: SubscriptionItem[] }>> {
|
||||||
const org = await db
|
const org = await db
|
||||||
.select()
|
.select()
|
||||||
.from(orgs)
|
.from(orgs)
|
||||||
@@ -122,21 +119,21 @@ export async function getOrgSubscriptionData(
|
|||||||
.where(eq(customers.orgId, orgId))
|
.where(eq(customers.orgId, orgId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
let subscription = null;
|
const subscriptionsWithItems: Array<{
|
||||||
let items: SubscriptionItem[] = [];
|
subscription: Subscription;
|
||||||
|
items: SubscriptionItem[];
|
||||||
|
}> = [];
|
||||||
|
|
||||||
if (customer.length > 0) {
|
if (customer.length > 0) {
|
||||||
// Get subscription for customer
|
// Get all subscriptions for customer
|
||||||
const subs = await db
|
const subs = await db
|
||||||
.select()
|
.select()
|
||||||
.from(subscriptions)
|
.from(subscriptions)
|
||||||
.where(eq(subscriptions.customerId, customer[0].customerId))
|
.where(eq(subscriptions.customerId, customer[0].customerId));
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (subs.length > 0) {
|
for (const subscription of subs) {
|
||||||
subscription = subs[0];
|
// Get subscription items for each subscription
|
||||||
// Get subscription items
|
const items = await db
|
||||||
items = await db
|
|
||||||
.select()
|
.select()
|
||||||
.from(subscriptionItems)
|
.from(subscriptionItems)
|
||||||
.where(
|
.where(
|
||||||
@@ -145,8 +142,13 @@ export async function getOrgSubscriptionData(
|
|||||||
subscription.subscriptionId
|
subscription.subscriptionId
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
subscriptionsWithItems.push({
|
||||||
|
subscription,
|
||||||
|
items
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { subscription, items };
|
return subscriptionsWithItems;
|
||||||
}
|
}
|
||||||
@@ -78,16 +78,10 @@ export async function getOrgUsage(
|
|||||||
// Get usage for org
|
// Get usage for org
|
||||||
const usageData = [];
|
const usageData = [];
|
||||||
|
|
||||||
const siteUptime = await usageService.getUsage(
|
const sites = await usageService.getUsage(orgId, FeatureId.SITES);
|
||||||
orgId,
|
const users = await usageService.getUsage(orgId, FeatureId.USERS);
|
||||||
FeatureId.SITE_UPTIME
|
const domains = await usageService.getUsage(orgId, FeatureId.DOMAINS);
|
||||||
);
|
const remoteExitNodes = await usageService.getUsage(
|
||||||
const users = await usageService.getUsageDaily(orgId, FeatureId.USERS);
|
|
||||||
const domains = await usageService.getUsageDaily(
|
|
||||||
orgId,
|
|
||||||
FeatureId.DOMAINS
|
|
||||||
);
|
|
||||||
const remoteExitNodes = await usageService.getUsageDaily(
|
|
||||||
orgId,
|
orgId,
|
||||||
FeatureId.REMOTE_EXIT_NODES
|
FeatureId.REMOTE_EXIT_NODES
|
||||||
);
|
);
|
||||||
@@ -96,8 +90,8 @@ export async function getOrgUsage(
|
|||||||
FeatureId.EGRESS_DATA_MB
|
FeatureId.EGRESS_DATA_MB
|
||||||
);
|
);
|
||||||
|
|
||||||
if (siteUptime) {
|
if (sites) {
|
||||||
usageData.push(siteUptime);
|
usageData.push(sites);
|
||||||
}
|
}
|
||||||
if (users) {
|
if (users) {
|
||||||
usageData.push(users);
|
usageData.push(users);
|
||||||
|
|||||||
62
server/private/routers/billing/hooks/getSubType.ts
Normal file
62
server/private/routers/billing/hooks/getSubType.ts
Normal file
@@ -0,0 +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 {
|
||||||
|
getHomeLabFeaturePriceSet,
|
||||||
|
getStarterFeaturePriceSet,
|
||||||
|
getScaleFeaturePriceSet,
|
||||||
|
} from "@server/lib/billing/features";
|
||||||
|
import Stripe from "stripe";
|
||||||
|
import { Tier } from "@server/types/Tiers";
|
||||||
|
|
||||||
|
export type SubscriptionType = Tier | "license";
|
||||||
|
|
||||||
|
export function getSubType(fullSubscription: Stripe.Response<Stripe.Subscription>): SubscriptionType | null {
|
||||||
|
// Determine subscription type by checking subscription items
|
||||||
|
if (!Array.isArray(fullSubscription.items?.data) || fullSubscription.items.data.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of fullSubscription.items.data) {
|
||||||
|
const priceId = item.price.id;
|
||||||
|
|
||||||
|
// Check if price ID matches any license price
|
||||||
|
const licensePrices = Object.values(getLicensePriceSet());
|
||||||
|
if (licensePrices.includes(priceId)) {
|
||||||
|
return "license";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if price ID matches home lab tier
|
||||||
|
const homeLabPrices = Object.values(getHomeLabFeaturePriceSet());
|
||||||
|
if (homeLabPrices.includes(priceId)) {
|
||||||
|
return "tier1";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if price ID matches tier2 tier
|
||||||
|
const tier2Prices = Object.values(getStarterFeaturePriceSet());
|
||||||
|
if (tier2Prices.includes(priceId)) {
|
||||||
|
return "tier2";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if price ID matches tier3 tier
|
||||||
|
const tier3Prices = Object.values(getScaleFeaturePriceSet());
|
||||||
|
if (tier3Prices.includes(priceId)) {
|
||||||
|
return "tier3";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -25,6 +25,14 @@ import logger from "@server/logger";
|
|||||||
import stripe from "#private/lib/stripe";
|
import stripe from "#private/lib/stripe";
|
||||||
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
||||||
import { AudienceIds, moveEmailToAudience } from "#private/lib/resend";
|
import { AudienceIds, moveEmailToAudience } from "#private/lib/resend";
|
||||||
|
import { getSubType } from "./getSubType";
|
||||||
|
import privateConfig from "#private/lib/config";
|
||||||
|
import { getLicensePriceSet, LicenseId } from "@server/lib/billing/licenses";
|
||||||
|
import { sendEmail } from "@server/emails";
|
||||||
|
import EnterpriseEditionKeyGenerated from "@server/emails/templates/EnterpriseEditionKeyGenerated";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
import { getFeatureIdByPriceId } from "@server/lib/billing/features";
|
||||||
|
import { handleTierChange } from "../featureLifecycle";
|
||||||
|
|
||||||
export async function handleSubscriptionCreated(
|
export async function handleSubscriptionCreated(
|
||||||
subscription: Stripe.Subscription
|
subscription: Stripe.Subscription
|
||||||
@@ -53,6 +61,8 @@ export async function handleSubscriptionCreated(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const type = getSubType(fullSubscription);
|
||||||
|
|
||||||
const newSubscription = {
|
const newSubscription = {
|
||||||
subscriptionId: subscription.id,
|
subscriptionId: subscription.id,
|
||||||
customerId: subscription.customer as string,
|
customerId: subscription.customer as string,
|
||||||
@@ -60,7 +70,9 @@ export async function handleSubscriptionCreated(
|
|||||||
canceledAt: subscription.canceled_at
|
canceledAt: subscription.canceled_at
|
||||||
? subscription.canceled_at
|
? subscription.canceled_at
|
||||||
: null,
|
: null,
|
||||||
createdAt: subscription.created
|
createdAt: subscription.created,
|
||||||
|
type: type,
|
||||||
|
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);
|
await db.insert(subscriptions).values(newSubscription);
|
||||||
@@ -81,10 +93,15 @@ export async function handleSubscriptionCreated(
|
|||||||
name = product.name || null;
|
name = product.name || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the feature ID from the price ID
|
||||||
|
const featureId = getFeatureIdByPriceId(item.price.id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
stripeSubscriptionItemId: item.id,
|
||||||
subscriptionId: subscription.id,
|
subscriptionId: subscription.id,
|
||||||
planId: item.plan.id,
|
planId: item.plan.id,
|
||||||
priceId: item.price.id,
|
priceId: item.price.id,
|
||||||
|
featureId: featureId || null,
|
||||||
meterId: item.plan.meter,
|
meterId: item.plan.meter,
|
||||||
unitAmount: item.price.unit_amount || 0,
|
unitAmount: item.price.unit_amount || 0,
|
||||||
currentPeriodStart: item.current_period_start,
|
currentPeriodStart: item.current_period_start,
|
||||||
@@ -123,24 +140,148 @@ export async function handleSubscriptionCreated(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await handleSubscriptionLifesycle(customer.orgId, subscription.status);
|
if (type === "tier1" || type === "tier2" || type === "tier3") {
|
||||||
|
logger.debug(
|
||||||
|
`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,
|
||||||
|
type
|
||||||
|
);
|
||||||
|
|
||||||
const [orgUserRes] = await db
|
// Handle initial tier setup - disable features not available in this tier
|
||||||
.select()
|
logger.info(
|
||||||
.from(userOrgs)
|
`Setting up initial tier features for org ${customer.orgId} with type ${type}`
|
||||||
.where(
|
);
|
||||||
and(
|
await handleTierChange(customer.orgId, type);
|
||||||
eq(userOrgs.orgId, customer.orgId),
|
|
||||||
eq(userOrgs.isOwner, true)
|
const [orgUserRes] = await db
|
||||||
|
.select()
|
||||||
|
.from(userOrgs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userOrgs.orgId, customer.orgId),
|
||||||
|
eq(userOrgs.isOwner, true)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
.innerJoin(users, eq(userOrgs.userId, users.userId));
|
||||||
.innerJoin(users, eq(userOrgs.userId, users.userId));
|
|
||||||
|
|
||||||
if (orgUserRes) {
|
if (orgUserRes) {
|
||||||
const email = orgUserRes.user.email;
|
const email = orgUserRes.user.email;
|
||||||
|
|
||||||
if (email) {
|
if (email) {
|
||||||
moveEmailToAudience(email, AudienceIds.Subscribed);
|
moveEmailToAudience(email, AudienceIds.Subscribed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (type === "license") {
|
||||||
|
logger.debug(
|
||||||
|
`License subscription created for org ${customer.orgId}, no lifecycle handling needed.`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Retrieve the client_reference_id from the checkout session
|
||||||
|
let licenseId: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessions = await stripe!.checkout.sessions.list({
|
||||||
|
subscription: subscription.id,
|
||||||
|
limit: 1
|
||||||
|
});
|
||||||
|
if (sessions.data.length > 0) {
|
||||||
|
licenseId = sessions.data[0].client_reference_id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!licenseId) {
|
||||||
|
logger.error(
|
||||||
|
`No client_reference_id found for subscription ${subscription.id}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Retrieved licenseId ${licenseId} from checkout session for subscription ${subscription.id}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Determine users and sites based on license type
|
||||||
|
const priceSet = getLicensePriceSet();
|
||||||
|
const subscriptionPriceId =
|
||||||
|
fullSubscription.items.data[0]?.price.id;
|
||||||
|
|
||||||
|
let numUsers: number;
|
||||||
|
let numSites: number;
|
||||||
|
|
||||||
|
if (subscriptionPriceId === priceSet[LicenseId.SMALL_LICENSE]) {
|
||||||
|
numUsers = 25;
|
||||||
|
numSites = 25;
|
||||||
|
} else if (
|
||||||
|
subscriptionPriceId === priceSet[LicenseId.BIG_LICENSE]
|
||||||
|
) {
|
||||||
|
numUsers = 50;
|
||||||
|
numSites = 50;
|
||||||
|
} else {
|
||||||
|
logger.error(
|
||||||
|
`Unknown price ID ${subscriptionPriceId} for subscription ${subscription.id}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`License type determined: ${numUsers} users, ${numSites} sites for subscription ${subscription.id}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/paid-for`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"api-key":
|
||||||
|
privateConfig.getRawPrivateConfig().server
|
||||||
|
.fossorial_api_key!,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
licenseId: parseInt(licenseId),
|
||||||
|
paidFor: true,
|
||||||
|
users: numUsers,
|
||||||
|
sites: numSites
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
logger.debug(`Fossorial API response: ${JSON.stringify(data)}`);
|
||||||
|
|
||||||
|
if (customer.email) {
|
||||||
|
logger.debug(
|
||||||
|
`Sending license key email to ${customer.email} for subscription ${subscription.id}`
|
||||||
|
);
|
||||||
|
await sendEmail(
|
||||||
|
EnterpriseEditionKeyGenerated({
|
||||||
|
keyValue: data.data.licenseKey,
|
||||||
|
personalUseOnly: false,
|
||||||
|
users: numUsers,
|
||||||
|
sites: numSites,
|
||||||
|
modifySubscriptionLink: `${config.getRawConfig().app.dashboard_url}/${customer.orgId}/settings/billing`
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
to: customer.email,
|
||||||
|
from: config.getNoReplyEmail(),
|
||||||
|
subject:
|
||||||
|
"Your Enterprise Edition license key is ready"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.error(
|
||||||
|
`No email found for customer ${customer.customerId} to send license key.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating new license:", error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -24,11 +24,23 @@ import { eq, and } from "drizzle-orm";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
||||||
import { AudienceIds, moveEmailToAudience } from "#private/lib/resend";
|
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(
|
export async function handleSubscriptionDeleted(
|
||||||
subscription: Stripe.Subscription
|
subscription: Stripe.Subscription
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
// Fetch the subscription from Stripe with expanded price.tiers
|
||||||
|
const fullSubscription = await stripe!.subscriptions.retrieve(
|
||||||
|
subscription.id,
|
||||||
|
{
|
||||||
|
expand: ["items.data.price.tiers"]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const [existingSubscription] = await db
|
const [existingSubscription] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(subscriptions)
|
.from(subscriptions)
|
||||||
@@ -64,24 +76,69 @@ export async function handleSubscriptionDeleted(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await handleSubscriptionLifesycle(customer.orgId, subscription.status);
|
const type = getSubType(fullSubscription);
|
||||||
|
if (type == "tier1" || type == "tier2" || type == "tier3") {
|
||||||
|
logger.debug(
|
||||||
|
`Handling SaaS subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}`
|
||||||
|
);
|
||||||
|
|
||||||
const [orgUserRes] = await db
|
await handleSubscriptionLifesycle(
|
||||||
.select()
|
customer.orgId,
|
||||||
.from(userOrgs)
|
subscription.status,
|
||||||
.where(
|
type
|
||||||
and(
|
);
|
||||||
eq(userOrgs.orgId, customer.orgId),
|
|
||||||
eq(userOrgs.isOwner, true)
|
// 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)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userOrgs.orgId, customer.orgId),
|
||||||
|
eq(userOrgs.isOwner, true)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
.innerJoin(users, eq(userOrgs.userId, users.userId));
|
||||||
.innerJoin(users, eq(userOrgs.userId, users.userId));
|
|
||||||
|
|
||||||
if (orgUserRes) {
|
if (orgUserRes) {
|
||||||
const email = orgUserRes.user.email;
|
const email = orgUserRes.user.email;
|
||||||
|
|
||||||
if (email) {
|
if (email) {
|
||||||
moveEmailToAudience(email, AudienceIds.Churned);
|
moveEmailToAudience(email, AudienceIds.Churned);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (type === "license") {
|
||||||
|
logger.debug(
|
||||||
|
`Handling license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}`
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
// WARNING:
|
||||||
|
// this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId
|
||||||
|
await fetch(
|
||||||
|
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"api-key":
|
||||||
|
privateConfig.getRawPrivateConfig().server
|
||||||
|
.fossorial_api_key!,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
orgId: customer.orgId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -23,9 +23,12 @@ import {
|
|||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
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 stripe from "#private/lib/stripe";
|
||||||
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
||||||
|
import { getSubType, SubscriptionType } from "./getSubType";
|
||||||
|
import privateConfig from "#private/lib/config";
|
||||||
|
import { handleTierChange } from "../featureLifecycle";
|
||||||
|
|
||||||
export async function handleSubscriptionUpdated(
|
export async function handleSubscriptionUpdated(
|
||||||
subscription: Stripe.Subscription,
|
subscription: Stripe.Subscription,
|
||||||
@@ -56,12 +59,15 @@ export async function handleSubscriptionUpdated(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// get the customer
|
// get the customer
|
||||||
const [existingCustomer] = await db
|
const [customer] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(customers)
|
.from(customers)
|
||||||
.where(eq(customers.customerId, subscription.customer as string))
|
.where(eq(customers.customerId, subscription.customer as string))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
|
const type = getSubType(fullSubscription);
|
||||||
|
const previousType = existingSubscription.type as SubscriptionType | null;
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(subscriptions)
|
.update(subscriptions)
|
||||||
.set({
|
.set({
|
||||||
@@ -70,30 +76,55 @@ export async function handleSubscriptionUpdated(
|
|||||||
? subscription.canceled_at
|
? subscription.canceled_at
|
||||||
: null,
|
: null,
|
||||||
updatedAt: Math.floor(Date.now() / 1000),
|
updatedAt: Math.floor(Date.now() / 1000),
|
||||||
billingCycleAnchor: subscription.billing_cycle_anchor
|
billingCycleAnchor: subscription.billing_cycle_anchor,
|
||||||
|
type: type
|
||||||
})
|
})
|
||||||
.where(eq(subscriptions.subscriptionId, subscription.id));
|
.where(eq(subscriptions.subscriptionId, subscription.id));
|
||||||
|
|
||||||
await handleSubscriptionLifesycle(
|
// Handle tier change if the subscription type changed
|
||||||
existingCustomer.orgId,
|
if (type && type !== previousType) {
|
||||||
subscription.status
|
logger.info(
|
||||||
);
|
`Tier change detected for org ${customer.orgId}: ${previousType} -> ${type}`
|
||||||
|
);
|
||||||
|
await handleTierChange(customer.orgId, type, previousType ?? undefined);
|
||||||
|
}
|
||||||
|
|
||||||
// Upsert subscription items
|
// Upsert subscription items
|
||||||
if (Array.isArray(fullSubscription.items?.data)) {
|
if (Array.isArray(fullSubscription.items?.data)) {
|
||||||
const itemsToUpsert = fullSubscription.items.data.map((item) => ({
|
// First, get existing items to preserve featureId when there's no match
|
||||||
subscriptionId: subscription.id,
|
const existingItems = await db
|
||||||
planId: item.plan.id,
|
.select()
|
||||||
priceId: item.price.id,
|
.from(subscriptionItems)
|
||||||
meterId: item.plan.meter,
|
.where(eq(subscriptionItems.subscriptionId, subscription.id));
|
||||||
unitAmount: item.price.unit_amount || 0,
|
|
||||||
currentPeriodStart: item.current_period_start,
|
const itemsToUpsert = fullSubscription.items.data.map((item) => {
|
||||||
currentPeriodEnd: item.current_period_end,
|
// Try to get featureId from price
|
||||||
tiers: item.price.tiers
|
let featureId: string | null = getFeatureIdByPriceId(item.price.id) || null;
|
||||||
? JSON.stringify(item.price.tiers)
|
|
||||||
: null,
|
// If no match, try to preserve existing featureId
|
||||||
interval: item.plan.interval
|
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) {
|
if (itemsToUpsert.length > 0) {
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await trx
|
await trx
|
||||||
@@ -141,23 +172,23 @@ export async function handleSubscriptionUpdated(
|
|||||||
// This item has cycled
|
// This item has cycled
|
||||||
const meterId = item.plan.meter;
|
const meterId = item.plan.meter;
|
||||||
if (!meterId) {
|
if (!meterId) {
|
||||||
logger.warn(
|
logger.debug(
|
||||||
`No meterId found for subscription item ${item.id}. Skipping usage reset.`
|
`No meterId found for subscription item ${item.id}. Skipping usage reset.`
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const featureId = getFeatureIdByMetricId(meterId);
|
const featureId = getFeatureIdByMetricId(meterId);
|
||||||
if (!featureId) {
|
if (!featureId) {
|
||||||
logger.warn(
|
logger.debug(
|
||||||
`No featureId found for meterId ${meterId}. Skipping usage reset.`
|
`No featureId found for meterId ${meterId}. Skipping usage reset.`
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const orgId = existingCustomer.orgId;
|
const orgId = customer.orgId;
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
logger.warn(
|
logger.debug(
|
||||||
`No orgId found in subscription metadata for subscription ${subscription.id}. Skipping usage reset.`
|
`No orgId found in subscription metadata for subscription ${subscription.id}. Skipping usage reset.`
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
@@ -236,6 +267,57 @@ export async function handleSubscriptionUpdated(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// --- end usage update ---
|
// --- end usage update ---
|
||||||
|
|
||||||
|
if (type === "tier1" || type === "tier2" || type === "tier3") {
|
||||||
|
logger.debug(
|
||||||
|
`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,
|
||||||
|
type
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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:
|
||||||
|
// this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId
|
||||||
|
await fetch(
|
||||||
|
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"api-key":
|
||||||
|
privateConfig.getRawPrivateConfig()
|
||||||
|
.server.fossorial_api_key!,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
orgId: customer.orgId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
export * from "./createCheckoutSession";
|
export * from "./createCheckoutSession";
|
||||||
export * from "./createPortalSession";
|
export * from "./createPortalSession";
|
||||||
export * from "./getOrgSubscription";
|
export * from "./getOrgSubscriptions";
|
||||||
export * from "./getOrgUsage";
|
export * from "./getOrgUsage";
|
||||||
export * from "./internalGetOrgTier";
|
export * from "./internalGetOrgTier";
|
||||||
|
export * from "./changeTier";
|
||||||
|
|||||||
@@ -13,38 +13,66 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
freeLimitSet,
|
freeLimitSet,
|
||||||
|
tier1LimitSet,
|
||||||
|
tier2LimitSet,
|
||||||
|
tier3LimitSet,
|
||||||
limitsService,
|
limitsService,
|
||||||
subscribedLimitSet
|
LimitSet
|
||||||
} from "@server/lib/billing";
|
} from "@server/lib/billing";
|
||||||
import { usageService } from "@server/lib/billing/usageService";
|
import { usageService } from "@server/lib/billing/usageService";
|
||||||
import logger from "@server/logger";
|
import { SubscriptionType } from "./hooks/getSubType";
|
||||||
|
|
||||||
|
function getLimitSetForSubscriptionType(
|
||||||
|
subType: SubscriptionType | null
|
||||||
|
): LimitSet {
|
||||||
|
switch (subType) {
|
||||||
|
case "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(
|
export async function handleSubscriptionLifesycle(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
status: string
|
status: string,
|
||||||
|
subType: SubscriptionType | null
|
||||||
) {
|
) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "active":
|
case "active":
|
||||||
await limitsService.applyLimitSetToOrg(orgId, subscribedLimitSet);
|
const activeLimitSet = getLimitSetForSubscriptionType(subType);
|
||||||
await usageService.checkLimitSet(orgId, true);
|
await limitsService.applyLimitSetToOrg(orgId, activeLimitSet);
|
||||||
|
await usageService.checkLimitSet(orgId);
|
||||||
break;
|
break;
|
||||||
case "canceled":
|
case "canceled":
|
||||||
|
// Subscription canceled - revert to free tier
|
||||||
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
|
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
|
||||||
await usageService.checkLimitSet(orgId, true);
|
await usageService.checkLimitSet(orgId);
|
||||||
break;
|
break;
|
||||||
case "past_due":
|
case "past_due":
|
||||||
// Optionally handle past due status, e.g., notify customer
|
// Payment past due - keep current limits but notify customer
|
||||||
|
// Limits will revert to free tier if it becomes unpaid
|
||||||
break;
|
break;
|
||||||
case "unpaid":
|
case "unpaid":
|
||||||
|
// Subscription unpaid - revert to free tier
|
||||||
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
|
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
|
||||||
await usageService.checkLimitSet(orgId, true);
|
await usageService.checkLimitSet(orgId);
|
||||||
break;
|
break;
|
||||||
case "incomplete":
|
case "incomplete":
|
||||||
// Optionally handle incomplete status, e.g., notify customer
|
// Payment incomplete - give them time to complete payment
|
||||||
break;
|
break;
|
||||||
case "incomplete_expired":
|
case "incomplete_expired":
|
||||||
|
// Payment never completed - revert to free tier
|
||||||
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
|
await limitsService.applyLimitSetToOrg(orgId, freeLimitSet);
|
||||||
await usageService.checkLimitSet(orgId, true);
|
await usageService.checkLimitSet(orgId);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ import {
|
|||||||
verifyUserHasAction,
|
verifyUserHasAction,
|
||||||
verifyUserIsServerAdmin,
|
verifyUserIsServerAdmin,
|
||||||
verifySiteAccess,
|
verifySiteAccess,
|
||||||
verifyClientAccess
|
verifyClientAccess,
|
||||||
|
verifyLimits
|
||||||
} from "@server/middlewares";
|
} from "@server/middlewares";
|
||||||
import { ActionsEnum } from "@server/auth/actions";
|
import { ActionsEnum } from "@server/auth/actions";
|
||||||
import {
|
import {
|
||||||
@@ -52,6 +53,7 @@ import {
|
|||||||
authenticated as a,
|
authenticated as a,
|
||||||
authRouter as aa
|
authRouter as aa
|
||||||
} from "@server/routers/external";
|
} from "@server/routers/external";
|
||||||
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
|
||||||
export const authenticated = a;
|
export const authenticated = a;
|
||||||
export const unauthenticated = ua;
|
export const unauthenticated = ua;
|
||||||
@@ -76,7 +78,9 @@ unauthenticated.post(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/idp/oidc",
|
"/org/:orgId/idp/oidc",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
verifyValidSubscription(tierMatrix.orgOidc),
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.createIdp),
|
verifyUserHasAction(ActionsEnum.createIdp),
|
||||||
logActionAudit(ActionsEnum.createIdp),
|
logActionAudit(ActionsEnum.createIdp),
|
||||||
orgIdp.createOrgOidcIdp
|
orgIdp.createOrgOidcIdp
|
||||||
@@ -85,8 +89,10 @@ authenticated.put(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/org/:orgId/idp/:idpId/oidc",
|
"/org/:orgId/idp/:idpId/oidc",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
verifyValidSubscription(tierMatrix.orgOidc),
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyIdpAccess,
|
verifyIdpAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.updateIdp),
|
verifyUserHasAction(ActionsEnum.updateIdp),
|
||||||
logActionAudit(ActionsEnum.updateIdp),
|
logActionAudit(ActionsEnum.updateIdp),
|
||||||
orgIdp.updateOrgOidcIdp
|
orgIdp.updateOrgOidcIdp
|
||||||
@@ -135,29 +141,13 @@ authenticated.post(
|
|||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyCertificateAccess,
|
verifyCertificateAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.restartCertificate),
|
verifyUserHasAction(ActionsEnum.restartCertificate),
|
||||||
logActionAudit(ActionsEnum.restartCertificate),
|
logActionAudit(ActionsEnum.restartCertificate),
|
||||||
certificates.restartCertificate
|
certificates.restartCertificate
|
||||||
);
|
);
|
||||||
|
|
||||||
if (build === "saas") {
|
if (build === "saas") {
|
||||||
unauthenticated.post(
|
|
||||||
"/quick-start",
|
|
||||||
rateLimit({
|
|
||||||
windowMs: 15 * 60 * 1000,
|
|
||||||
max: 100,
|
|
||||||
keyGenerator: (req) => req.path,
|
|
||||||
handler: (req, res, next) => {
|
|
||||||
const message = `We're too busy right now. Please try again later.`;
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.TOO_MANY_REQUESTS, message)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
store: createStore()
|
|
||||||
}),
|
|
||||||
auth.quickStart
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/org/:orgId/billing/create-checkout-session",
|
"/org/:orgId/billing/create-checkout-session",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
@@ -166,6 +156,14 @@ if (build === "saas") {
|
|||||||
billing.createCheckoutSession
|
billing.createCheckoutSession
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/org/:orgId/billing/change-tier",
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.billing),
|
||||||
|
logActionAudit(ActionsEnum.billing),
|
||||||
|
billing.changeTier
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/org/:orgId/billing/create-portal-session",
|
"/org/:orgId/billing/create-portal-session",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
@@ -175,10 +173,10 @@ if (build === "saas") {
|
|||||||
);
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/billing/subscription",
|
"/org/:orgId/billing/subscriptions",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserHasAction(ActionsEnum.billing),
|
verifyUserHasAction(ActionsEnum.billing),
|
||||||
billing.getOrgSubscription
|
billing.getOrgSubscriptions
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
@@ -200,6 +198,14 @@ if (build === "saas") {
|
|||||||
generateLicense.generateNewLicense
|
generateLicense.generateNewLicense
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/org/:orgId/license/enterprise",
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.billing),
|
||||||
|
logActionAudit(ActionsEnum.billing),
|
||||||
|
generateLicense.generateNewEnterpriseLicense
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/send-support-request",
|
"/send-support-request",
|
||||||
rateLimit({
|
rateLimit({
|
||||||
@@ -235,6 +241,7 @@ authenticated.put(
|
|||||||
"/org/:orgId/remote-exit-node",
|
"/org/:orgId/remote-exit-node",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.createRemoteExitNode),
|
verifyUserHasAction(ActionsEnum.createRemoteExitNode),
|
||||||
logActionAudit(ActionsEnum.createRemoteExitNode),
|
logActionAudit(ActionsEnum.createRemoteExitNode),
|
||||||
remoteExitNode.createRemoteExitNode
|
remoteExitNode.createRemoteExitNode
|
||||||
@@ -278,7 +285,9 @@ authenticated.delete(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/login-page",
|
"/org/:orgId/login-page",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
verifyValidSubscription(tierMatrix.loginPageDomain),
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.createLoginPage),
|
verifyUserHasAction(ActionsEnum.createLoginPage),
|
||||||
logActionAudit(ActionsEnum.createLoginPage),
|
logActionAudit(ActionsEnum.createLoginPage),
|
||||||
loginPage.createLoginPage
|
loginPage.createLoginPage
|
||||||
@@ -287,8 +296,10 @@ authenticated.put(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/org/:orgId/login-page/:loginPageId",
|
"/org/:orgId/login-page/:loginPageId",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
verifyValidSubscription(tierMatrix.loginPageDomain),
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyLoginPageAccess,
|
verifyLoginPageAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.updateLoginPage),
|
verifyUserHasAction(ActionsEnum.updateLoginPage),
|
||||||
logActionAudit(ActionsEnum.updateLoginPage),
|
logActionAudit(ActionsEnum.updateLoginPage),
|
||||||
loginPage.updateLoginPage
|
loginPage.updateLoginPage
|
||||||
@@ -315,6 +326,7 @@ authenticated.get(
|
|||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/approvals",
|
"/org/:orgId/approvals",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
verifyValidSubscription(tierMatrix.deviceApprovals),
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserHasAction(ActionsEnum.listApprovals),
|
verifyUserHasAction(ActionsEnum.listApprovals),
|
||||||
logActionAudit(ActionsEnum.listApprovals),
|
logActionAudit(ActionsEnum.listApprovals),
|
||||||
@@ -331,7 +343,9 @@ authenticated.get(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/approvals/:approvalId",
|
"/org/:orgId/approvals/:approvalId",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
verifyValidSubscription(tierMatrix.deviceApprovals),
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.updateApprovals),
|
verifyUserHasAction(ActionsEnum.updateApprovals),
|
||||||
logActionAudit(ActionsEnum.updateApprovals),
|
logActionAudit(ActionsEnum.updateApprovals),
|
||||||
approval.processPendingApproval
|
approval.processPendingApproval
|
||||||
@@ -340,6 +354,7 @@ authenticated.put(
|
|||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/login-page-branding",
|
"/org/:orgId/login-page-branding",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
verifyValidSubscription(tierMatrix.loginPageBranding),
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserHasAction(ActionsEnum.getLoginPage),
|
verifyUserHasAction(ActionsEnum.getLoginPage),
|
||||||
logActionAudit(ActionsEnum.getLoginPage),
|
logActionAudit(ActionsEnum.getLoginPage),
|
||||||
@@ -349,7 +364,9 @@ authenticated.get(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/login-page-branding",
|
"/org/:orgId/login-page-branding",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
verifyValidSubscription(tierMatrix.loginPageBranding),
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.updateLoginPage),
|
verifyUserHasAction(ActionsEnum.updateLoginPage),
|
||||||
logActionAudit(ActionsEnum.updateLoginPage),
|
logActionAudit(ActionsEnum.updateLoginPage),
|
||||||
loginPage.upsertLoginPageBranding
|
loginPage.upsertLoginPageBranding
|
||||||
@@ -425,7 +442,7 @@ authenticated.post(
|
|||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/logs/action",
|
"/org/:orgId/logs/action",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
verifyValidSubscription,
|
verifyValidSubscription(tierMatrix.actionLogs),
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserHasAction(ActionsEnum.exportLogs),
|
verifyUserHasAction(ActionsEnum.exportLogs),
|
||||||
logs.queryActionAuditLogs
|
logs.queryActionAuditLogs
|
||||||
@@ -434,7 +451,7 @@ authenticated.get(
|
|||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/logs/action/export",
|
"/org/:orgId/logs/action/export",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
verifyValidSubscription,
|
verifyValidSubscription(tierMatrix.logExport),
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserHasAction(ActionsEnum.exportLogs),
|
verifyUserHasAction(ActionsEnum.exportLogs),
|
||||||
logActionAudit(ActionsEnum.exportLogs),
|
logActionAudit(ActionsEnum.exportLogs),
|
||||||
@@ -444,7 +461,7 @@ authenticated.get(
|
|||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/logs/access",
|
"/org/:orgId/logs/access",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
verifyValidSubscription,
|
verifyValidSubscription(tierMatrix.accessLogs),
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserHasAction(ActionsEnum.exportLogs),
|
verifyUserHasAction(ActionsEnum.exportLogs),
|
||||||
logs.queryAccessAuditLogs
|
logs.queryAccessAuditLogs
|
||||||
@@ -453,7 +470,7 @@ authenticated.get(
|
|||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/logs/access/export",
|
"/org/:orgId/logs/access/export",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
verifyValidSubscription,
|
verifyValidSubscription(tierMatrix.logExport),
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserHasAction(ActionsEnum.exportLogs),
|
verifyUserHasAction(ActionsEnum.exportLogs),
|
||||||
logActionAudit(ActionsEnum.exportLogs),
|
logActionAudit(ActionsEnum.exportLogs),
|
||||||
@@ -462,18 +479,20 @@ authenticated.get(
|
|||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/re-key/:clientId/regenerate-client-secret",
|
"/re-key/:clientId/regenerate-client-secret",
|
||||||
verifyClientAccess, // this is first to set the org id
|
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
verifyValidSubscription,
|
verifyValidSubscription(tierMatrix.rotateCredentials),
|
||||||
|
verifyClientAccess, // this is first to set the org id
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.reGenerateSecret),
|
verifyUserHasAction(ActionsEnum.reGenerateSecret),
|
||||||
reKey.reGenerateClientSecret
|
reKey.reGenerateClientSecret
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/re-key/:siteId/regenerate-site-secret",
|
"/re-key/:siteId/regenerate-site-secret",
|
||||||
verifySiteAccess, // this is first to set the org id
|
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
verifyValidSubscription,
|
verifyValidSubscription(tierMatrix.rotateCredentials),
|
||||||
|
verifySiteAccess, // this is first to set the org id
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.reGenerateSecret),
|
verifyUserHasAction(ActionsEnum.reGenerateSecret),
|
||||||
reKey.reGenerateSiteSecret
|
reKey.reGenerateSiteSecret
|
||||||
);
|
);
|
||||||
@@ -481,8 +500,9 @@ authenticated.post(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/re-key/:orgId/regenerate-remote-exit-node-secret",
|
"/re-key/:orgId/regenerate-remote-exit-node-secret",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
verifyValidSubscription,
|
verifyValidSubscription(tierMatrix.rotateCredentials),
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.reGenerateSecret),
|
verifyUserHasAction(ActionsEnum.reGenerateSecret),
|
||||||
reKey.reGenerateExitNodeSecret
|
reKey.reGenerateExitNodeSecret
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { response as sendResponse } from "@server/lib/response";
|
||||||
|
import privateConfig from "#private/lib/config";
|
||||||
|
import { createNewLicense } from "./generateNewLicense";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
import { getLicensePriceSet, LicenseId } from "@server/lib/billing/licenses";
|
||||||
|
import stripe from "#private/lib/stripe";
|
||||||
|
import { customers, db } from "@server/db";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import z from "zod";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { log } from "winston";
|
||||||
|
|
||||||
|
const generateNewEnterpriseLicenseParamsSchema = z.strictObject({
|
||||||
|
orgId: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function generateNewEnterpriseLicense(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const parsedParams = generateNewEnterpriseLicenseParamsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
|
if (!orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Organization ID is required"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Generating new license for orgId: ${orgId}`);
|
||||||
|
|
||||||
|
const licenseData = req.body;
|
||||||
|
|
||||||
|
if (licenseData.tier != "big_license" && licenseData.tier != "small_license") {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Invalid tier specified. Must be either 'big_license' or 'small_license'."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiResponse = await createNewLicense(orgId, licenseData);
|
||||||
|
|
||||||
|
// Check if the API call was successful
|
||||||
|
if (!apiResponse.success || apiResponse.error) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
apiResponse.status || HttpCode.BAD_REQUEST,
|
||||||
|
apiResponse.message || "Failed to create license from Fossorial API"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyId = apiResponse?.data?.licenseKey?.id;
|
||||||
|
if (!keyId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Fossorial API did not return a valid license key ID"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if we already have a customer for this org
|
||||||
|
const [customer] = await db
|
||||||
|
.select()
|
||||||
|
.from(customers)
|
||||||
|
.where(eq(customers.orgId, orgId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
// If we don't have a customer, create one
|
||||||
|
if (!customer) {
|
||||||
|
// error
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"No customer found for this organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tier = licenseData.tier === "big_license" ? LicenseId.BIG_LICENSE : LicenseId.SMALL_LICENSE;
|
||||||
|
const tierPrice = getLicensePriceSet()[tier]
|
||||||
|
|
||||||
|
const session = await stripe!.checkout.sessions.create({
|
||||||
|
client_reference_id: keyId.toString(),
|
||||||
|
billing_address_collection: "required",
|
||||||
|
line_items: [
|
||||||
|
{
|
||||||
|
price: tierPrice, // Use the standard tier
|
||||||
|
quantity: 1
|
||||||
|
},
|
||||||
|
], // Start with the standard feature set that matches the free limits
|
||||||
|
customer: customer.customerId,
|
||||||
|
mode: "subscription",
|
||||||
|
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`
|
||||||
|
});
|
||||||
|
|
||||||
|
return sendResponse<string>(res, {
|
||||||
|
data: session.url,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "License and checkout session created successfully",
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"An error occurred while generating new license."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,10 +19,40 @@ import { response as sendResponse } from "@server/lib/response";
|
|||||||
import privateConfig from "#private/lib/config";
|
import privateConfig from "#private/lib/config";
|
||||||
import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types";
|
import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types";
|
||||||
|
|
||||||
async function createNewLicense(orgId: string, licenseData: any): Promise<any> {
|
export interface CreateNewLicenseResponse {
|
||||||
|
data: Data
|
||||||
|
success: boolean
|
||||||
|
error: boolean
|
||||||
|
message: string
|
||||||
|
status: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Data {
|
||||||
|
licenseKey: LicenseKey
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LicenseKey {
|
||||||
|
id: number
|
||||||
|
instanceName: any
|
||||||
|
instanceId: string
|
||||||
|
licenseKey: string
|
||||||
|
tier: string
|
||||||
|
type: string
|
||||||
|
quantity: number
|
||||||
|
quantity_2: number
|
||||||
|
isValid: boolean
|
||||||
|
updatedAt: string
|
||||||
|
createdAt: string
|
||||||
|
expiresAt: string
|
||||||
|
paidFor: boolean
|
||||||
|
orgId: string
|
||||||
|
metadata: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createNewLicense(orgId: string, licenseData: any): Promise<CreateNewLicenseResponse> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`https://api.fossorial.io/api/v1/license-internal/enterprise/${orgId}/create`,
|
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/create`, // this says enterprise but it does both
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -35,9 +65,8 @@ async function createNewLicense(orgId: string, licenseData: any): Promise<any> {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const data = await response.json();
|
const data: CreateNewLicenseResponse = await response.json();
|
||||||
|
|
||||||
logger.debug("Fossorial API response:", { data });
|
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating new license:", error);
|
console.error("Error creating new license:", error);
|
||||||
|
|||||||
@@ -13,3 +13,4 @@
|
|||||||
|
|
||||||
export * from "./listGeneratedLicenses";
|
export * from "./listGeneratedLicenses";
|
||||||
export * from "./generateNewLicense";
|
export * from "./generateNewLicense";
|
||||||
|
export * from "./generateNewEnterpriseLicense";
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
async function fetchLicenseKeys(orgId: string): Promise<any> {
|
async function fetchLicenseKeys(orgId: string): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`https://api.fossorial.io/api/v1/license-internal/enterprise/${orgId}/list`,
|
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/list`,
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export type ResourceWithAuth = {
|
|||||||
password: ResourcePassword | null;
|
password: ResourcePassword | null;
|
||||||
headerAuth: ResourceHeaderAuth | null;
|
headerAuth: ResourceHeaderAuth | null;
|
||||||
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
|
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
|
||||||
org: Org
|
org: Org;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserSessionWithUser = {
|
export type UserSessionWithUser = {
|
||||||
@@ -270,7 +270,6 @@ hybridRouter.get(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
let encryptionKeyPath = "";
|
|
||||||
let encryptionKeyHex = "";
|
let encryptionKeyHex = "";
|
||||||
let encryptionKey: Buffer;
|
let encryptionKey: Buffer;
|
||||||
function loadEncryptData() {
|
function loadEncryptData() {
|
||||||
@@ -278,16 +277,8 @@ function loadEncryptData() {
|
|||||||
return; // already loaded
|
return; // already loaded
|
||||||
}
|
}
|
||||||
|
|
||||||
encryptionKeyPath =
|
encryptionKeyHex =
|
||||||
privateConfig.getRawPrivateConfig().server.encryption_key_path;
|
privateConfig.getRawPrivateConfig().server.encryption_key;
|
||||||
|
|
||||||
if (!fs.existsSync(encryptionKeyPath)) {
|
|
||||||
throw new Error(
|
|
||||||
"Encryption key file not found. Please generate one first."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
encryptionKeyHex = fs.readFileSync(encryptionKeyPath, "utf8").trim();
|
|
||||||
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
|
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,21 +19,20 @@ import {
|
|||||||
verifyApiKeyHasAction,
|
verifyApiKeyHasAction,
|
||||||
verifyApiKeyIsRoot,
|
verifyApiKeyIsRoot,
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
verifyApiKeyIdpAccess
|
verifyApiKeyIdpAccess,
|
||||||
|
verifyLimits
|
||||||
} from "@server/middlewares";
|
} from "@server/middlewares";
|
||||||
import {
|
import {
|
||||||
verifyValidSubscription,
|
verifyValidSubscription,
|
||||||
verifyValidLicense
|
verifyValidLicense
|
||||||
} from "#private/middlewares";
|
} from "#private/middlewares";
|
||||||
import { ActionsEnum } from "@server/auth/actions";
|
import { ActionsEnum } from "@server/auth/actions";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
unauthenticated as ua,
|
unauthenticated as ua,
|
||||||
authenticated as a
|
authenticated as a
|
||||||
} from "@server/routers/integration";
|
} from "@server/routers/integration";
|
||||||
import { logActionAudit } from "#private/middlewares";
|
import { logActionAudit } from "#private/middlewares";
|
||||||
import config from "#private/lib/config";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import { build } from "@server/build";
|
|
||||||
|
|
||||||
export const unauthenticated = ua;
|
export const unauthenticated = ua;
|
||||||
export const authenticated = a;
|
export const authenticated = a;
|
||||||
@@ -57,7 +56,7 @@ authenticated.delete(
|
|||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/logs/action",
|
"/org/:orgId/logs/action",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
verifyValidSubscription,
|
verifyValidSubscription(tierMatrix.actionLogs),
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
verifyApiKeyHasAction(ActionsEnum.exportLogs),
|
verifyApiKeyHasAction(ActionsEnum.exportLogs),
|
||||||
logs.queryActionAuditLogs
|
logs.queryActionAuditLogs
|
||||||
@@ -66,7 +65,7 @@ authenticated.get(
|
|||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/logs/action/export",
|
"/org/:orgId/logs/action/export",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
verifyValidSubscription,
|
verifyValidSubscription(tierMatrix.logExport),
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
verifyApiKeyHasAction(ActionsEnum.exportLogs),
|
verifyApiKeyHasAction(ActionsEnum.exportLogs),
|
||||||
logActionAudit(ActionsEnum.exportLogs),
|
logActionAudit(ActionsEnum.exportLogs),
|
||||||
@@ -76,7 +75,7 @@ authenticated.get(
|
|||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/logs/access",
|
"/org/:orgId/logs/access",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
verifyValidSubscription,
|
verifyValidSubscription(tierMatrix.accessLogs),
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
verifyApiKeyHasAction(ActionsEnum.exportLogs),
|
verifyApiKeyHasAction(ActionsEnum.exportLogs),
|
||||||
logs.queryAccessAuditLogs
|
logs.queryAccessAuditLogs
|
||||||
@@ -85,7 +84,7 @@ authenticated.get(
|
|||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/logs/access/export",
|
"/org/:orgId/logs/access/export",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
verifyValidSubscription,
|
verifyValidSubscription(tierMatrix.logExport),
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
verifyApiKeyHasAction(ActionsEnum.exportLogs),
|
verifyApiKeyHasAction(ActionsEnum.exportLogs),
|
||||||
logActionAudit(ActionsEnum.exportLogs),
|
logActionAudit(ActionsEnum.exportLogs),
|
||||||
@@ -95,7 +94,9 @@ authenticated.get(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/idp/oidc",
|
"/org/:orgId/idp/oidc",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
verifyValidSubscription(tierMatrix.orgOidc),
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.createIdp),
|
verifyApiKeyHasAction(ActionsEnum.createIdp),
|
||||||
logActionAudit(ActionsEnum.createIdp),
|
logActionAudit(ActionsEnum.createIdp),
|
||||||
orgIdp.createOrgOidcIdp
|
orgIdp.createOrgOidcIdp
|
||||||
@@ -104,8 +105,10 @@ authenticated.put(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/org/:orgId/idp/:idpId/oidc",
|
"/org/:orgId/idp/:idpId/oidc",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
verifyValidSubscription(tierMatrix.orgOidc),
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
verifyApiKeyIdpAccess,
|
verifyApiKeyIdpAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.updateIdp),
|
verifyApiKeyHasAction(ActionsEnum.updateIdp),
|
||||||
logActionAudit(ActionsEnum.updateIdp),
|
logActionAudit(ActionsEnum.updateIdp),
|
||||||
orgIdp.updateOrgOidcIdp
|
orgIdp.updateOrgOidcIdp
|
||||||
|
|||||||
@@ -30,9 +30,7 @@ import { fromError } from "zod-validation-error";
|
|||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||||
import { createCertificate } from "#private/routers/certificates/createCertificate";
|
import { createCertificate } from "#private/routers/certificates/createCertificate";
|
||||||
import { getOrgTierData } from "#private/lib/billing";
|
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
import { build } from "@server/build";
|
|
||||||
import { CreateLoginPageResponse } from "@server/routers/loginPage/types";
|
import { CreateLoginPageResponse } from "@server/routers/loginPage/types";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
@@ -76,19 +74,6 @@ export async function createLoginPage(
|
|||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
if (build === "saas") {
|
|
||||||
const { tier } = await getOrgTierData(orgId);
|
|
||||||
const subscribed = tier === TierId.STANDARD;
|
|
||||||
if (!subscribed) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.FORBIDDEN,
|
|
||||||
"This organization's current plan does not support this feature."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(loginPageOrg)
|
.from(loginPageOrg)
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ import createHttpError from "http-errors";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { getOrgTierData } from "#private/lib/billing";
|
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
import { build } from "@server/build";
|
|
||||||
|
|
||||||
const paramsSchema = z
|
const paramsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -53,18 +51,6 @@ export async function deleteLoginPageBranding(
|
|||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
if (build === "saas") {
|
|
||||||
const { tier } = await getOrgTierData(orgId);
|
|
||||||
const subscribed = tier === TierId.STANDARD;
|
|
||||||
if (!subscribed) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.FORBIDDEN,
|
|
||||||
"This organization's current plan does not support this feature."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existingLoginPageBranding] = await db
|
const [existingLoginPageBranding] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ import createHttpError from "http-errors";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { getOrgTierData } from "#private/lib/billing";
|
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
import { build } from "@server/build";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
@@ -51,19 +49,6 @@ export async function getLoginPageBranding(
|
|||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
if (build === "saas") {
|
|
||||||
const { tier } = await getOrgTierData(orgId);
|
|
||||||
const subscribed = tier === TierId.STANDARD;
|
|
||||||
if (!subscribed) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.FORBIDDEN,
|
|
||||||
"This organization's current plan does not support this feature."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existingLoginPageBranding] = await db
|
const [existingLoginPageBranding] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(loginPageBranding)
|
.from(loginPageBranding)
|
||||||
|
|||||||
@@ -23,9 +23,7 @@ import { eq, and } from "drizzle-orm";
|
|||||||
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||||
import { subdomainSchema } from "@server/lib/schemas";
|
import { subdomainSchema } from "@server/lib/schemas";
|
||||||
import { createCertificate } from "#private/routers/certificates/createCertificate";
|
import { createCertificate } from "#private/routers/certificates/createCertificate";
|
||||||
import { getOrgTierData } from "#private/lib/billing";
|
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
import { build } from "@server/build";
|
|
||||||
import { UpdateLoginPageResponse } from "@server/routers/loginPage/types";
|
import { UpdateLoginPageResponse } from "@server/routers/loginPage/types";
|
||||||
|
|
||||||
const paramsSchema = z
|
const paramsSchema = z
|
||||||
@@ -87,18 +85,6 @@ export async function updateLoginPage(
|
|||||||
|
|
||||||
const { loginPageId, orgId } = parsedParams.data;
|
const { loginPageId, orgId } = parsedParams.data;
|
||||||
|
|
||||||
if (build === "saas") {
|
|
||||||
const { tier } = await getOrgTierData(orgId);
|
|
||||||
const subscribed = tier === TierId.STANDARD;
|
|
||||||
if (!subscribed) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.FORBIDDEN,
|
|
||||||
"This organization's current plan does not support this feature."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existingLoginPage] = await db
|
const [existingLoginPage] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -25,10 +25,8 @@ import createHttpError from "http-errors";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { eq, InferInsertModel } from "drizzle-orm";
|
import { eq, InferInsertModel } from "drizzle-orm";
|
||||||
import { getOrgTierData } from "#private/lib/billing";
|
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import config from "@server/private/lib/config";
|
import config from "#private/lib/config";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
@@ -37,27 +35,55 @@ const paramsSchema = z.strictObject({
|
|||||||
const bodySchema = z.strictObject({
|
const bodySchema = z.strictObject({
|
||||||
logoUrl: z
|
logoUrl: z
|
||||||
.union([
|
.union([
|
||||||
z.string().length(0),
|
z.literal(""),
|
||||||
z.url().refine(
|
z
|
||||||
async (url) => {
|
.url("Must be a valid URL")
|
||||||
|
.superRefine(async (url, ctx) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url, {
|
||||||
return (
|
method: "HEAD"
|
||||||
response.status === 200 &&
|
}).catch(() => {
|
||||||
(
|
// If HEAD fails (CORS or method not allowed), try GET
|
||||||
response.headers.get("content-type") ?? ""
|
return fetch(url, { method: "GET" });
|
||||||
).startsWith("image/")
|
});
|
||||||
);
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: `Failed to load image. Please check that the URL is accessible.`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType =
|
||||||
|
response.headers.get("content-type") ?? "";
|
||||||
|
if (!contentType.startsWith("image/")) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: `URL does not point to an image. Please provide a URL to an image file (e.g., .png, .jpg, .svg).`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return false;
|
let errorMessage =
|
||||||
|
"Unable to verify image URL. Please check that the URL is accessible and points to an image file.";
|
||||||
|
|
||||||
|
if (error instanceof TypeError && error.message.includes("fetch")) {
|
||||||
|
errorMessage =
|
||||||
|
"Network error: Unable to reach the URL. Please check your internet connection and verify the URL is correct.";
|
||||||
|
} else if (error instanceof Error) {
|
||||||
|
errorMessage = `Error verifying URL: ${error.message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: errorMessage
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
{
|
|
||||||
error: "Invalid logo URL, must be a valid image URL"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
])
|
])
|
||||||
.optional(),
|
.transform((val) => (val === "" ? null : val))
|
||||||
|
.nullish(),
|
||||||
logoWidth: z.coerce.number<number>().min(1),
|
logoWidth: z.coerce.number<number>().min(1),
|
||||||
logoHeight: z.coerce.number<number>().min(1),
|
logoHeight: z.coerce.number<number>().min(1),
|
||||||
resourceTitle: z.string(),
|
resourceTitle: z.string(),
|
||||||
@@ -78,7 +104,7 @@ export async function upsertLoginPageBranding(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const parsedBody = bodySchema.safeParse(req.body);
|
const parsedBody = await bodySchema.safeParseAsync(req.body);
|
||||||
if (!parsedBody.success) {
|
if (!parsedBody.success) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
@@ -100,26 +126,12 @@ export async function upsertLoginPageBranding(
|
|||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
if (build === "saas") {
|
|
||||||
const { tier } = await getOrgTierData(orgId);
|
|
||||||
const subscribed = tier === TierId.STANDARD;
|
|
||||||
if (!subscribed) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.FORBIDDEN,
|
|
||||||
"This organization's current plan does not support this feature."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let updateData = parsedBody.data satisfies InferInsertModel<
|
let updateData = parsedBody.data satisfies InferInsertModel<
|
||||||
typeof loginPageBranding
|
typeof loginPageBranding
|
||||||
>;
|
>;
|
||||||
|
|
||||||
if ((updateData.logoUrl ?? "").trim().length === 0) {
|
// Empty strings are transformed to null by the schema, which will clear the logo URL in the database
|
||||||
updateData.logoUrl = undefined;
|
// We keep it as null (not undefined) because undefined fields are omitted from Drizzle updates
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
build !== "saas" &&
|
build !== "saas" &&
|
||||||
|
|||||||
@@ -24,10 +24,9 @@ import { idp, idpOidcConfig, idpOrg, orgs } from "@server/db";
|
|||||||
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
||||||
import { encrypt } from "@server/lib/crypto";
|
import { encrypt } from "@server/lib/crypto";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { build } from "@server/build";
|
|
||||||
import { getOrgTierData } from "#private/lib/billing";
|
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
import { CreateOrgIdpResponse } from "@server/routers/orgIdp/types";
|
import { CreateOrgIdpResponse } from "@server/routers/orgIdp/types";
|
||||||
|
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||||
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({ orgId: z.string().nonempty() });
|
const paramsSchema = z.strictObject({ orgId: z.string().nonempty() });
|
||||||
|
|
||||||
@@ -103,23 +102,19 @@ export async function createOrgOidcIdp(
|
|||||||
emailPath,
|
emailPath,
|
||||||
namePath,
|
namePath,
|
||||||
name,
|
name,
|
||||||
autoProvision,
|
|
||||||
variant,
|
variant,
|
||||||
roleMapping,
|
roleMapping,
|
||||||
tags
|
tags
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
if (build === "saas") {
|
let { autoProvision } = parsedBody.data;
|
||||||
const { tier, active } = await getOrgTierData(orgId);
|
|
||||||
const subscribed = tier === TierId.STANDARD;
|
const subscribed = await isSubscribed(
|
||||||
if (!subscribed) {
|
orgId,
|
||||||
return next(
|
tierMatrix.deviceApprovals
|
||||||
createHttpError(
|
);
|
||||||
HttpCode.FORBIDDEN,
|
if (!subscribed) {
|
||||||
"This organization's current plan does not support this feature."
|
autoProvision = false;
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = config.getRawConfig().server.secret!;
|
const key = config.getRawConfig().server.secret!;
|
||||||
|
|||||||
@@ -24,9 +24,8 @@ import { idp, idpOidcConfig } from "@server/db";
|
|||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { encrypt } from "@server/lib/crypto";
|
import { encrypt } from "@server/lib/crypto";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { build } from "@server/build";
|
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||||
import { getOrgTierData } from "#private/lib/billing";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
|
|
||||||
const paramsSchema = z
|
const paramsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -109,22 +108,18 @@ export async function updateOrgOidcIdp(
|
|||||||
emailPath,
|
emailPath,
|
||||||
namePath,
|
namePath,
|
||||||
name,
|
name,
|
||||||
autoProvision,
|
|
||||||
roleMapping,
|
roleMapping,
|
||||||
tags
|
tags
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
if (build === "saas") {
|
let { autoProvision } = parsedBody.data;
|
||||||
const { tier, active } = await getOrgTierData(orgId);
|
|
||||||
const subscribed = tier === TierId.STANDARD;
|
const subscribed = await isSubscribed(
|
||||||
if (!subscribed) {
|
orgId,
|
||||||
return next(
|
tierMatrix.deviceApprovals
|
||||||
createHttpError(
|
);
|
||||||
HttpCode.FORBIDDEN,
|
if (!subscribed) {
|
||||||
"This organization's current plan does not support this feature."
|
autoProvision = false;
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if IDP exists and is of type OIDC
|
// Check if IDP exists and is of type OIDC
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export async function createRemoteExitNode(
|
|||||||
if (usage) {
|
if (usage) {
|
||||||
const rejectRemoteExitNodes = await usageService.checkLimitSet(
|
const rejectRemoteExitNodes = await usageService.checkLimitSet(
|
||||||
orgId,
|
orgId,
|
||||||
false,
|
|
||||||
FeatureId.REMOTE_EXIT_NODES,
|
FeatureId.REMOTE_EXIT_NODES,
|
||||||
{
|
{
|
||||||
...usage,
|
...usage,
|
||||||
@@ -97,7 +97,7 @@ export async function createRemoteExitNode(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
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) {
|
if (numExitNodeOrgs) {
|
||||||
await usageService.updateDaily(
|
await usageService.updateCount(
|
||||||
orgId,
|
orgId,
|
||||||
FeatureId.REMOTE_EXIT_NODES,
|
FeatureId.REMOTE_EXIT_NODES,
|
||||||
numExitNodeOrgs.length
|
numExitNodeOrgs.length
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export async function deleteRemoteExitNode(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (numExitNodeOrgs) {
|
if (numExitNodeOrgs) {
|
||||||
await usageService.updateDaily(
|
await usageService.updateCount(
|
||||||
orgId,
|
orgId,
|
||||||
FeatureId.REMOTE_EXIT_NODES,
|
FeatureId.REMOTE_EXIT_NODES,
|
||||||
numExitNodeOrgs.length
|
numExitNodeOrgs.length
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { db, orgs, requestAuditLog } from "@server/db";
|
import { db, orgs, requestAuditLog } from "@server/db";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { and, eq, lt } from "drizzle-orm";
|
import { and, eq, lt, sql } from "drizzle-orm";
|
||||||
import cache from "@server/lib/cache";
|
import cache from "@server/lib/cache";
|
||||||
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
|
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
|
||||||
import { stripPortFromHost } from "@server/lib/ip";
|
import { stripPortFromHost } from "@server/lib/ip";
|
||||||
@@ -67,17 +67,27 @@ async function flushAuditLogs() {
|
|||||||
const logsToWrite = auditLogBuffer.splice(0, auditLogBuffer.length);
|
const logsToWrite = auditLogBuffer.splice(0, auditLogBuffer.length);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Batch insert logs in groups of 25 to avoid overwhelming the database
|
// Use a transaction to ensure all inserts succeed or fail together
|
||||||
const BATCH_DB_SIZE = 25;
|
// This prevents index corruption from partial writes
|
||||||
for (let i = 0; i < logsToWrite.length; i += BATCH_DB_SIZE) {
|
await db.transaction(async (tx) => {
|
||||||
const batch = logsToWrite.slice(i, i + BATCH_DB_SIZE);
|
// Batch insert logs in groups of 25 to avoid overwhelming the database
|
||||||
await db.insert(requestAuditLog).values(batch);
|
const BATCH_DB_SIZE = 25;
|
||||||
}
|
for (let i = 0; i < logsToWrite.length; i += BATCH_DB_SIZE) {
|
||||||
|
const batch = logsToWrite.slice(i, i + BATCH_DB_SIZE);
|
||||||
|
await tx.insert(requestAuditLog).values(batch);
|
||||||
|
}
|
||||||
|
});
|
||||||
logger.debug(`Flushed ${logsToWrite.length} audit logs to database`);
|
logger.debug(`Flushed ${logsToWrite.length} audit logs to database`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error flushing audit logs:", error);
|
logger.error("Error flushing audit logs:", error);
|
||||||
// On error, we lose these logs - consider a fallback strategy if needed
|
// On transaction error, put logs back at the front of the buffer to retry
|
||||||
// (e.g., write to file, or put back in buffer with retry limit)
|
// but only if buffer isn't too large
|
||||||
|
if (auditLogBuffer.length < MAX_BUFFER_SIZE - logsToWrite.length) {
|
||||||
|
auditLogBuffer.unshift(...logsToWrite);
|
||||||
|
logger.info(`Re-queued ${logsToWrite.length} audit logs for retry`);
|
||||||
|
} else {
|
||||||
|
logger.error(`Buffer full, dropped ${logsToWrite.length} audit logs`);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isFlushInProgress = false;
|
isFlushInProgress = false;
|
||||||
// If buffer filled up while we were flushing, flush again
|
// If buffer filled up while we were flushing, flush again
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ import {
|
|||||||
ResourceHeaderAuthExtendedCompatibility,
|
ResourceHeaderAuthExtendedCompatibility,
|
||||||
ResourcePassword,
|
ResourcePassword,
|
||||||
ResourcePincode,
|
ResourcePincode,
|
||||||
ResourceRule,
|
ResourceRule
|
||||||
resourceSessions
|
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
|
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
|
||||||
@@ -32,7 +31,6 @@ import { fromError } from "zod-validation-error";
|
|||||||
import { getCountryCodeForIp } from "@server/lib/geoip";
|
import { getCountryCodeForIp } from "@server/lib/geoip";
|
||||||
import { getAsnForIp } from "@server/lib/asn";
|
import { getAsnForIp } from "@server/lib/asn";
|
||||||
import { getOrgTierData } from "#dynamic/lib/billing";
|
import { getOrgTierData } from "#dynamic/lib/billing";
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
import { verifyPassword } from "@server/auth/password";
|
import { verifyPassword } from "@server/auth/password";
|
||||||
import {
|
import {
|
||||||
checkOrgAccessPolicy,
|
checkOrgAccessPolicy,
|
||||||
@@ -40,8 +38,9 @@ import {
|
|||||||
} from "#dynamic/lib/checkOrgAccessPolicy";
|
} from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
import { logRequestAudit } from "./logRequestAudit";
|
import { logRequestAudit } from "./logRequestAudit";
|
||||||
import cache from "@server/lib/cache";
|
import cache from "@server/lib/cache";
|
||||||
import semver from "semver";
|
|
||||||
import { APP_VERSION } from "@server/lib/consts";
|
import { APP_VERSION } from "@server/lib/consts";
|
||||||
|
import { isSubscribed } from "#private/lib/isSubscribed";
|
||||||
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
|
||||||
const verifyResourceSessionSchema = z.object({
|
const verifyResourceSessionSchema = z.object({
|
||||||
sessions: z.record(z.string(), z.string()).optional(),
|
sessions: z.record(z.string(), z.string()).optional(),
|
||||||
@@ -798,8 +797,11 @@ async function notAllowed(
|
|||||||
) {
|
) {
|
||||||
let loginPage: LoginPage | null = null;
|
let loginPage: LoginPage | null = null;
|
||||||
if (orgId) {
|
if (orgId) {
|
||||||
const { tier } = await getOrgTierData(orgId); // returns null in oss
|
const subscribed = await isSubscribed(
|
||||||
if (tier === TierId.STANDARD) {
|
orgId,
|
||||||
|
tierMatrix.loginPageDomain
|
||||||
|
);
|
||||||
|
if (subscribed) {
|
||||||
loginPage = await getOrgLoginPage(orgId);
|
loginPage = await getOrgLoginPage(orgId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -852,8 +854,8 @@ async function headerAuthChallenged(
|
|||||||
) {
|
) {
|
||||||
let loginPage: LoginPage | null = null;
|
let loginPage: LoginPage | null = null;
|
||||||
if (orgId) {
|
if (orgId) {
|
||||||
const { tier } = await getOrgTierData(orgId); // returns null in oss
|
const subscribed = await isSubscribed(orgId, tierMatrix.loginPageDomain);
|
||||||
if (tier === TierId.STANDARD) {
|
if (subscribed) {
|
||||||
loginPage = await getOrgLoginPage(orgId);
|
loginPage = await getOrgLoginPage(orgId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1039,7 +1041,11 @@ export function isPathAllowed(pattern: string, path: string): boolean {
|
|||||||
const MAX_RECURSION_DEPTH = 100;
|
const MAX_RECURSION_DEPTH = 100;
|
||||||
|
|
||||||
// Recursive function to try different wildcard matches
|
// 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
|
// Check recursion depth limit
|
||||||
if (depth > MAX_RECURSION_DEPTH) {
|
if (depth > MAX_RECURSION_DEPTH) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@@ -1125,7 +1131,11 @@ export function isPathAllowed(pattern: string, path: string): boolean {
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
`${indent}Segment with wildcard matches: "${currentPatternPart}" matches "${currentPathPart}"`
|
`${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(
|
logger.debug(
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Limit, Subscription, SubscriptionItem, Usage } from "@server/db";
|
import { Limit, Subscription, SubscriptionItem, Usage } from "@server/db";
|
||||||
|
|
||||||
export type GetOrgSubscriptionResponse = {
|
export type GetOrgSubscriptionResponse = {
|
||||||
subscription: Subscription | null;
|
subscriptions: Array<{ subscription: Subscription; items: SubscriptionItem[] }>;
|
||||||
items: SubscriptionItem[];
|
/** When build === saas, true if org has exceeded plan limits (sites, users, etc.) */
|
||||||
|
limitsExceeded?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetOrgUsageResponse = {
|
export type GetOrgUsageResponse = {
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ import createHttpError from "http-errors";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
|
|
||||||
import { sendTerminateClient } from "./terminate";
|
|
||||||
import { OlmErrorCodes } from "../olm/error";
|
|
||||||
|
|
||||||
const archiveClientSchema = z.strictObject({
|
const archiveClientSchema = z.strictObject({
|
||||||
clientId: z.string().transform(Number).pipe(z.int().positive())
|
clientId: z.string().transform(Number).pipe(z.int().positive())
|
||||||
@@ -77,9 +74,6 @@ export async function archiveClient(
|
|||||||
.update(clients)
|
.update(clients)
|
||||||
.set({ archived: true })
|
.set({ archived: true })
|
||||||
.where(eq(clients.clientId, clientId));
|
.where(eq(clients.clientId, clientId));
|
||||||
|
|
||||||
// Rebuild associations to clean up related data
|
|
||||||
await rebuildClientAssociationsFromClient(client, trx);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export async function createClient(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
"Invalid subnet format. Please provide a valid CIDR notation."
|
"Invalid subnet format. Please provide a valid IP."
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, olms } from "@server/db";
|
import { db, olms, users } from "@server/db";
|
||||||
import { clients, currentFingerprint } from "@server/db";
|
import { clients, currentFingerprint } from "@server/db";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
@@ -13,6 +13,7 @@ import { OpenAPITags, registry } from "@server/openApi";
|
|||||||
import { getUserDeviceName } from "@server/db/names";
|
import { getUserDeviceName } from "@server/db/names";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
|
||||||
const getClientSchema = z.strictObject({
|
const getClientSchema = z.strictObject({
|
||||||
clientId: z
|
clientId: z
|
||||||
@@ -36,6 +37,7 @@ async function query(clientId?: number, niceId?: string, orgId?: string) {
|
|||||||
currentFingerprint,
|
currentFingerprint,
|
||||||
eq(olms.olmId, currentFingerprint.olmId)
|
eq(olms.olmId, currentFingerprint.olmId)
|
||||||
)
|
)
|
||||||
|
.leftJoin(users, eq(clients.userId, users.userId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
return res;
|
return res;
|
||||||
} else if (niceId && orgId) {
|
} else if (niceId && orgId) {
|
||||||
@@ -48,25 +50,36 @@ async function query(clientId?: number, niceId?: string, orgId?: string) {
|
|||||||
currentFingerprint,
|
currentFingerprint,
|
||||||
eq(olms.olmId, currentFingerprint.olmId)
|
eq(olms.olmId, currentFingerprint.olmId)
|
||||||
)
|
)
|
||||||
|
.leftJoin(users, eq(clients.userId, users.userId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type PostureData = {
|
type PostureData = {
|
||||||
biometricsEnabled?: boolean | null;
|
biometricsEnabled?: boolean | null | "-";
|
||||||
diskEncrypted?: boolean | null;
|
diskEncrypted?: boolean | null | "-";
|
||||||
firewallEnabled?: boolean | null;
|
firewallEnabled?: boolean | null | "-";
|
||||||
autoUpdatesEnabled?: boolean | null;
|
autoUpdatesEnabled?: boolean | null | "-";
|
||||||
tpmAvailable?: boolean | null;
|
tpmAvailable?: boolean | null | "-";
|
||||||
windowsAntivirusEnabled?: boolean | null;
|
windowsAntivirusEnabled?: boolean | null | "-";
|
||||||
macosSipEnabled?: boolean | null;
|
macosSipEnabled?: boolean | null | "-";
|
||||||
macosGatekeeperEnabled?: boolean | null;
|
macosGatekeeperEnabled?: boolean | null | "-";
|
||||||
macosFirewallStealthMode?: boolean | null;
|
macosFirewallStealthMode?: boolean | null | "-";
|
||||||
linuxAppArmorEnabled?: boolean | null;
|
linuxAppArmorEnabled?: boolean | null | "-";
|
||||||
linuxSELinuxEnabled?: boolean | null;
|
linuxSELinuxEnabled?: boolean | null | "-";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function maskPostureDataWithPlaceholder(posture: PostureData): PostureData {
|
||||||
|
const masked: PostureData = {};
|
||||||
|
for (const key of Object.keys(posture) as (keyof PostureData)[]) {
|
||||||
|
if (posture[key] !== undefined && posture[key] !== null) {
|
||||||
|
(masked as Record<keyof PostureData, "-">)[key] = "-";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return masked;
|
||||||
|
}
|
||||||
|
|
||||||
function getPlatformPostureData(
|
function getPlatformPostureData(
|
||||||
platform: string | null | undefined,
|
platform: string | null | undefined,
|
||||||
fingerprint: typeof currentFingerprint.$inferSelect | null
|
fingerprint: typeof currentFingerprint.$inferSelect | null
|
||||||
@@ -207,6 +220,9 @@ export type GetClientResponse = NonNullable<
|
|||||||
olmId: string | null;
|
olmId: string | null;
|
||||||
agent: string | null;
|
agent: string | null;
|
||||||
olmVersion: string | null;
|
olmVersion: string | null;
|
||||||
|
userEmail: string | null;
|
||||||
|
userName: string | null;
|
||||||
|
userUsername: string | null;
|
||||||
fingerprint: {
|
fingerprint: {
|
||||||
username: string | null;
|
username: string | null;
|
||||||
hostname: string | null;
|
hostname: string | null;
|
||||||
@@ -279,9 +295,11 @@ export async function getClient(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isUserDevice = client.user !== null && client.user !== undefined;
|
||||||
|
|
||||||
// Replace name with device name if OLM exists
|
// Replace name with device name if OLM exists
|
||||||
let clientName = client.clients.name;
|
let clientName = client.clients.name;
|
||||||
if (client.olms) {
|
if (client.olms && isUserDevice) {
|
||||||
const model = client.currentFingerprint?.deviceModel || null;
|
const model = client.currentFingerprint?.deviceModel || null;
|
||||||
clientName = getUserDeviceName(model, client.clients.name);
|
clientName = getUserDeviceName(model, client.clients.name);
|
||||||
}
|
}
|
||||||
@@ -289,32 +307,35 @@ export async function getClient(
|
|||||||
// Build fingerprint data if available
|
// Build fingerprint data if available
|
||||||
const fingerprintData = client.currentFingerprint
|
const fingerprintData = client.currentFingerprint
|
||||||
? {
|
? {
|
||||||
username: client.currentFingerprint.username || null,
|
username: client.currentFingerprint.username || null,
|
||||||
hostname: client.currentFingerprint.hostname || null,
|
hostname: client.currentFingerprint.hostname || null,
|
||||||
platform: client.currentFingerprint.platform || null,
|
platform: client.currentFingerprint.platform || null,
|
||||||
osVersion: client.currentFingerprint.osVersion || null,
|
osVersion: client.currentFingerprint.osVersion || null,
|
||||||
kernelVersion:
|
kernelVersion:
|
||||||
client.currentFingerprint.kernelVersion || null,
|
client.currentFingerprint.kernelVersion || null,
|
||||||
arch: client.currentFingerprint.arch || null,
|
arch: client.currentFingerprint.arch || null,
|
||||||
deviceModel: client.currentFingerprint.deviceModel || null,
|
deviceModel: client.currentFingerprint.deviceModel || null,
|
||||||
serialNumber: client.currentFingerprint.serialNumber || null,
|
serialNumber: client.currentFingerprint.serialNumber || null,
|
||||||
firstSeen: client.currentFingerprint.firstSeen || null,
|
firstSeen: client.currentFingerprint.firstSeen || null,
|
||||||
lastSeen: client.currentFingerprint.lastSeen || null
|
lastSeen: client.currentFingerprint.lastSeen || null
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Build posture data if available (platform-specific)
|
// Build posture data if available (platform-specific)
|
||||||
// Only return posture data if org is licensed/subscribed
|
// Licensed: real values; not licensed: same keys but values set to "-"
|
||||||
let postureData: PostureData | null = null;
|
const rawPosture = getPlatformPostureData(
|
||||||
const isOrgLicensed = await isLicensedOrSubscribed(
|
client.currentFingerprint?.platform || null,
|
||||||
client.clients.orgId
|
client.currentFingerprint
|
||||||
);
|
);
|
||||||
if (isOrgLicensed) {
|
const isOrgLicensed = await isLicensedOrSubscribed(
|
||||||
postureData = getPlatformPostureData(
|
client.clients.orgId,
|
||||||
client.currentFingerprint?.platform || null,
|
tierMatrix.devicePosture
|
||||||
client.currentFingerprint
|
);
|
||||||
);
|
const postureData: PostureData | null = rawPosture
|
||||||
}
|
? isOrgLicensed
|
||||||
|
? rawPosture
|
||||||
|
: maskPostureDataWithPlaceholder(rawPosture)
|
||||||
|
: null;
|
||||||
|
|
||||||
const data: GetClientResponse = {
|
const data: GetClientResponse = {
|
||||||
...client.clients,
|
...client.clients,
|
||||||
@@ -322,6 +343,9 @@ export async function getClient(
|
|||||||
olmId: client.olms ? client.olms.olmId : null,
|
olmId: client.olms ? client.olms.olmId : null,
|
||||||
agent: client.olms?.agent || null,
|
agent: client.olms?.agent || null,
|
||||||
olmVersion: client.olms?.version || null,
|
olmVersion: client.olms?.version || null,
|
||||||
|
userEmail: client.user?.email ?? null,
|
||||||
|
userName: client.user?.name ?? null,
|
||||||
|
userUsername: client.user?.username ?? null,
|
||||||
fingerprint: fingerprintData,
|
fingerprint: fingerprintData,
|
||||||
posture: postureData
|
posture: postureData
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -320,7 +320,10 @@ export async function listClients(
|
|||||||
// Merge clients with their site associations and replace name with device name
|
// Merge clients with their site associations and replace name with device name
|
||||||
const clientsWithSites = clientsList.map((client) => {
|
const clientsWithSites = clientsList.map((client) => {
|
||||||
const model = client.deviceModel || null;
|
const model = client.deviceModel || null;
|
||||||
const newName = getUserDeviceName(model, client.name);
|
let newName = client.name;
|
||||||
|
if (filter === "user") {
|
||||||
|
newName = getUserDeviceName(model, client.name);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...client,
|
...client,
|
||||||
name: newName,
|
name: newName,
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export async function createOrgDomain(
|
|||||||
}
|
}
|
||||||
const rejectDomains = await usageService.checkLimitSet(
|
const rejectDomains = await usageService.checkLimitSet(
|
||||||
orgId,
|
orgId,
|
||||||
false,
|
|
||||||
FeatureId.DOMAINS,
|
FeatureId.DOMAINS,
|
||||||
{
|
{
|
||||||
...usage,
|
...usage,
|
||||||
@@ -354,7 +354,7 @@ export async function createOrgDomain(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (numOrgDomains) {
|
if (numOrgDomains) {
|
||||||
await usageService.updateDaily(
|
await usageService.updateCount(
|
||||||
orgId,
|
orgId,
|
||||||
FeatureId.DOMAINS,
|
FeatureId.DOMAINS,
|
||||||
numOrgDomains.length
|
numOrgDomains.length
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export async function deleteAccountDomain(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (numOrgDomains) {
|
if (numOrgDomains) {
|
||||||
await usageService.updateDaily(
|
await usageService.updateCount(
|
||||||
orgId,
|
orgId,
|
||||||
FeatureId.DOMAINS,
|
FeatureId.DOMAINS,
|
||||||
numOrgDomains.length
|
numOrgDomains.length
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ import {
|
|||||||
verifyUserHasAction,
|
verifyUserHasAction,
|
||||||
verifyUserIsOrgOwner,
|
verifyUserIsOrgOwner,
|
||||||
verifySiteResourceAccess,
|
verifySiteResourceAccess,
|
||||||
verifyOlmAccess
|
verifyOlmAccess,
|
||||||
|
verifyLimits
|
||||||
} from "@server/middlewares";
|
} from "@server/middlewares";
|
||||||
import { ActionsEnum } from "@server/auth/actions";
|
import { ActionsEnum } from "@server/auth/actions";
|
||||||
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
|
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
|
||||||
@@ -79,6 +80,7 @@ authenticated.get(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/org/:orgId",
|
"/org/:orgId",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.updateOrg),
|
verifyUserHasAction(ActionsEnum.updateOrg),
|
||||||
logActionAudit(ActionsEnum.updateOrg),
|
logActionAudit(ActionsEnum.updateOrg),
|
||||||
org.updateOrg
|
org.updateOrg
|
||||||
@@ -161,6 +163,7 @@ authenticated.get(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/client",
|
"/org/:orgId/client",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.createClient),
|
verifyUserHasAction(ActionsEnum.createClient),
|
||||||
logActionAudit(ActionsEnum.createClient),
|
logActionAudit(ActionsEnum.createClient),
|
||||||
client.createClient
|
client.createClient
|
||||||
@@ -178,6 +181,7 @@ authenticated.delete(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/client/:clientId/archive",
|
"/client/:clientId/archive",
|
||||||
verifyClientAccess,
|
verifyClientAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.archiveClient),
|
verifyUserHasAction(ActionsEnum.archiveClient),
|
||||||
logActionAudit(ActionsEnum.archiveClient),
|
logActionAudit(ActionsEnum.archiveClient),
|
||||||
client.archiveClient
|
client.archiveClient
|
||||||
@@ -186,6 +190,7 @@ authenticated.post(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/client/:clientId/unarchive",
|
"/client/:clientId/unarchive",
|
||||||
verifyClientAccess,
|
verifyClientAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.unarchiveClient),
|
verifyUserHasAction(ActionsEnum.unarchiveClient),
|
||||||
logActionAudit(ActionsEnum.unarchiveClient),
|
logActionAudit(ActionsEnum.unarchiveClient),
|
||||||
client.unarchiveClient
|
client.unarchiveClient
|
||||||
@@ -194,6 +199,7 @@ authenticated.post(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/client/:clientId/block",
|
"/client/:clientId/block",
|
||||||
verifyClientAccess,
|
verifyClientAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.blockClient),
|
verifyUserHasAction(ActionsEnum.blockClient),
|
||||||
logActionAudit(ActionsEnum.blockClient),
|
logActionAudit(ActionsEnum.blockClient),
|
||||||
client.blockClient
|
client.blockClient
|
||||||
@@ -202,6 +208,7 @@ authenticated.post(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/client/:clientId/unblock",
|
"/client/:clientId/unblock",
|
||||||
verifyClientAccess,
|
verifyClientAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.unblockClient),
|
verifyUserHasAction(ActionsEnum.unblockClient),
|
||||||
logActionAudit(ActionsEnum.unblockClient),
|
logActionAudit(ActionsEnum.unblockClient),
|
||||||
client.unblockClient
|
client.unblockClient
|
||||||
@@ -210,6 +217,7 @@ authenticated.post(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/client/:clientId",
|
"/client/:clientId",
|
||||||
verifyClientAccess, // this will check if the user has access to the client
|
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
|
verifyUserHasAction(ActionsEnum.updateClient), // this will check if the user has permission to update the client
|
||||||
logActionAudit(ActionsEnum.updateClient),
|
logActionAudit(ActionsEnum.updateClient),
|
||||||
client.updateClient
|
client.updateClient
|
||||||
@@ -224,6 +232,7 @@ authenticated.post(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/site/:siteId",
|
"/site/:siteId",
|
||||||
verifySiteAccess,
|
verifySiteAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.updateSite),
|
verifyUserHasAction(ActionsEnum.updateSite),
|
||||||
logActionAudit(ActionsEnum.updateSite),
|
logActionAudit(ActionsEnum.updateSite),
|
||||||
site.updateSite
|
site.updateSite
|
||||||
@@ -273,6 +282,7 @@ authenticated.get(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/site-resource",
|
"/org/:orgId/site-resource",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.createSiteResource),
|
verifyUserHasAction(ActionsEnum.createSiteResource),
|
||||||
logActionAudit(ActionsEnum.createSiteResource),
|
logActionAudit(ActionsEnum.createSiteResource),
|
||||||
siteResource.createSiteResource
|
siteResource.createSiteResource
|
||||||
@@ -303,6 +313,7 @@ authenticated.get(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/site-resource/:siteResourceId",
|
"/site-resource/:siteResourceId",
|
||||||
verifySiteResourceAccess,
|
verifySiteResourceAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.updateSiteResource),
|
verifyUserHasAction(ActionsEnum.updateSiteResource),
|
||||||
logActionAudit(ActionsEnum.updateSiteResource),
|
logActionAudit(ActionsEnum.updateSiteResource),
|
||||||
siteResource.updateSiteResource
|
siteResource.updateSiteResource
|
||||||
@@ -341,6 +352,7 @@ authenticated.post(
|
|||||||
"/site-resource/:siteResourceId/roles",
|
"/site-resource/:siteResourceId/roles",
|
||||||
verifySiteResourceAccess,
|
verifySiteResourceAccess,
|
||||||
verifyRoleAccess,
|
verifyRoleAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.setResourceRoles),
|
verifyUserHasAction(ActionsEnum.setResourceRoles),
|
||||||
logActionAudit(ActionsEnum.setResourceRoles),
|
logActionAudit(ActionsEnum.setResourceRoles),
|
||||||
siteResource.setSiteResourceRoles
|
siteResource.setSiteResourceRoles
|
||||||
@@ -350,6 +362,7 @@ authenticated.post(
|
|||||||
"/site-resource/:siteResourceId/users",
|
"/site-resource/:siteResourceId/users",
|
||||||
verifySiteResourceAccess,
|
verifySiteResourceAccess,
|
||||||
verifySetResourceUsers,
|
verifySetResourceUsers,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.setResourceUsers),
|
verifyUserHasAction(ActionsEnum.setResourceUsers),
|
||||||
logActionAudit(ActionsEnum.setResourceUsers),
|
logActionAudit(ActionsEnum.setResourceUsers),
|
||||||
siteResource.setSiteResourceUsers
|
siteResource.setSiteResourceUsers
|
||||||
@@ -359,6 +372,7 @@ authenticated.post(
|
|||||||
"/site-resource/:siteResourceId/clients",
|
"/site-resource/:siteResourceId/clients",
|
||||||
verifySiteResourceAccess,
|
verifySiteResourceAccess,
|
||||||
verifySetResourceClients,
|
verifySetResourceClients,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.setResourceUsers),
|
verifyUserHasAction(ActionsEnum.setResourceUsers),
|
||||||
logActionAudit(ActionsEnum.setResourceUsers),
|
logActionAudit(ActionsEnum.setResourceUsers),
|
||||||
siteResource.setSiteResourceClients
|
siteResource.setSiteResourceClients
|
||||||
@@ -368,6 +382,7 @@ authenticated.post(
|
|||||||
"/site-resource/:siteResourceId/clients/add",
|
"/site-resource/:siteResourceId/clients/add",
|
||||||
verifySiteResourceAccess,
|
verifySiteResourceAccess,
|
||||||
verifySetResourceClients,
|
verifySetResourceClients,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.setResourceUsers),
|
verifyUserHasAction(ActionsEnum.setResourceUsers),
|
||||||
logActionAudit(ActionsEnum.setResourceUsers),
|
logActionAudit(ActionsEnum.setResourceUsers),
|
||||||
siteResource.addClientToSiteResource
|
siteResource.addClientToSiteResource
|
||||||
@@ -377,6 +392,7 @@ authenticated.post(
|
|||||||
"/site-resource/:siteResourceId/clients/remove",
|
"/site-resource/:siteResourceId/clients/remove",
|
||||||
verifySiteResourceAccess,
|
verifySiteResourceAccess,
|
||||||
verifySetResourceClients,
|
verifySetResourceClients,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.setResourceUsers),
|
verifyUserHasAction(ActionsEnum.setResourceUsers),
|
||||||
logActionAudit(ActionsEnum.setResourceUsers),
|
logActionAudit(ActionsEnum.setResourceUsers),
|
||||||
siteResource.removeClientFromSiteResource
|
siteResource.removeClientFromSiteResource
|
||||||
@@ -385,6 +401,7 @@ authenticated.post(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/resource",
|
"/org/:orgId/resource",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.createResource),
|
verifyUserHasAction(ActionsEnum.createResource),
|
||||||
logActionAudit(ActionsEnum.createResource),
|
logActionAudit(ActionsEnum.createResource),
|
||||||
resource.createResource
|
resource.createResource
|
||||||
@@ -499,6 +516,7 @@ authenticated.get(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/resource/:resourceId",
|
"/resource/:resourceId",
|
||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.updateResource),
|
verifyUserHasAction(ActionsEnum.updateResource),
|
||||||
logActionAudit(ActionsEnum.updateResource),
|
logActionAudit(ActionsEnum.updateResource),
|
||||||
resource.updateResource
|
resource.updateResource
|
||||||
@@ -514,6 +532,7 @@ authenticated.delete(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/resource/:resourceId/target",
|
"/resource/:resourceId/target",
|
||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.createTarget),
|
verifyUserHasAction(ActionsEnum.createTarget),
|
||||||
logActionAudit(ActionsEnum.createTarget),
|
logActionAudit(ActionsEnum.createTarget),
|
||||||
target.createTarget
|
target.createTarget
|
||||||
@@ -528,6 +547,7 @@ authenticated.get(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/resource/:resourceId/rule",
|
"/resource/:resourceId/rule",
|
||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.createResourceRule),
|
verifyUserHasAction(ActionsEnum.createResourceRule),
|
||||||
logActionAudit(ActionsEnum.createResourceRule),
|
logActionAudit(ActionsEnum.createResourceRule),
|
||||||
resource.createResourceRule
|
resource.createResourceRule
|
||||||
@@ -541,6 +561,7 @@ authenticated.get(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/resource/:resourceId/rule/:ruleId",
|
"/resource/:resourceId/rule/:ruleId",
|
||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.updateResourceRule),
|
verifyUserHasAction(ActionsEnum.updateResourceRule),
|
||||||
logActionAudit(ActionsEnum.updateResourceRule),
|
logActionAudit(ActionsEnum.updateResourceRule),
|
||||||
resource.updateResourceRule
|
resource.updateResourceRule
|
||||||
@@ -562,6 +583,7 @@ authenticated.get(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/target/:targetId",
|
"/target/:targetId",
|
||||||
verifyTargetAccess,
|
verifyTargetAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.updateTarget),
|
verifyUserHasAction(ActionsEnum.updateTarget),
|
||||||
logActionAudit(ActionsEnum.updateTarget),
|
logActionAudit(ActionsEnum.updateTarget),
|
||||||
target.updateTarget
|
target.updateTarget
|
||||||
@@ -577,6 +599,7 @@ authenticated.delete(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/role",
|
"/org/:orgId/role",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.createRole),
|
verifyUserHasAction(ActionsEnum.createRole),
|
||||||
logActionAudit(ActionsEnum.createRole),
|
logActionAudit(ActionsEnum.createRole),
|
||||||
role.createRole
|
role.createRole
|
||||||
@@ -591,6 +614,7 @@ authenticated.get(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/role/:roleId",
|
"/role/:roleId",
|
||||||
verifyRoleAccess,
|
verifyRoleAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.updateRole),
|
verifyUserHasAction(ActionsEnum.updateRole),
|
||||||
logActionAudit(ActionsEnum.updateRole),
|
logActionAudit(ActionsEnum.updateRole),
|
||||||
role.updateRole
|
role.updateRole
|
||||||
@@ -619,6 +643,7 @@ authenticated.post(
|
|||||||
"/role/:roleId/add/:userId",
|
"/role/:roleId/add/:userId",
|
||||||
verifyRoleAccess,
|
verifyRoleAccess,
|
||||||
verifyUserAccess,
|
verifyUserAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.addUserRole),
|
verifyUserHasAction(ActionsEnum.addUserRole),
|
||||||
logActionAudit(ActionsEnum.addUserRole),
|
logActionAudit(ActionsEnum.addUserRole),
|
||||||
user.addUserRole
|
user.addUserRole
|
||||||
@@ -628,6 +653,7 @@ authenticated.post(
|
|||||||
"/resource/:resourceId/roles",
|
"/resource/:resourceId/roles",
|
||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
verifyRoleAccess,
|
verifyRoleAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.setResourceRoles),
|
verifyUserHasAction(ActionsEnum.setResourceRoles),
|
||||||
logActionAudit(ActionsEnum.setResourceRoles),
|
logActionAudit(ActionsEnum.setResourceRoles),
|
||||||
resource.setResourceRoles
|
resource.setResourceRoles
|
||||||
@@ -637,6 +663,7 @@ authenticated.post(
|
|||||||
"/resource/:resourceId/users",
|
"/resource/:resourceId/users",
|
||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
verifySetResourceUsers,
|
verifySetResourceUsers,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.setResourceUsers),
|
verifyUserHasAction(ActionsEnum.setResourceUsers),
|
||||||
logActionAudit(ActionsEnum.setResourceUsers),
|
logActionAudit(ActionsEnum.setResourceUsers),
|
||||||
resource.setResourceUsers
|
resource.setResourceUsers
|
||||||
@@ -645,6 +672,7 @@ authenticated.post(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
`/resource/:resourceId/password`,
|
`/resource/:resourceId/password`,
|
||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.setResourcePassword),
|
verifyUserHasAction(ActionsEnum.setResourcePassword),
|
||||||
logActionAudit(ActionsEnum.setResourcePassword),
|
logActionAudit(ActionsEnum.setResourcePassword),
|
||||||
resource.setResourcePassword
|
resource.setResourcePassword
|
||||||
@@ -653,6 +681,7 @@ authenticated.post(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
`/resource/:resourceId/pincode`,
|
`/resource/:resourceId/pincode`,
|
||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.setResourcePincode),
|
verifyUserHasAction(ActionsEnum.setResourcePincode),
|
||||||
logActionAudit(ActionsEnum.setResourcePincode),
|
logActionAudit(ActionsEnum.setResourcePincode),
|
||||||
resource.setResourcePincode
|
resource.setResourcePincode
|
||||||
@@ -661,6 +690,7 @@ authenticated.post(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
`/resource/:resourceId/header-auth`,
|
`/resource/:resourceId/header-auth`,
|
||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.setResourceHeaderAuth),
|
verifyUserHasAction(ActionsEnum.setResourceHeaderAuth),
|
||||||
logActionAudit(ActionsEnum.setResourceHeaderAuth),
|
logActionAudit(ActionsEnum.setResourceHeaderAuth),
|
||||||
resource.setResourceHeaderAuth
|
resource.setResourceHeaderAuth
|
||||||
@@ -669,6 +699,7 @@ authenticated.post(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
`/resource/:resourceId/whitelist`,
|
`/resource/:resourceId/whitelist`,
|
||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.setResourceWhitelist),
|
verifyUserHasAction(ActionsEnum.setResourceWhitelist),
|
||||||
logActionAudit(ActionsEnum.setResourceWhitelist),
|
logActionAudit(ActionsEnum.setResourceWhitelist),
|
||||||
resource.setResourceWhitelist
|
resource.setResourceWhitelist
|
||||||
@@ -684,6 +715,7 @@ authenticated.get(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
`/resource/:resourceId/access-token`,
|
`/resource/:resourceId/access-token`,
|
||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.generateAccessToken),
|
verifyUserHasAction(ActionsEnum.generateAccessToken),
|
||||||
logActionAudit(ActionsEnum.generateAccessToken),
|
logActionAudit(ActionsEnum.generateAccessToken),
|
||||||
accessToken.generateAccessToken
|
accessToken.generateAccessToken
|
||||||
@@ -774,6 +806,7 @@ authenticated.delete(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/user",
|
"/org/:orgId/user",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.createOrgUser),
|
verifyUserHasAction(ActionsEnum.createOrgUser),
|
||||||
logActionAudit(ActionsEnum.createOrgUser),
|
logActionAudit(ActionsEnum.createOrgUser),
|
||||||
user.createOrgUser
|
user.createOrgUser
|
||||||
@@ -783,6 +816,7 @@ authenticated.post(
|
|||||||
"/org/:orgId/user/:userId",
|
"/org/:orgId/user/:userId",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserAccess,
|
verifyUserAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.updateOrgUser),
|
verifyUserHasAction(ActionsEnum.updateOrgUser),
|
||||||
logActionAudit(ActionsEnum.updateOrgUser),
|
logActionAudit(ActionsEnum.updateOrgUser),
|
||||||
user.updateOrgUser
|
user.updateOrgUser
|
||||||
@@ -855,6 +889,7 @@ authenticated.post(
|
|||||||
"/user/:userId/olm/:olmId/archive",
|
"/user/:userId/olm/:olmId/archive",
|
||||||
verifyIsLoggedInUser,
|
verifyIsLoggedInUser,
|
||||||
verifyOlmAccess,
|
verifyOlmAccess,
|
||||||
|
verifyLimits,
|
||||||
olm.archiveUserOlm
|
olm.archiveUserOlm
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -969,6 +1004,7 @@ authenticated.post(
|
|||||||
`/org/:orgId/api-key/:apiKeyId/actions`,
|
`/org/:orgId/api-key/:apiKeyId/actions`,
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyApiKeyAccess,
|
verifyApiKeyAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.setApiKeyActions),
|
verifyUserHasAction(ActionsEnum.setApiKeyActions),
|
||||||
logActionAudit(ActionsEnum.setApiKeyActions),
|
logActionAudit(ActionsEnum.setApiKeyActions),
|
||||||
apiKeys.setApiKeyActions
|
apiKeys.setApiKeyActions
|
||||||
@@ -985,6 +1021,7 @@ authenticated.get(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
`/org/:orgId/api-key`,
|
`/org/:orgId/api-key`,
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.createApiKey),
|
verifyUserHasAction(ActionsEnum.createApiKey),
|
||||||
logActionAudit(ActionsEnum.createApiKey),
|
logActionAudit(ActionsEnum.createApiKey),
|
||||||
apiKeys.createOrgApiKey
|
apiKeys.createOrgApiKey
|
||||||
@@ -1010,6 +1047,7 @@ authenticated.get(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
`/org/:orgId/domain`,
|
`/org/:orgId/domain`,
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.createOrgDomain),
|
verifyUserHasAction(ActionsEnum.createOrgDomain),
|
||||||
logActionAudit(ActionsEnum.createOrgDomain),
|
logActionAudit(ActionsEnum.createOrgDomain),
|
||||||
domain.createOrgDomain
|
domain.createOrgDomain
|
||||||
@@ -1019,6 +1057,7 @@ authenticated.post(
|
|||||||
`/org/:orgId/domain/:domainId/restart`,
|
`/org/:orgId/domain/:domainId/restart`,
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyDomainAccess,
|
verifyDomainAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.restartOrgDomain),
|
verifyUserHasAction(ActionsEnum.restartOrgDomain),
|
||||||
logActionAudit(ActionsEnum.restartOrgDomain),
|
logActionAudit(ActionsEnum.restartOrgDomain),
|
||||||
domain.restartOrgDomain
|
domain.restartOrgDomain
|
||||||
@@ -1065,6 +1104,7 @@ authenticated.get(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/blueprint",
|
"/org/:orgId/blueprint",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.applyBlueprint),
|
verifyUserHasAction(ActionsEnum.applyBlueprint),
|
||||||
blueprints.applyYAMLBlueprint
|
blueprints.applyYAMLBlueprint
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export type GeneratedLicenseKey = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
tier: string;
|
tier: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
users: number;
|
||||||
|
sites: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ListGeneratedLicenseKeysResponse = GeneratedLicenseKey[];
|
export type ListGeneratedLicenseKeysResponse = GeneratedLicenseKey[];
|
||||||
@@ -19,6 +21,7 @@ export type NewLicenseKey = {
|
|||||||
tier: string;
|
tier: string;
|
||||||
type: string;
|
type: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
|
quantity_2: number;
|
||||||
isValid: boolean;
|
isValid: boolean;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
@@ -114,7 +114,6 @@ export async function updateSiteBandwidth(
|
|||||||
|
|
||||||
// Aggregate usage data by organization (collected outside transaction)
|
// Aggregate usage data by organization (collected outside transaction)
|
||||||
const orgUsageMap = new Map<string, number>();
|
const orgUsageMap = new Map<string, number>();
|
||||||
const orgUptimeMap = new Map<string, number>();
|
|
||||||
|
|
||||||
if (activePeers.length > 0) {
|
if (activePeers.length > 0) {
|
||||||
// Remove any active peers from offline tracking since they're sending data
|
// Remove any active peers from offline tracking since they're sending data
|
||||||
@@ -166,14 +165,6 @@ export async function updateSiteBandwidth(
|
|||||||
updatedSite.orgId,
|
updatedSite.orgId,
|
||||||
currentOrgUsage + totalBandwidth
|
currentOrgUsage + totalBandwidth
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add 10 seconds of uptime for each active site
|
|
||||||
const currentOrgUptime =
|
|
||||||
orgUptimeMap.get(updatedSite.orgId) || 0;
|
|
||||||
orgUptimeMap.set(
|
|
||||||
updatedSite.orgId,
|
|
||||||
currentOrgUptime + 10 / 60
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -187,11 +178,9 @@ export async function updateSiteBandwidth(
|
|||||||
|
|
||||||
// Process usage updates outside of site update transactions
|
// Process usage updates outside of site update transactions
|
||||||
// This separates the concerns and reduces lock contention
|
// This separates the concerns and reduces lock contention
|
||||||
if (calcUsageAndLimits && (orgUsageMap.size > 0 || orgUptimeMap.size > 0)) {
|
if (calcUsageAndLimits && orgUsageMap.size > 0) {
|
||||||
// Sort org IDs to ensure consistent lock ordering
|
// Sort org IDs to ensure consistent lock ordering
|
||||||
const allOrgIds = [
|
const allOrgIds = [...new Set([...orgUsageMap.keys()])].sort();
|
||||||
...new Set([...orgUsageMap.keys(), ...orgUptimeMap.keys()])
|
|
||||||
].sort();
|
|
||||||
|
|
||||||
for (const orgId of allOrgIds) {
|
for (const orgId of allOrgIds) {
|
||||||
try {
|
try {
|
||||||
@@ -208,7 +197,7 @@ export async function updateSiteBandwidth(
|
|||||||
usageService
|
usageService
|
||||||
.checkLimitSet(
|
.checkLimitSet(
|
||||||
orgId,
|
orgId,
|
||||||
true,
|
|
||||||
FeatureId.EGRESS_DATA_MB,
|
FeatureId.EGRESS_DATA_MB,
|
||||||
bandwidthUsage
|
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) {
|
} catch (error) {
|
||||||
logger.error(`Error processing usage for org ${orgId}:`, error);
|
logger.error(`Error processing usage for org ${orgId}:`, error);
|
||||||
// Continue with other orgs
|
// Continue with other orgs
|
||||||
|
|||||||
@@ -93,7 +93,9 @@ export async function createOidcIdp(
|
|||||||
name,
|
name,
|
||||||
autoProvision,
|
autoProvision,
|
||||||
type: "oidc",
|
type: "oidc",
|
||||||
tags
|
tags,
|
||||||
|
defaultOrgMapping: `'{{orgId}}'`,
|
||||||
|
defaultRoleMapping: `'Member'`
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import jsonwebtoken from "jsonwebtoken";
|
|||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { decrypt } from "@server/lib/crypto";
|
import { decrypt } from "@server/lib/crypto";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { getOrgTierData } from "#dynamic/lib/billing";
|
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
|
||||||
const paramsSchema = z
|
const paramsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -113,8 +113,10 @@ export async function generateOidcUrl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (build === "saas") {
|
if (build === "saas") {
|
||||||
const { tier } = await getOrgTierData(orgId);
|
const subscribed = await isSubscribed(
|
||||||
const subscribed = tier === TierId.STANDARD;
|
orgId,
|
||||||
|
tierMatrix.orgOidc
|
||||||
|
);
|
||||||
if (!subscribed) {
|
if (!subscribed) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ import { FeatureId } from "@server/lib/billing";
|
|||||||
import { usageService } from "@server/lib/billing/usageService";
|
import { usageService } from "@server/lib/billing/usageService";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
|
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
|
||||||
|
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||||
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
|
||||||
const ensureTrailingSlash = (url: string): string => {
|
const ensureTrailingSlash = (url: string): string => {
|
||||||
return url;
|
return url;
|
||||||
@@ -326,6 +328,33 @@ export async function validateOidcCallback(
|
|||||||
.where(eq(idpOrg.idpId, existingIdp.idp.idpId))
|
.where(eq(idpOrg.idpId, existingIdp.idp.idpId))
|
||||||
.innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId));
|
.innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId));
|
||||||
allOrgs = idpOrgs.map((o) => o.orgs);
|
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 {
|
} else {
|
||||||
allOrgs = await db.select().from(orgs);
|
allOrgs = await db.select().from(orgs);
|
||||||
}
|
}
|
||||||
@@ -587,7 +616,7 @@ export async function validateOidcCallback(
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const orgCount of orgUserCounts) {
|
for (const orgCount of orgUserCounts) {
|
||||||
await usageService.updateDaily(
|
await usageService.updateCount(
|
||||||
orgCount.orgId,
|
orgCount.orgId,
|
||||||
FeatureId.USERS,
|
FeatureId.USERS,
|
||||||
orgCount.userCount
|
orgCount.userCount
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ import {
|
|||||||
verifyApiKeyIsRoot,
|
verifyApiKeyIsRoot,
|
||||||
verifyApiKeyClientAccess,
|
verifyApiKeyClientAccess,
|
||||||
verifyApiKeySiteResourceAccess,
|
verifyApiKeySiteResourceAccess,
|
||||||
verifyApiKeySetResourceClients
|
verifyApiKeySetResourceClients,
|
||||||
|
verifyLimits
|
||||||
} from "@server/middlewares";
|
} from "@server/middlewares";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
@@ -74,6 +75,7 @@ authenticated.get(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/org/:orgId",
|
"/org/:orgId",
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.updateOrg),
|
verifyApiKeyHasAction(ActionsEnum.updateOrg),
|
||||||
logActionAudit(ActionsEnum.updateOrg),
|
logActionAudit(ActionsEnum.updateOrg),
|
||||||
org.updateOrg
|
org.updateOrg
|
||||||
@@ -90,6 +92,7 @@ authenticated.delete(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/site",
|
"/org/:orgId/site",
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.createSite),
|
verifyApiKeyHasAction(ActionsEnum.createSite),
|
||||||
logActionAudit(ActionsEnum.createSite),
|
logActionAudit(ActionsEnum.createSite),
|
||||||
site.createSite
|
site.createSite
|
||||||
@@ -126,6 +129,7 @@ authenticated.get(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/site/:siteId",
|
"/site/:siteId",
|
||||||
verifyApiKeySiteAccess,
|
verifyApiKeySiteAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.updateSite),
|
verifyApiKeyHasAction(ActionsEnum.updateSite),
|
||||||
logActionAudit(ActionsEnum.updateSite),
|
logActionAudit(ActionsEnum.updateSite),
|
||||||
site.updateSite
|
site.updateSite
|
||||||
@@ -146,8 +150,9 @@ authenticated.get(
|
|||||||
);
|
);
|
||||||
// Site Resource endpoints
|
// Site Resource endpoints
|
||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/private-resource",
|
"/org/:orgId/site-resource",
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.createSiteResource),
|
verifyApiKeyHasAction(ActionsEnum.createSiteResource),
|
||||||
logActionAudit(ActionsEnum.createSiteResource),
|
logActionAudit(ActionsEnum.createSiteResource),
|
||||||
siteResource.createSiteResource
|
siteResource.createSiteResource
|
||||||
@@ -178,6 +183,7 @@ authenticated.get(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/site-resource/:siteResourceId",
|
"/site-resource/:siteResourceId",
|
||||||
verifyApiKeySiteResourceAccess,
|
verifyApiKeySiteResourceAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.updateSiteResource),
|
verifyApiKeyHasAction(ActionsEnum.updateSiteResource),
|
||||||
logActionAudit(ActionsEnum.updateSiteResource),
|
logActionAudit(ActionsEnum.updateSiteResource),
|
||||||
siteResource.updateSiteResource
|
siteResource.updateSiteResource
|
||||||
@@ -216,6 +222,7 @@ authenticated.post(
|
|||||||
"/site-resource/:siteResourceId/roles",
|
"/site-resource/:siteResourceId/roles",
|
||||||
verifyApiKeySiteResourceAccess,
|
verifyApiKeySiteResourceAccess,
|
||||||
verifyApiKeyRoleAccess,
|
verifyApiKeyRoleAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.setResourceRoles),
|
verifyApiKeyHasAction(ActionsEnum.setResourceRoles),
|
||||||
logActionAudit(ActionsEnum.setResourceRoles),
|
logActionAudit(ActionsEnum.setResourceRoles),
|
||||||
siteResource.setSiteResourceRoles
|
siteResource.setSiteResourceRoles
|
||||||
@@ -225,6 +232,7 @@ authenticated.post(
|
|||||||
"/site-resource/:siteResourceId/users",
|
"/site-resource/:siteResourceId/users",
|
||||||
verifyApiKeySiteResourceAccess,
|
verifyApiKeySiteResourceAccess,
|
||||||
verifyApiKeySetResourceUsers,
|
verifyApiKeySetResourceUsers,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
|
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
|
||||||
logActionAudit(ActionsEnum.setResourceUsers),
|
logActionAudit(ActionsEnum.setResourceUsers),
|
||||||
siteResource.setSiteResourceUsers
|
siteResource.setSiteResourceUsers
|
||||||
@@ -234,6 +242,7 @@ authenticated.post(
|
|||||||
"/site-resource/:siteResourceId/roles/add",
|
"/site-resource/:siteResourceId/roles/add",
|
||||||
verifyApiKeySiteResourceAccess,
|
verifyApiKeySiteResourceAccess,
|
||||||
verifyApiKeyRoleAccess,
|
verifyApiKeyRoleAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.setResourceRoles),
|
verifyApiKeyHasAction(ActionsEnum.setResourceRoles),
|
||||||
logActionAudit(ActionsEnum.setResourceRoles),
|
logActionAudit(ActionsEnum.setResourceRoles),
|
||||||
siteResource.addRoleToSiteResource
|
siteResource.addRoleToSiteResource
|
||||||
@@ -243,6 +252,7 @@ authenticated.post(
|
|||||||
"/site-resource/:siteResourceId/roles/remove",
|
"/site-resource/:siteResourceId/roles/remove",
|
||||||
verifyApiKeySiteResourceAccess,
|
verifyApiKeySiteResourceAccess,
|
||||||
verifyApiKeyRoleAccess,
|
verifyApiKeyRoleAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.setResourceRoles),
|
verifyApiKeyHasAction(ActionsEnum.setResourceRoles),
|
||||||
logActionAudit(ActionsEnum.setResourceRoles),
|
logActionAudit(ActionsEnum.setResourceRoles),
|
||||||
siteResource.removeRoleFromSiteResource
|
siteResource.removeRoleFromSiteResource
|
||||||
@@ -252,6 +262,7 @@ authenticated.post(
|
|||||||
"/site-resource/:siteResourceId/users/add",
|
"/site-resource/:siteResourceId/users/add",
|
||||||
verifyApiKeySiteResourceAccess,
|
verifyApiKeySiteResourceAccess,
|
||||||
verifyApiKeySetResourceUsers,
|
verifyApiKeySetResourceUsers,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
|
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
|
||||||
logActionAudit(ActionsEnum.setResourceUsers),
|
logActionAudit(ActionsEnum.setResourceUsers),
|
||||||
siteResource.addUserToSiteResource
|
siteResource.addUserToSiteResource
|
||||||
@@ -261,6 +272,7 @@ authenticated.post(
|
|||||||
"/site-resource/:siteResourceId/users/remove",
|
"/site-resource/:siteResourceId/users/remove",
|
||||||
verifyApiKeySiteResourceAccess,
|
verifyApiKeySiteResourceAccess,
|
||||||
verifyApiKeySetResourceUsers,
|
verifyApiKeySetResourceUsers,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
|
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
|
||||||
logActionAudit(ActionsEnum.setResourceUsers),
|
logActionAudit(ActionsEnum.setResourceUsers),
|
||||||
siteResource.removeUserFromSiteResource
|
siteResource.removeUserFromSiteResource
|
||||||
@@ -270,6 +282,7 @@ authenticated.post(
|
|||||||
"/site-resource/:siteResourceId/clients",
|
"/site-resource/:siteResourceId/clients",
|
||||||
verifyApiKeySiteResourceAccess,
|
verifyApiKeySiteResourceAccess,
|
||||||
verifyApiKeySetResourceClients,
|
verifyApiKeySetResourceClients,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
|
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
|
||||||
logActionAudit(ActionsEnum.setResourceUsers),
|
logActionAudit(ActionsEnum.setResourceUsers),
|
||||||
siteResource.setSiteResourceClients
|
siteResource.setSiteResourceClients
|
||||||
@@ -279,6 +292,7 @@ authenticated.post(
|
|||||||
"/site-resource/:siteResourceId/clients/add",
|
"/site-resource/:siteResourceId/clients/add",
|
||||||
verifyApiKeySiteResourceAccess,
|
verifyApiKeySiteResourceAccess,
|
||||||
verifyApiKeySetResourceClients,
|
verifyApiKeySetResourceClients,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
|
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
|
||||||
logActionAudit(ActionsEnum.setResourceUsers),
|
logActionAudit(ActionsEnum.setResourceUsers),
|
||||||
siteResource.addClientToSiteResource
|
siteResource.addClientToSiteResource
|
||||||
@@ -288,6 +302,7 @@ authenticated.post(
|
|||||||
"/site-resource/:siteResourceId/clients/remove",
|
"/site-resource/:siteResourceId/clients/remove",
|
||||||
verifyApiKeySiteResourceAccess,
|
verifyApiKeySiteResourceAccess,
|
||||||
verifyApiKeySetResourceClients,
|
verifyApiKeySetResourceClients,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
|
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
|
||||||
logActionAudit(ActionsEnum.setResourceUsers),
|
logActionAudit(ActionsEnum.setResourceUsers),
|
||||||
siteResource.removeClientFromSiteResource
|
siteResource.removeClientFromSiteResource
|
||||||
@@ -296,6 +311,7 @@ authenticated.post(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/resource",
|
"/org/:orgId/resource",
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.createResource),
|
verifyApiKeyHasAction(ActionsEnum.createResource),
|
||||||
logActionAudit(ActionsEnum.createResource),
|
logActionAudit(ActionsEnum.createResource),
|
||||||
resource.createResource
|
resource.createResource
|
||||||
@@ -304,6 +320,7 @@ authenticated.put(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/site/:siteId/resource",
|
"/org/:orgId/site/:siteId/resource",
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.createResource),
|
verifyApiKeyHasAction(ActionsEnum.createResource),
|
||||||
logActionAudit(ActionsEnum.createResource),
|
logActionAudit(ActionsEnum.createResource),
|
||||||
resource.createResource
|
resource.createResource
|
||||||
@@ -340,6 +357,7 @@ authenticated.get(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/org/:orgId/create-invite",
|
"/org/:orgId/create-invite",
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.inviteUser),
|
verifyApiKeyHasAction(ActionsEnum.inviteUser),
|
||||||
logActionAudit(ActionsEnum.inviteUser),
|
logActionAudit(ActionsEnum.inviteUser),
|
||||||
user.inviteUser
|
user.inviteUser
|
||||||
@@ -377,6 +395,7 @@ authenticated.get(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/resource/:resourceId",
|
"/resource/:resourceId",
|
||||||
verifyApiKeyResourceAccess,
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.updateResource),
|
verifyApiKeyHasAction(ActionsEnum.updateResource),
|
||||||
logActionAudit(ActionsEnum.updateResource),
|
logActionAudit(ActionsEnum.updateResource),
|
||||||
resource.updateResource
|
resource.updateResource
|
||||||
@@ -393,6 +412,7 @@ authenticated.delete(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/resource/:resourceId/target",
|
"/resource/:resourceId/target",
|
||||||
verifyApiKeyResourceAccess,
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.createTarget),
|
verifyApiKeyHasAction(ActionsEnum.createTarget),
|
||||||
logActionAudit(ActionsEnum.createTarget),
|
logActionAudit(ActionsEnum.createTarget),
|
||||||
target.createTarget
|
target.createTarget
|
||||||
@@ -408,6 +428,7 @@ authenticated.get(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/resource/:resourceId/rule",
|
"/resource/:resourceId/rule",
|
||||||
verifyApiKeyResourceAccess,
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.createResourceRule),
|
verifyApiKeyHasAction(ActionsEnum.createResourceRule),
|
||||||
logActionAudit(ActionsEnum.createResourceRule),
|
logActionAudit(ActionsEnum.createResourceRule),
|
||||||
resource.createResourceRule
|
resource.createResourceRule
|
||||||
@@ -423,6 +444,7 @@ authenticated.get(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/resource/:resourceId/rule/:ruleId",
|
"/resource/:resourceId/rule/:ruleId",
|
||||||
verifyApiKeyResourceAccess,
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.updateResourceRule),
|
verifyApiKeyHasAction(ActionsEnum.updateResourceRule),
|
||||||
logActionAudit(ActionsEnum.updateResourceRule),
|
logActionAudit(ActionsEnum.updateResourceRule),
|
||||||
resource.updateResourceRule
|
resource.updateResourceRule
|
||||||
@@ -446,6 +468,7 @@ authenticated.get(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/target/:targetId",
|
"/target/:targetId",
|
||||||
verifyApiKeyTargetAccess,
|
verifyApiKeyTargetAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.updateTarget),
|
verifyApiKeyHasAction(ActionsEnum.updateTarget),
|
||||||
logActionAudit(ActionsEnum.updateTarget),
|
logActionAudit(ActionsEnum.updateTarget),
|
||||||
target.updateTarget
|
target.updateTarget
|
||||||
@@ -462,6 +485,7 @@ authenticated.delete(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/role",
|
"/org/:orgId/role",
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.createRole),
|
verifyApiKeyHasAction(ActionsEnum.createRole),
|
||||||
logActionAudit(ActionsEnum.createRole),
|
logActionAudit(ActionsEnum.createRole),
|
||||||
role.createRole
|
role.createRole
|
||||||
@@ -470,6 +494,7 @@ authenticated.put(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/role/:roleId",
|
"/role/:roleId",
|
||||||
verifyApiKeyRoleAccess,
|
verifyApiKeyRoleAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.updateRole),
|
verifyApiKeyHasAction(ActionsEnum.updateRole),
|
||||||
logActionAudit(ActionsEnum.updateRole),
|
logActionAudit(ActionsEnum.updateRole),
|
||||||
role.updateRole
|
role.updateRole
|
||||||
@@ -501,6 +526,7 @@ authenticated.post(
|
|||||||
"/role/:roleId/add/:userId",
|
"/role/:roleId/add/:userId",
|
||||||
verifyApiKeyRoleAccess,
|
verifyApiKeyRoleAccess,
|
||||||
verifyApiKeyUserAccess,
|
verifyApiKeyUserAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.addUserRole),
|
verifyApiKeyHasAction(ActionsEnum.addUserRole),
|
||||||
logActionAudit(ActionsEnum.addUserRole),
|
logActionAudit(ActionsEnum.addUserRole),
|
||||||
user.addUserRole
|
user.addUserRole
|
||||||
@@ -510,6 +536,7 @@ authenticated.post(
|
|||||||
"/resource/:resourceId/roles",
|
"/resource/:resourceId/roles",
|
||||||
verifyApiKeyResourceAccess,
|
verifyApiKeyResourceAccess,
|
||||||
verifyApiKeyRoleAccess,
|
verifyApiKeyRoleAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.setResourceRoles),
|
verifyApiKeyHasAction(ActionsEnum.setResourceRoles),
|
||||||
logActionAudit(ActionsEnum.setResourceRoles),
|
logActionAudit(ActionsEnum.setResourceRoles),
|
||||||
resource.setResourceRoles
|
resource.setResourceRoles
|
||||||
@@ -519,6 +546,7 @@ authenticated.post(
|
|||||||
"/resource/:resourceId/users",
|
"/resource/:resourceId/users",
|
||||||
verifyApiKeyResourceAccess,
|
verifyApiKeyResourceAccess,
|
||||||
verifyApiKeySetResourceUsers,
|
verifyApiKeySetResourceUsers,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
|
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
|
||||||
logActionAudit(ActionsEnum.setResourceUsers),
|
logActionAudit(ActionsEnum.setResourceUsers),
|
||||||
resource.setResourceUsers
|
resource.setResourceUsers
|
||||||
@@ -528,6 +556,7 @@ authenticated.post(
|
|||||||
"/resource/:resourceId/roles/add",
|
"/resource/:resourceId/roles/add",
|
||||||
verifyApiKeyResourceAccess,
|
verifyApiKeyResourceAccess,
|
||||||
verifyApiKeyRoleAccess,
|
verifyApiKeyRoleAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.setResourceRoles),
|
verifyApiKeyHasAction(ActionsEnum.setResourceRoles),
|
||||||
logActionAudit(ActionsEnum.setResourceRoles),
|
logActionAudit(ActionsEnum.setResourceRoles),
|
||||||
resource.addRoleToResource
|
resource.addRoleToResource
|
||||||
@@ -537,6 +566,7 @@ authenticated.post(
|
|||||||
"/resource/:resourceId/roles/remove",
|
"/resource/:resourceId/roles/remove",
|
||||||
verifyApiKeyResourceAccess,
|
verifyApiKeyResourceAccess,
|
||||||
verifyApiKeyRoleAccess,
|
verifyApiKeyRoleAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.setResourceRoles),
|
verifyApiKeyHasAction(ActionsEnum.setResourceRoles),
|
||||||
logActionAudit(ActionsEnum.setResourceRoles),
|
logActionAudit(ActionsEnum.setResourceRoles),
|
||||||
resource.removeRoleFromResource
|
resource.removeRoleFromResource
|
||||||
@@ -546,6 +576,7 @@ authenticated.post(
|
|||||||
"/resource/:resourceId/users/add",
|
"/resource/:resourceId/users/add",
|
||||||
verifyApiKeyResourceAccess,
|
verifyApiKeyResourceAccess,
|
||||||
verifyApiKeySetResourceUsers,
|
verifyApiKeySetResourceUsers,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
|
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
|
||||||
logActionAudit(ActionsEnum.setResourceUsers),
|
logActionAudit(ActionsEnum.setResourceUsers),
|
||||||
resource.addUserToResource
|
resource.addUserToResource
|
||||||
@@ -555,6 +586,7 @@ authenticated.post(
|
|||||||
"/resource/:resourceId/users/remove",
|
"/resource/:resourceId/users/remove",
|
||||||
verifyApiKeyResourceAccess,
|
verifyApiKeyResourceAccess,
|
||||||
verifyApiKeySetResourceUsers,
|
verifyApiKeySetResourceUsers,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
|
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
|
||||||
logActionAudit(ActionsEnum.setResourceUsers),
|
logActionAudit(ActionsEnum.setResourceUsers),
|
||||||
resource.removeUserFromResource
|
resource.removeUserFromResource
|
||||||
@@ -563,6 +595,7 @@ authenticated.post(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
`/resource/:resourceId/password`,
|
`/resource/:resourceId/password`,
|
||||||
verifyApiKeyResourceAccess,
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.setResourcePassword),
|
verifyApiKeyHasAction(ActionsEnum.setResourcePassword),
|
||||||
logActionAudit(ActionsEnum.setResourcePassword),
|
logActionAudit(ActionsEnum.setResourcePassword),
|
||||||
resource.setResourcePassword
|
resource.setResourcePassword
|
||||||
@@ -571,6 +604,7 @@ authenticated.post(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
`/resource/:resourceId/pincode`,
|
`/resource/:resourceId/pincode`,
|
||||||
verifyApiKeyResourceAccess,
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.setResourcePincode),
|
verifyApiKeyHasAction(ActionsEnum.setResourcePincode),
|
||||||
logActionAudit(ActionsEnum.setResourcePincode),
|
logActionAudit(ActionsEnum.setResourcePincode),
|
||||||
resource.setResourcePincode
|
resource.setResourcePincode
|
||||||
@@ -579,6 +613,7 @@ authenticated.post(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
`/resource/:resourceId/header-auth`,
|
`/resource/:resourceId/header-auth`,
|
||||||
verifyApiKeyResourceAccess,
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.setResourceHeaderAuth),
|
verifyApiKeyHasAction(ActionsEnum.setResourceHeaderAuth),
|
||||||
logActionAudit(ActionsEnum.setResourceHeaderAuth),
|
logActionAudit(ActionsEnum.setResourceHeaderAuth),
|
||||||
resource.setResourceHeaderAuth
|
resource.setResourceHeaderAuth
|
||||||
@@ -587,6 +622,7 @@ authenticated.post(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
`/resource/:resourceId/whitelist`,
|
`/resource/:resourceId/whitelist`,
|
||||||
verifyApiKeyResourceAccess,
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist),
|
verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist),
|
||||||
logActionAudit(ActionsEnum.setResourceWhitelist),
|
logActionAudit(ActionsEnum.setResourceWhitelist),
|
||||||
resource.setResourceWhitelist
|
resource.setResourceWhitelist
|
||||||
@@ -595,6 +631,7 @@ authenticated.post(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
`/resource/:resourceId/whitelist/add`,
|
`/resource/:resourceId/whitelist/add`,
|
||||||
verifyApiKeyResourceAccess,
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist),
|
verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist),
|
||||||
resource.addEmailToResourceWhitelist
|
resource.addEmailToResourceWhitelist
|
||||||
);
|
);
|
||||||
@@ -602,6 +639,7 @@ authenticated.post(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
`/resource/:resourceId/whitelist/remove`,
|
`/resource/:resourceId/whitelist/remove`,
|
||||||
verifyApiKeyResourceAccess,
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist),
|
verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist),
|
||||||
resource.removeEmailFromResourceWhitelist
|
resource.removeEmailFromResourceWhitelist
|
||||||
);
|
);
|
||||||
@@ -616,6 +654,7 @@ authenticated.get(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
`/resource/:resourceId/access-token`,
|
`/resource/:resourceId/access-token`,
|
||||||
verifyApiKeyResourceAccess,
|
verifyApiKeyResourceAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.generateAccessToken),
|
verifyApiKeyHasAction(ActionsEnum.generateAccessToken),
|
||||||
logActionAudit(ActionsEnum.generateAccessToken),
|
logActionAudit(ActionsEnum.generateAccessToken),
|
||||||
accessToken.generateAccessToken
|
accessToken.generateAccessToken
|
||||||
@@ -653,6 +692,7 @@ authenticated.get(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/user/:userId/2fa",
|
"/user/:userId/2fa",
|
||||||
verifyApiKeyIsRoot,
|
verifyApiKeyIsRoot,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.updateUser),
|
verifyApiKeyHasAction(ActionsEnum.updateUser),
|
||||||
logActionAudit(ActionsEnum.updateUser),
|
logActionAudit(ActionsEnum.updateUser),
|
||||||
user.updateUser2FA
|
user.updateUser2FA
|
||||||
@@ -675,6 +715,7 @@ authenticated.get(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/user",
|
"/org/:orgId/user",
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.createOrgUser),
|
verifyApiKeyHasAction(ActionsEnum.createOrgUser),
|
||||||
logActionAudit(ActionsEnum.createOrgUser),
|
logActionAudit(ActionsEnum.createOrgUser),
|
||||||
user.createOrgUser
|
user.createOrgUser
|
||||||
@@ -684,6 +725,7 @@ authenticated.post(
|
|||||||
"/org/:orgId/user/:userId",
|
"/org/:orgId/user/:userId",
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
verifyApiKeyUserAccess,
|
verifyApiKeyUserAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.updateOrgUser),
|
verifyApiKeyHasAction(ActionsEnum.updateOrgUser),
|
||||||
logActionAudit(ActionsEnum.updateOrgUser),
|
logActionAudit(ActionsEnum.updateOrgUser),
|
||||||
user.updateOrgUser
|
user.updateOrgUser
|
||||||
@@ -714,6 +756,7 @@ authenticated.get(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
`/org/:orgId/api-key/:apiKeyId/actions`,
|
`/org/:orgId/api-key/:apiKeyId/actions`,
|
||||||
verifyApiKeyIsRoot,
|
verifyApiKeyIsRoot,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.setApiKeyActions),
|
verifyApiKeyHasAction(ActionsEnum.setApiKeyActions),
|
||||||
logActionAudit(ActionsEnum.setApiKeyActions),
|
logActionAudit(ActionsEnum.setApiKeyActions),
|
||||||
apiKeys.setApiKeyActions
|
apiKeys.setApiKeyActions
|
||||||
@@ -729,6 +772,7 @@ authenticated.get(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
`/org/:orgId/api-key`,
|
`/org/:orgId/api-key`,
|
||||||
verifyApiKeyIsRoot,
|
verifyApiKeyIsRoot,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.createApiKey),
|
verifyApiKeyHasAction(ActionsEnum.createApiKey),
|
||||||
logActionAudit(ActionsEnum.createApiKey),
|
logActionAudit(ActionsEnum.createApiKey),
|
||||||
apiKeys.createOrgApiKey
|
apiKeys.createOrgApiKey
|
||||||
@@ -745,6 +789,7 @@ authenticated.delete(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/idp/oidc",
|
"/idp/oidc",
|
||||||
verifyApiKeyIsRoot,
|
verifyApiKeyIsRoot,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.createIdp),
|
verifyApiKeyHasAction(ActionsEnum.createIdp),
|
||||||
logActionAudit(ActionsEnum.createIdp),
|
logActionAudit(ActionsEnum.createIdp),
|
||||||
idp.createOidcIdp
|
idp.createOidcIdp
|
||||||
@@ -753,6 +798,7 @@ authenticated.put(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/idp/:idpId/oidc",
|
"/idp/:idpId/oidc",
|
||||||
verifyApiKeyIsRoot,
|
verifyApiKeyIsRoot,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.updateIdp),
|
verifyApiKeyHasAction(ActionsEnum.updateIdp),
|
||||||
logActionAudit(ActionsEnum.updateIdp),
|
logActionAudit(ActionsEnum.updateIdp),
|
||||||
idp.updateOidcIdp
|
idp.updateOidcIdp
|
||||||
@@ -776,6 +822,7 @@ authenticated.get(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/idp/:idpId/org/:orgId",
|
"/idp/:idpId/org/:orgId",
|
||||||
verifyApiKeyIsRoot,
|
verifyApiKeyIsRoot,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.createIdpOrg),
|
verifyApiKeyHasAction(ActionsEnum.createIdpOrg),
|
||||||
logActionAudit(ActionsEnum.createIdpOrg),
|
logActionAudit(ActionsEnum.createIdpOrg),
|
||||||
idp.createIdpOrgPolicy
|
idp.createIdpOrgPolicy
|
||||||
@@ -784,6 +831,7 @@ authenticated.put(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/idp/:idpId/org/:orgId",
|
"/idp/:idpId/org/:orgId",
|
||||||
verifyApiKeyIsRoot,
|
verifyApiKeyIsRoot,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.updateIdpOrg),
|
verifyApiKeyHasAction(ActionsEnum.updateIdpOrg),
|
||||||
logActionAudit(ActionsEnum.updateIdpOrg),
|
logActionAudit(ActionsEnum.updateIdpOrg),
|
||||||
idp.updateIdpOrgPolicy
|
idp.updateIdpOrgPolicy
|
||||||
@@ -828,6 +876,7 @@ authenticated.get(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/client",
|
"/org/:orgId/client",
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.createClient),
|
verifyApiKeyHasAction(ActionsEnum.createClient),
|
||||||
logActionAudit(ActionsEnum.createClient),
|
logActionAudit(ActionsEnum.createClient),
|
||||||
client.createClient
|
client.createClient
|
||||||
@@ -854,6 +903,7 @@ authenticated.delete(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/client/:clientId/archive",
|
"/client/:clientId/archive",
|
||||||
verifyApiKeyClientAccess,
|
verifyApiKeyClientAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.archiveClient),
|
verifyApiKeyHasAction(ActionsEnum.archiveClient),
|
||||||
logActionAudit(ActionsEnum.archiveClient),
|
logActionAudit(ActionsEnum.archiveClient),
|
||||||
client.archiveClient
|
client.archiveClient
|
||||||
@@ -862,6 +912,7 @@ authenticated.post(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/client/:clientId/unarchive",
|
"/client/:clientId/unarchive",
|
||||||
verifyApiKeyClientAccess,
|
verifyApiKeyClientAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.unarchiveClient),
|
verifyApiKeyHasAction(ActionsEnum.unarchiveClient),
|
||||||
logActionAudit(ActionsEnum.unarchiveClient),
|
logActionAudit(ActionsEnum.unarchiveClient),
|
||||||
client.unarchiveClient
|
client.unarchiveClient
|
||||||
@@ -870,6 +921,7 @@ authenticated.post(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/client/:clientId/block",
|
"/client/:clientId/block",
|
||||||
verifyApiKeyClientAccess,
|
verifyApiKeyClientAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.blockClient),
|
verifyApiKeyHasAction(ActionsEnum.blockClient),
|
||||||
logActionAudit(ActionsEnum.blockClient),
|
logActionAudit(ActionsEnum.blockClient),
|
||||||
client.blockClient
|
client.blockClient
|
||||||
@@ -878,6 +930,7 @@ authenticated.post(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/client/:clientId/unblock",
|
"/client/:clientId/unblock",
|
||||||
verifyApiKeyClientAccess,
|
verifyApiKeyClientAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.unblockClient),
|
verifyApiKeyHasAction(ActionsEnum.unblockClient),
|
||||||
logActionAudit(ActionsEnum.unblockClient),
|
logActionAudit(ActionsEnum.unblockClient),
|
||||||
client.unblockClient
|
client.unblockClient
|
||||||
@@ -886,6 +939,7 @@ authenticated.post(
|
|||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/client/:clientId",
|
"/client/:clientId",
|
||||||
verifyApiKeyClientAccess,
|
verifyApiKeyClientAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.updateClient),
|
verifyApiKeyHasAction(ActionsEnum.updateClient),
|
||||||
logActionAudit(ActionsEnum.updateClient),
|
logActionAudit(ActionsEnum.updateClient),
|
||||||
client.updateClient
|
client.updateClient
|
||||||
@@ -894,6 +948,7 @@ authenticated.post(
|
|||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/blueprint",
|
"/org/:orgId/blueprint",
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
verifyApiKeyHasAction(ActionsEnum.applyBlueprint),
|
verifyApiKeyHasAction(ActionsEnum.applyBlueprint),
|
||||||
logActionAudit(ActionsEnum.applyBlueprint),
|
logActionAudit(ActionsEnum.applyBlueprint),
|
||||||
blueprints.applyJSONBlueprint
|
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 { MessageHandler } from "@server/routers/ws";
|
||||||
import { exitNodes, Newt, resources, sites, Target, targets } from "@server/db";
|
import { exitNodes, Newt, sites } from "@server/db";
|
||||||
import { targetHealthCheck } from "@server/db";
|
import { eq } from "drizzle-orm";
|
||||||
import { eq, and, sql, inArray, ne } from "drizzle-orm";
|
|
||||||
import { addPeer, deletePeer } from "../gerbil/peers";
|
import { addPeer, deletePeer } from "../gerbil/peers";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import {
|
import {
|
||||||
findNextAvailableCidr,
|
findNextAvailableCidr,
|
||||||
getNextAvailableClientSubnet
|
|
||||||
} from "@server/lib/ip";
|
} from "@server/lib/ip";
|
||||||
import { usageService } from "@server/lib/billing/usageService";
|
|
||||||
import { FeatureId } from "@server/lib/billing";
|
|
||||||
import {
|
import {
|
||||||
selectBestExitNode,
|
selectBestExitNode,
|
||||||
verifyExitNodeOrgAccess
|
verifyExitNodeOrgAccess
|
||||||
@@ -30,8 +26,6 @@ export type ExitNodePingResult = {
|
|||||||
wasPreviouslyConnected: boolean;
|
wasPreviouslyConnected: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const numTimesLimitExceededForId: Record<string, number> = {};
|
|
||||||
|
|
||||||
export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||||
const { message, client, sendToClient } = context;
|
const { message, client, sendToClient } = context;
|
||||||
const newt = client as Newt;
|
const newt = client as Newt;
|
||||||
@@ -96,42 +90,6 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
fetchContainers(newt.newtId);
|
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 siteSubnet = oldSite.subnet;
|
||||||
let exitNodeIdToQuery = oldSite.exitNodeId;
|
let exitNodeIdToQuery = oldSite.exitNodeId;
|
||||||
if (exitNodeId && (oldSite.exitNodeId !== exitNodeId || !oldSite.subnet)) {
|
if (exitNodeId && (oldSite.exitNodeId !== exitNodeId || !oldSite.subnet)) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { olms, clients } from "@server/db";
|
import { olms } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
@@ -8,9 +8,6 @@ import response from "@server/lib/response";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
|
|
||||||
import { sendTerminateClient } from "../client/terminate";
|
|
||||||
import { OlmErrorCodes } from "./error";
|
|
||||||
|
|
||||||
const paramsSchema = z
|
const paramsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -37,26 +34,7 @@ export async function archiveUserOlm(
|
|||||||
|
|
||||||
const { olmId } = parsedParams.data;
|
const { olmId } = parsedParams.data;
|
||||||
|
|
||||||
// Archive the OLM and disconnect associated clients in a transaction
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
// Find all clients associated with this OLM
|
|
||||||
const associatedClients = await trx
|
|
||||||
.select()
|
|
||||||
.from(clients)
|
|
||||||
.where(eq(clients.olmId, olmId));
|
|
||||||
|
|
||||||
// Disconnect clients from the OLM (set olmId to null)
|
|
||||||
for (const client of associatedClients) {
|
|
||||||
await trx
|
|
||||||
.update(clients)
|
|
||||||
.set({ olmId: null })
|
|
||||||
.where(eq(clients.clientId, client.clientId));
|
|
||||||
|
|
||||||
await rebuildClientAssociationsFromClient(client, trx);
|
|
||||||
await sendTerminateClient(client.clientId, OlmErrorCodes.TERMINATED_ARCHIVED, olmId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Archive the OLM (set archived to true)
|
|
||||||
await trx
|
await trx
|
||||||
.update(olms)
|
.update(olms)
|
||||||
.set({ archived: true })
|
.set({ archived: true })
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ export const handleOlmPingMessage: MessageHandler = async (context) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isUserDevice = olm.userId !== null && olm.userId !== undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// get the client
|
// get the client
|
||||||
const [client] = await db
|
const [client] = await db
|
||||||
@@ -219,7 +221,9 @@ export const handleOlmPingMessage: MessageHandler = async (context) => {
|
|||||||
logger.error("Error handling ping message", { error });
|
logger.error("Error handling ping message", { error });
|
||||||
}
|
}
|
||||||
|
|
||||||
await handleFingerprintInsertion(olm, fingerprint, postures);
|
if (isUserDevice) {
|
||||||
|
await handleFingerprintInsertion(olm, fingerprint, postures);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: {
|
message: {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
|||||||
import { validateSessionToken } from "@server/auth/sessions/app";
|
import { validateSessionToken } from "@server/auth/sessions/app";
|
||||||
import { encodeHexLowerCase } from "@oslojs/encoding";
|
import { encodeHexLowerCase } from "@oslojs/encoding";
|
||||||
import { sha256 } from "@oslojs/crypto/sha2";
|
import { sha256 } from "@oslojs/crypto/sha2";
|
||||||
|
import { getUserDeviceName } from "@server/db/names";
|
||||||
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
|
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
|
||||||
import { OlmErrorCodes, sendOlmError } from "./error";
|
import { OlmErrorCodes, sendOlmError } from "./error";
|
||||||
import { handleFingerprintInsertion } from "./fingerprintingUtils";
|
import { handleFingerprintInsertion } from "./fingerprintingUtils";
|
||||||
@@ -52,7 +53,11 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
postures
|
postures
|
||||||
});
|
});
|
||||||
|
|
||||||
await handleFingerprintInsertion(olm, fingerprint, postures);
|
const isUserDevice = olm.userId !== null && olm.userId !== undefined;
|
||||||
|
|
||||||
|
if (isUserDevice) {
|
||||||
|
await handleFingerprintInsertion(olm, fingerprint, postures);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(olmVersion && olm.version !== olmVersion) ||
|
(olmVersion && olm.version !== olmVersion) ||
|
||||||
@@ -97,6 +102,21 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deviceModel = fingerprint?.deviceModel ?? null;
|
||||||
|
const computedName = getUserDeviceName(deviceModel, client.name);
|
||||||
|
if (computedName && computedName !== client.name) {
|
||||||
|
await db
|
||||||
|
.update(clients)
|
||||||
|
.set({ name: computedName })
|
||||||
|
.where(eq(clients.clientId, client.clientId));
|
||||||
|
}
|
||||||
|
if (computedName && computedName !== olm.name) {
|
||||||
|
await db
|
||||||
|
.update(olms)
|
||||||
|
.set({ name: computedName })
|
||||||
|
.where(eq(olms.olmId, olm.olmId));
|
||||||
|
}
|
||||||
|
|
||||||
const [org] = await db
|
const [org] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(orgs)
|
.from(orgs)
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ export async function createOrg(
|
|||||||
// make sure we have the stripe customer
|
// make sure we have the stripe customer
|
||||||
const customerId = await createCustomer(orgId, req.user?.email);
|
const customerId = await createCustomer(orgId, req.user?.email);
|
||||||
if (customerId) {
|
if (customerId) {
|
||||||
await usageService.updateDaily(
|
await usageService.updateCount(
|
||||||
orgId,
|
orgId,
|
||||||
FeatureId.USERS,
|
FeatureId.USERS,
|
||||||
1,
|
1,
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import logger from "@server/logger";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { getOrgTierData } from "#dynamic/lib/billing";
|
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
import { cache } from "@server/lib/cache";
|
import { cache } from "@server/lib/cache";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
|
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
import { getOrgTierData } from "#dynamic/lib/billing";
|
||||||
|
|
||||||
const updateOrgParamsSchema = z.strictObject({
|
const updateOrgParamsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
@@ -88,26 +88,83 @@ export async function updateOrg(
|
|||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
const isLicensed = await isLicensedOrSubscribed(orgId);
|
// Check 2FA enforcement feature
|
||||||
if (!isLicensed) {
|
const has2FAFeature = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix[TierFeature.TwoFactorEnforcement]
|
||||||
|
);
|
||||||
|
if (!has2FAFeature) {
|
||||||
parsedBody.data.requireTwoFactor = undefined;
|
parsedBody.data.requireTwoFactor = undefined;
|
||||||
parsedBody.data.maxSessionLengthHours = undefined;
|
|
||||||
parsedBody.data.passwordExpiryDays = undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { tier } = await getOrgTierData(orgId);
|
// Check session duration policies feature
|
||||||
if (
|
const hasSessionDurationFeature = await isLicensedOrSubscribed(
|
||||||
build == "saas" &&
|
orgId,
|
||||||
tier != TierId.STANDARD &&
|
tierMatrix[TierFeature.SessionDurationPolicies]
|
||||||
parsedBody.data.settingsLogRetentionDaysRequest &&
|
);
|
||||||
parsedBody.data.settingsLogRetentionDaysRequest > 30
|
if (!hasSessionDurationFeature) {
|
||||||
) {
|
parsedBody.data.maxSessionLengthHours = undefined;
|
||||||
return next(
|
}
|
||||||
createHttpError(
|
|
||||||
HttpCode.FORBIDDEN,
|
// Check password expiration policies feature
|
||||||
"You are not allowed to set log retention days greater than 30 with your current subscription"
|
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
|
const updatedOrg = await db
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { createCertificate } from "#dynamic/routers/certificates/createCertifica
|
|||||||
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
|
||||||
const updateResourceParamsSchema = z.strictObject({
|
const updateResourceParamsSchema = z.strictObject({
|
||||||
resourceId: z.string().transform(Number).pipe(z.int().positive())
|
resourceId: z.string().transform(Number).pipe(z.int().positive())
|
||||||
@@ -341,7 +342,7 @@ async function updateHttpResource(
|
|||||||
headers = null;
|
headers = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLicensed = await isLicensedOrSubscribed(resource.orgId);
|
const isLicensed = await isLicensedOrSubscribed(resource.orgId, tierMatrix.maintencePage);
|
||||||
if (!isLicensed) {
|
if (!isLicensed) {
|
||||||
updateData.maintenanceModeEnabled = undefined;
|
updateData.maintenanceModeEnabled = undefined;
|
||||||
updateData.maintenanceModeType = undefined;
|
updateData.maintenanceModeType = undefined;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user