mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-21 10:08:34 -06:00
feat: license status for self hosters (#7236)
This commit is contained in:
committed by
GitHub
parent
fb0ef2fa82
commit
73e8e2f899
@@ -73,7 +73,12 @@ describe("rateLimitConfigs", () => {
|
||||
|
||||
test("should have all action configurations", () => {
|
||||
const actionConfigs = Object.keys(rateLimitConfigs.actions);
|
||||
expect(actionConfigs).toEqual(["emailUpdate", "surveyFollowUp", "sendLinkSurveyEmail"]);
|
||||
expect(actionConfigs).toEqual([
|
||||
"emailUpdate",
|
||||
"surveyFollowUp",
|
||||
"sendLinkSurveyEmail",
|
||||
"licenseRecheck",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export const rateLimitConfigs = {
|
||||
allowedPerInterval: 10,
|
||||
namespace: "action:send-link-survey-email",
|
||||
}, // 10 per hour
|
||||
licenseRecheck: { interval: 60, allowedPerInterval: 5, namespace: "action:license-recheck" }, // 5 per minute
|
||||
},
|
||||
|
||||
storage: {
|
||||
|
||||
100
apps/web/modules/ee/license-check/actions.ts
Normal file
100
apps/web/modules/ee/license-check/actions.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import {
|
||||
AuthenticationError,
|
||||
OperationNotAllowedError,
|
||||
ResourceNotFoundError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import {
|
||||
FAILED_FETCH_TTL_MS,
|
||||
FETCH_LICENSE_TTL_MS,
|
||||
LicenseApiError,
|
||||
clearLicenseCache,
|
||||
computeFreshLicenseState,
|
||||
fetchLicenseFresh,
|
||||
getCacheKeys,
|
||||
} from "./lib/license";
|
||||
|
||||
const ZRecheckLicenseAction = z.object({
|
||||
environmentId: ZId,
|
||||
});
|
||||
|
||||
export type TRecheckLicenseAction = z.infer<typeof ZRecheckLicenseAction>;
|
||||
|
||||
export const recheckLicenseAction = authenticatedActionClient
|
||||
.schema(ZRecheckLicenseAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: TRecheckLicenseAction;
|
||||
}) => {
|
||||
// Rate limit: 5 rechecks per minute per user
|
||||
await applyRateLimit(rateLimitConfigs.actions.licenseRecheck, ctx.user.id);
|
||||
|
||||
// Only allow on self-hosted instances
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
throw new OperationNotAllowedError("License recheck is only available on self-hosted instances");
|
||||
}
|
||||
|
||||
// Get organization from environment
|
||||
const organization = await getOrganizationByEnvironmentId(parsedInput.environmentId);
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("Organization", null);
|
||||
}
|
||||
|
||||
// Check user is owner or manager (not member)
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(ctx.user.id, organization.id);
|
||||
if (!currentUserMembership) {
|
||||
throw new AuthenticationError("User not a member of this organization");
|
||||
}
|
||||
|
||||
if (currentUserMembership.role === "member") {
|
||||
throw new OperationNotAllowedError("Only owners and managers can recheck license");
|
||||
}
|
||||
|
||||
// Clear main license cache (preserves previous result cache for grace period)
|
||||
// This prevents instant downgrade if the license server is temporarily unreachable
|
||||
await clearLicenseCache();
|
||||
|
||||
const cacheKeys = getCacheKeys();
|
||||
let freshLicense: Awaited<ReturnType<typeof fetchLicenseFresh>>;
|
||||
|
||||
try {
|
||||
freshLicense = await fetchLicenseFresh();
|
||||
} catch (error) {
|
||||
// 400 = invalid license key — return directly so the UI shows the correct message
|
||||
if (error instanceof LicenseApiError && error.status === 400) {
|
||||
return { active: false, status: "invalid_license" as const };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Cache the fresh result (or null if failed) so getEnterpriseLicense can use it.
|
||||
// Wrapped in { value: ... } so fetchLicense can distinguish cache miss from cached null.
|
||||
if (freshLicense) {
|
||||
await cache.set(cacheKeys.FETCH_LICENSE_CACHE_KEY, { value: freshLicense }, FETCH_LICENSE_TTL_MS);
|
||||
} else {
|
||||
await cache.set(cacheKeys.FETCH_LICENSE_CACHE_KEY, { value: null }, FAILED_FETCH_TTL_MS);
|
||||
}
|
||||
|
||||
const licenseState = await computeFreshLicenseState(freshLicense);
|
||||
|
||||
return {
|
||||
active: licenseState.active,
|
||||
status: licenseState.status,
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -98,6 +98,7 @@ describe("License Core Logic", () => {
|
||||
mockCache.get.mockReset();
|
||||
mockCache.set.mockReset();
|
||||
mockCache.del.mockReset();
|
||||
mockCache.exists.mockReset();
|
||||
mockCache.withCache.mockReset();
|
||||
mockLogger.error.mockReset();
|
||||
mockLogger.warn.mockReset();
|
||||
@@ -105,9 +106,10 @@ describe("License Core Logic", () => {
|
||||
mockLogger.debug.mockReset();
|
||||
|
||||
// Set up default mock implementations for Result types
|
||||
// fetchLicense uses get + exists; getPreviousResult uses get with :previous_result key
|
||||
mockCache.get.mockResolvedValue({ ok: true, data: null });
|
||||
mockCache.exists.mockResolvedValue({ ok: true, data: false }); // default: cache miss
|
||||
mockCache.set.mockResolvedValue({ ok: true });
|
||||
mockCache.withCache.mockImplementation(async (fn) => await fn());
|
||||
|
||||
vi.mocked(prisma.response.count).mockResolvedValue(100);
|
||||
vi.mocked(prisma.organization.findFirst).mockResolvedValue({
|
||||
@@ -164,16 +166,20 @@ describe("License Core Logic", () => {
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
|
||||
// Mock cache.withCache to return cached license details (simulating cache hit)
|
||||
mockCache.withCache.mockResolvedValue(mockFetchedLicenseDetails);
|
||||
// Mock cache hit: get returns wrapped license for status key
|
||||
mockCache.get.mockImplementation(async (key: string) => {
|
||||
if (key.includes(":previous_result")) {
|
||||
return { ok: true, data: null };
|
||||
}
|
||||
if (key.includes(":status")) {
|
||||
return { ok: true, data: { value: mockFetchedLicenseDetails } };
|
||||
}
|
||||
return { ok: true, data: null };
|
||||
});
|
||||
|
||||
const license = await getEnterpriseLicense();
|
||||
expect(license).toEqual(expectedActiveLicenseState);
|
||||
expect(mockCache.withCache).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.stringContaining("fb:license:"),
|
||||
expect.any(Number)
|
||||
);
|
||||
expect(mockCache.get).toHaveBeenCalledWith(expect.stringContaining("fb:license:"));
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -181,9 +187,7 @@ describe("License Core Logic", () => {
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
|
||||
// Mock cache.withCache to execute the function (simulating cache miss)
|
||||
mockCache.withCache.mockImplementation(async (fn) => await fn());
|
||||
|
||||
// Default mocks give cache miss (get returns null)
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: mockFetchedLicenseDetails }),
|
||||
@@ -192,11 +196,7 @@ describe("License Core Logic", () => {
|
||||
const license = await getEnterpriseLicense();
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockCache.withCache).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.stringContaining("fb:license:"),
|
||||
expect.any(Number)
|
||||
);
|
||||
expect(mockCache.get).toHaveBeenCalledWith(expect.stringContaining("fb:license:"));
|
||||
expect(license).toEqual(expectedActiveLicenseState);
|
||||
});
|
||||
|
||||
@@ -212,11 +212,9 @@ describe("License Core Logic", () => {
|
||||
version: 1,
|
||||
};
|
||||
|
||||
// Mock cache.withCache to return null (simulating fetch failure)
|
||||
mockCache.withCache.mockResolvedValue(null);
|
||||
|
||||
// Mock cache.get to return previous result when requested
|
||||
mockCache.get.mockImplementation(async (key) => {
|
||||
// Cache miss for fetch (get null, exists false) -> fetch fails -> null
|
||||
// getPreviousResult returns previous result for :previous_result key
|
||||
mockCache.get.mockImplementation(async (key: string) => {
|
||||
if (key.includes(":previous_result")) {
|
||||
return { ok: true, data: mockPreviousResult };
|
||||
}
|
||||
@@ -227,7 +225,7 @@ describe("License Core Logic", () => {
|
||||
|
||||
const license = await getEnterpriseLicense();
|
||||
|
||||
expect(mockCache.withCache).toHaveBeenCalled();
|
||||
expect(mockCache.get).toHaveBeenCalled();
|
||||
expect(license).toEqual({
|
||||
active: true,
|
||||
features: mockPreviousResult.features,
|
||||
@@ -250,11 +248,8 @@ describe("License Core Logic", () => {
|
||||
version: 1,
|
||||
};
|
||||
|
||||
// Mock cache.withCache to return null (simulating fetch failure)
|
||||
mockCache.withCache.mockResolvedValue(null);
|
||||
|
||||
// Mock cache.get to return previous result when requested
|
||||
mockCache.get.mockImplementation(async (key) => {
|
||||
// Cache miss -> fetch fails -> null; getPreviousResult returns old previous result
|
||||
mockCache.get.mockImplementation(async (key: string) => {
|
||||
if (key.includes(":previous_result")) {
|
||||
return { ok: true, data: mockPreviousResult };
|
||||
}
|
||||
@@ -265,7 +260,7 @@ describe("License Core Logic", () => {
|
||||
|
||||
const license = await getEnterpriseLicense();
|
||||
|
||||
expect(mockCache.withCache).toHaveBeenCalled();
|
||||
expect(mockCache.get).toHaveBeenCalled();
|
||||
expect(mockCache.set).toHaveBeenCalledWith(
|
||||
expect.stringContaining("fb:license:"),
|
||||
{
|
||||
@@ -319,12 +314,7 @@ describe("License Core Logic", () => {
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
|
||||
// Mock cache.withCache to return null (simulating fetch failure)
|
||||
mockCache.withCache.mockResolvedValue(null);
|
||||
|
||||
// Mock cache.get to return no previous result
|
||||
mockCache.get.mockResolvedValue({ ok: true, data: null });
|
||||
|
||||
// Cache miss -> fetch fails; no previous result (default get returns null)
|
||||
fetch.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
const license = await getEnterpriseLicense();
|
||||
@@ -397,49 +387,83 @@ describe("License Core Logic", () => {
|
||||
});
|
||||
expect(mockCache.get).not.toHaveBeenCalled();
|
||||
expect(mockCache.set).not.toHaveBeenCalled();
|
||||
expect(mockCache.withCache).not.toHaveBeenCalled();
|
||||
expect(mockCache.exists).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle fetch throwing an error and use grace period or return inactive", async () => {
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
|
||||
// Mock cache.withCache to return null (simulating fetch failure)
|
||||
mockCache.withCache.mockResolvedValue(null);
|
||||
|
||||
// Mock cache.get to return no previous result
|
||||
mockCache.get.mockResolvedValue({ ok: true, data: null });
|
||||
|
||||
fetch.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
const license = await getEnterpriseLicense();
|
||||
expect(license).toEqual({
|
||||
active: false,
|
||||
features: null,
|
||||
lastChecked: expect.any(Date),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
status: "no-license" as const,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLicenseFeatures", () => {
|
||||
test("should return features if license is active", async () => {
|
||||
// Set up environment before import
|
||||
vi.stubGlobal("window", undefined);
|
||||
// Runs after "no-license" test which uses vi.doMock; env may have empty key
|
||||
vi.resetModules();
|
||||
vi.doMock("@/lib/env", () => ({
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
ENVIRONMENT: "production",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
},
|
||||
}));
|
||||
// Mock cache.withCache to return license details
|
||||
mockCache.withCache.mockResolvedValue({
|
||||
status: "active",
|
||||
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
|
||||
// Cache miss -> fetch throws -> no previous result -> handleInitialFailure
|
||||
fetch.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
const license = await getEnterpriseLicense();
|
||||
expect(license).toEqual({
|
||||
active: false,
|
||||
features: expect.objectContaining({
|
||||
isMultiOrgEnabled: false,
|
||||
projects: 3,
|
||||
removeBranding: false,
|
||||
}),
|
||||
lastChecked: expect.any(Date),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
status: "unreachable" as const,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return invalid_license when API returns 400 (bad license key)", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@/lib/env", () => ({
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
ENVIRONMENT: "production",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
|
||||
mockCache.get.mockResolvedValue({ ok: true, data: null });
|
||||
fetch.mockResolvedValueOnce({ ok: false, status: 400 } as any);
|
||||
|
||||
const license = await getEnterpriseLicense();
|
||||
|
||||
expect(license).toEqual({
|
||||
active: false,
|
||||
features: expect.objectContaining({ projects: 3 }),
|
||||
lastChecked: expect.any(Date),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
status: "invalid_license" as const,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLicenseFeatures", () => {
|
||||
test("should return features if license is active", async () => {
|
||||
vi.resetModules();
|
||||
vi.stubGlobal("window", undefined);
|
||||
// Mock cache hit for fetchLicense (get returns wrapped license)
|
||||
const activeLicenseDetails = {
|
||||
status: "active" as const,
|
||||
features: {
|
||||
isMultiOrgEnabled: true,
|
||||
contacts: true,
|
||||
@@ -452,9 +476,21 @@ describe("License Core Logic", () => {
|
||||
spamProtection: true,
|
||||
ai: true,
|
||||
auditLogs: true,
|
||||
multiLanguageSurveys: true,
|
||||
accessControl: true,
|
||||
quotas: true,
|
||||
},
|
||||
};
|
||||
mockCache.get.mockImplementation(async (key: string) => {
|
||||
if (key.includes(":previous_result")) {
|
||||
return { ok: true, data: null };
|
||||
}
|
||||
if (key.includes(":status")) {
|
||||
return { ok: true, data: { value: activeLicenseDetails } };
|
||||
}
|
||||
return { ok: true, data: null };
|
||||
});
|
||||
// Import after env and mocks are set
|
||||
|
||||
const { getLicenseFeatures } = await import("./license");
|
||||
const features = await getLicenseFeatures();
|
||||
expect(features).toEqual({
|
||||
@@ -469,14 +505,48 @@ describe("License Core Logic", () => {
|
||||
spamProtection: true,
|
||||
ai: true,
|
||||
auditLogs: true,
|
||||
multiLanguageSurveys: true,
|
||||
accessControl: true,
|
||||
quotas: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return null if license is inactive", async () => {
|
||||
const { getLicenseFeatures } = await import("./license");
|
||||
|
||||
// Mock cache.withCache to return expired license
|
||||
mockCache.withCache.mockResolvedValue({ status: "expired", features: null });
|
||||
// Mock cache hit with expired license wrapped in { value: ... }
|
||||
mockCache.get.mockImplementation(async (key: string) => {
|
||||
if (key.includes(":previous_result")) {
|
||||
return { ok: true, data: null };
|
||||
}
|
||||
if (key.includes(":status")) {
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
value: {
|
||||
status: "expired",
|
||||
features: {
|
||||
isMultiOrgEnabled: false,
|
||||
projects: 3,
|
||||
twoFactorAuth: false,
|
||||
sso: false,
|
||||
whitelabel: false,
|
||||
removeBranding: false,
|
||||
contacts: false,
|
||||
ai: false,
|
||||
saml: false,
|
||||
spamProtection: false,
|
||||
auditLogs: false,
|
||||
multiLanguageSurveys: false,
|
||||
accessControl: false,
|
||||
quotas: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return { ok: true, data: null };
|
||||
});
|
||||
|
||||
const features = await getLicenseFeatures();
|
||||
expect(features).toBeNull();
|
||||
@@ -485,8 +555,8 @@ describe("License Core Logic", () => {
|
||||
test("should return null if getEnterpriseLicense throws", async () => {
|
||||
const { getLicenseFeatures } = await import("./license");
|
||||
|
||||
// Mock cache.withCache to throw an error
|
||||
mockCache.withCache.mockRejectedValue(new Error("Cache error"));
|
||||
// Mock cache.get to throw so getEnterpriseLicense fails
|
||||
mockCache.get.mockRejectedValue(new Error("Cache error"));
|
||||
|
||||
const features = await getLicenseFeatures();
|
||||
expect(features).toBeNull();
|
||||
@@ -499,23 +569,57 @@ describe("License Core Logic", () => {
|
||||
mockCache.get.mockReset();
|
||||
mockCache.set.mockReset();
|
||||
mockCache.del.mockReset();
|
||||
mockCache.withCache.mockReset();
|
||||
mockCache.exists.mockReset();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
test("should use 'browser' as cache key in browser environment", async () => {
|
||||
vi.stubGlobal("window", {});
|
||||
|
||||
// Set up default mock for cache.withCache
|
||||
mockCache.withCache.mockImplementation(async (fn) => await fn());
|
||||
// Ensure env has license key (previous "no-license" test may have poisoned env)
|
||||
vi.doMock("@/lib/env", () => ({
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
ENVIRONMENT: "production",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
// Cache miss so fetch runs; mock get for cache check
|
||||
mockCache.get.mockResolvedValue({ ok: true, data: null });
|
||||
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
status: "active",
|
||||
features: {
|
||||
isMultiOrgEnabled: true,
|
||||
projects: 5,
|
||||
twoFactorAuth: true,
|
||||
sso: true,
|
||||
whitelabel: true,
|
||||
removeBranding: true,
|
||||
contacts: true,
|
||||
ai: true,
|
||||
saml: true,
|
||||
spamProtection: true,
|
||||
auditLogs: true,
|
||||
multiLanguageSurveys: true,
|
||||
accessControl: true,
|
||||
quotas: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
await getEnterpriseLicense();
|
||||
expect(mockCache.withCache).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.stringContaining("fb:license:browser:status"),
|
||||
expect.any(Number)
|
||||
);
|
||||
expect(mockCache.get).toHaveBeenCalledWith(expect.stringContaining("fb:license:browser:status"));
|
||||
});
|
||||
|
||||
test("should use 'no-license' as cache key when ENTERPRISE_LICENSE_KEY is not set", async () => {
|
||||
@@ -534,16 +638,19 @@ describe("License Core Logic", () => {
|
||||
await getEnterpriseLicense();
|
||||
// The cache should NOT be accessed if there is no license key
|
||||
expect(mockCache.get).not.toHaveBeenCalled();
|
||||
expect(mockCache.withCache).not.toHaveBeenCalled();
|
||||
expect(mockCache.exists).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should use hashed license key as cache key when ENTERPRISE_LICENSE_KEY is set", async () => {
|
||||
vi.resetModules();
|
||||
const testLicenseKey = "test-license-key";
|
||||
vi.stubGlobal("window", undefined);
|
||||
|
||||
// Ensure env has license key (restore after "no-license" test)
|
||||
vi.doMock("@/lib/env", () => ({
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: testLicenseKey,
|
||||
ENVIRONMENT: "production",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
@@ -551,22 +658,49 @@ describe("License Core Logic", () => {
|
||||
},
|
||||
}));
|
||||
|
||||
// Set up default mock for cache.withCache
|
||||
mockCache.withCache.mockImplementation(async (fn) => await fn());
|
||||
mockCache.get.mockResolvedValue({ ok: true, data: null });
|
||||
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
status: "active",
|
||||
features: {
|
||||
isMultiOrgEnabled: true,
|
||||
projects: 5,
|
||||
twoFactorAuth: true,
|
||||
sso: true,
|
||||
whitelabel: true,
|
||||
removeBranding: true,
|
||||
contacts: true,
|
||||
ai: true,
|
||||
saml: true,
|
||||
spamProtection: true,
|
||||
auditLogs: true,
|
||||
multiLanguageSurveys: true,
|
||||
accessControl: true,
|
||||
quotas: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const { hashString } = await import("@/lib/hash-string");
|
||||
const expectedHash = hashString(testLicenseKey);
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
await getEnterpriseLicense();
|
||||
expect(mockCache.withCache).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.stringContaining(`fb:license:${expectedHash}:status`),
|
||||
expect.any(Number)
|
||||
expect(mockCache.get).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`fb:license:${expectedHash}:status`)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error and Warning Logging", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
test("should log warning when setPreviousResult cache.set fails (line 176-178)", async () => {
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
@@ -591,10 +725,18 @@ describe("License Core Logic", () => {
|
||||
},
|
||||
};
|
||||
|
||||
// Mock successful fetch from API
|
||||
mockCache.withCache.mockResolvedValue(mockFetchedLicenseDetails);
|
||||
// Cache hit - fetchLicense returns wrapped cached license
|
||||
mockCache.get.mockImplementation(async (key: string) => {
|
||||
if (key.includes(":previous_result")) {
|
||||
return { ok: true, data: null };
|
||||
}
|
||||
if (key.includes(":status")) {
|
||||
return { ok: true, data: { value: mockFetchedLicenseDetails } };
|
||||
}
|
||||
return { ok: true, data: null };
|
||||
});
|
||||
|
||||
// Mock cache.set to fail when saving previous result
|
||||
// cache.set fails when setPreviousResult tries to save (called for previous_result key)
|
||||
mockCache.set.mockResolvedValue({
|
||||
ok: false,
|
||||
error: new Error("Redis connection failed"),
|
||||
@@ -602,7 +744,6 @@ describe("License Core Logic", () => {
|
||||
|
||||
await getEnterpriseLicense();
|
||||
|
||||
// Verify that the warning was logged
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
{ error: new Error("Redis connection failed") },
|
||||
"Failed to cache previous result"
|
||||
@@ -613,10 +754,7 @@ describe("License Core Logic", () => {
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
|
||||
// Mock cache.withCache to execute the function (simulating cache miss)
|
||||
mockCache.withCache.mockImplementation(async (fn) => await fn());
|
||||
|
||||
// Mock API response with 500 status
|
||||
// Cache miss -> fetch returns 500
|
||||
const mockStatus = 500;
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
@@ -626,7 +764,6 @@ describe("License Core Logic", () => {
|
||||
|
||||
await getEnterpriseLicense();
|
||||
|
||||
// Verify that the API error was logged with correct structure
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: mockStatus,
|
||||
@@ -641,8 +778,7 @@ describe("License Core Logic", () => {
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
|
||||
// Test with 403 Forbidden
|
||||
mockCache.withCache.mockImplementation(async (fn) => await fn());
|
||||
// Cache miss -> fetch returns 403
|
||||
const mockStatus = 403;
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
@@ -652,7 +788,6 @@ describe("License Core Logic", () => {
|
||||
|
||||
await getEnterpriseLicense();
|
||||
|
||||
// Verify that the API error was logged with correct structure
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: mockStatus,
|
||||
@@ -675,8 +810,7 @@ describe("License Core Logic", () => {
|
||||
version: 1,
|
||||
};
|
||||
|
||||
mockCache.withCache.mockResolvedValue(null);
|
||||
mockCache.get.mockImplementation(async (key) => {
|
||||
mockCache.get.mockImplementation(async (key: string) => {
|
||||
if (key.includes(":previous_result")) {
|
||||
return { ok: true, data: mockPreviousResult };
|
||||
}
|
||||
@@ -687,7 +821,6 @@ describe("License Core Logic", () => {
|
||||
|
||||
await getEnterpriseLicense();
|
||||
|
||||
// Verify that the fallback info was logged
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fallbackLevel: "grace",
|
||||
@@ -698,6 +831,220 @@ describe("License Core Logic", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeFreshLicenseState", () => {
|
||||
const mockActiveLicenseDetails: TEnterpriseLicenseDetails = {
|
||||
status: "active",
|
||||
features: {
|
||||
isMultiOrgEnabled: true,
|
||||
contacts: true,
|
||||
projects: 10,
|
||||
whitelabel: true,
|
||||
removeBranding: true,
|
||||
twoFactorAuth: true,
|
||||
sso: true,
|
||||
saml: true,
|
||||
spamProtection: true,
|
||||
ai: false,
|
||||
auditLogs: true,
|
||||
multiLanguageSurveys: true,
|
||||
accessControl: true,
|
||||
quotas: true,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.resetAllMocks();
|
||||
mockCache.get.mockResolvedValue({ ok: true, data: null });
|
||||
mockCache.exists.mockResolvedValue({ ok: true, data: false });
|
||||
mockCache.set.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
test("should return active license state from pre-fetched active license without calling fetch", async () => {
|
||||
const { computeFreshLicenseState } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
|
||||
const result = await computeFreshLicenseState(mockActiveLicenseDetails);
|
||||
|
||||
expect(result).toEqual({
|
||||
active: true,
|
||||
features: mockActiveLicenseDetails.features,
|
||||
lastChecked: expect.any(Date),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
status: "active",
|
||||
});
|
||||
// Must not call the license API — the data was passed in directly
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should apply grace period fallback when freshLicense is null and previous result exists within grace", async () => {
|
||||
const previousTime = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000); // 1 day ago
|
||||
const mockPreviousResult = {
|
||||
active: true,
|
||||
features: { removeBranding: true, projects: 5 },
|
||||
lastChecked: previousTime,
|
||||
};
|
||||
|
||||
mockCache.get.mockImplementation(async (key: string) => {
|
||||
if (key.includes(":previous_result")) {
|
||||
return { ok: true, data: mockPreviousResult };
|
||||
}
|
||||
return { ok: true, data: null };
|
||||
});
|
||||
|
||||
const { computeFreshLicenseState } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
|
||||
const result = await computeFreshLicenseState(null);
|
||||
|
||||
expect(result).toEqual({
|
||||
active: true,
|
||||
features: mockPreviousResult.features,
|
||||
lastChecked: previousTime,
|
||||
isPendingDowngrade: true,
|
||||
fallbackLevel: "grace",
|
||||
status: "unreachable",
|
||||
});
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return inactive default when freshLicense is null and no previous result", async () => {
|
||||
const { computeFreshLicenseState } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
|
||||
const result = await computeFreshLicenseState(null);
|
||||
|
||||
expect(result).toEqual({
|
||||
active: false,
|
||||
features: expect.objectContaining({
|
||||
isMultiOrgEnabled: false,
|
||||
projects: 3,
|
||||
}),
|
||||
lastChecked: expect.any(Date),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default",
|
||||
status: "unreachable",
|
||||
});
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return expired state when freshLicense has expired status", async () => {
|
||||
const expiredLicense: TEnterpriseLicenseDetails = {
|
||||
status: "expired",
|
||||
features: mockActiveLicenseDetails.features,
|
||||
};
|
||||
|
||||
const { computeFreshLicenseState } = await import("./license");
|
||||
|
||||
const result = await computeFreshLicenseState(expiredLicense);
|
||||
|
||||
expect(result).toEqual({
|
||||
active: false,
|
||||
features: expect.objectContaining({
|
||||
isMultiOrgEnabled: false,
|
||||
projects: 3,
|
||||
}),
|
||||
lastChecked: expect.any(Date),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default",
|
||||
status: "expired",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearLicenseCache", () => {
|
||||
test("should clear memory cache and delete FETCH_LICENSE_CACHE_KEY", async () => {
|
||||
const { clearLicenseCache, getEnterpriseLicense } = await import("./license");
|
||||
const activeLicense = {
|
||||
status: "active" as const,
|
||||
features: {
|
||||
isMultiOrgEnabled: true,
|
||||
projects: 5,
|
||||
twoFactorAuth: true,
|
||||
sso: true,
|
||||
whitelabel: true,
|
||||
removeBranding: true,
|
||||
contacts: true,
|
||||
ai: true,
|
||||
saml: true,
|
||||
spamProtection: true,
|
||||
auditLogs: true,
|
||||
multiLanguageSurveys: true,
|
||||
accessControl: true,
|
||||
quotas: true,
|
||||
},
|
||||
};
|
||||
mockCache.get.mockImplementation(async (key: string) => {
|
||||
if (key.includes(":previous_result")) return { ok: true, data: null };
|
||||
if (key.includes(":status")) return { ok: true, data: { value: activeLicense } };
|
||||
return { ok: true, data: null };
|
||||
});
|
||||
mockCache.del.mockResolvedValue({ ok: true });
|
||||
|
||||
await getEnterpriseLicense();
|
||||
await clearLicenseCache();
|
||||
|
||||
expect(mockCache.del).toHaveBeenCalledWith(expect.arrayContaining([expect.stringContaining("fb:license:")]));
|
||||
});
|
||||
|
||||
test("should log warning when cache.del fails", async () => {
|
||||
const { clearLicenseCache } = await import("./license");
|
||||
mockCache.del.mockResolvedValue({ ok: false, error: new Error("Redis error") });
|
||||
|
||||
await clearLicenseCache();
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
{ error: new Error("Redis error") },
|
||||
"Failed to delete license cache"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchLicenseFresh", () => {
|
||||
test("should fetch directly from server without using cache", async () => {
|
||||
const { fetchLicenseFresh } = await import("./license");
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
|
||||
mockCache.get.mockResolvedValue({ ok: true, data: null });
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
status: "active",
|
||||
features: {
|
||||
isMultiOrgEnabled: true,
|
||||
projects: 5,
|
||||
twoFactorAuth: true,
|
||||
sso: true,
|
||||
whitelabel: true,
|
||||
removeBranding: true,
|
||||
contacts: true,
|
||||
ai: true,
|
||||
saml: true,
|
||||
spamProtection: true,
|
||||
auditLogs: true,
|
||||
multiLanguageSurveys: true,
|
||||
accessControl: true,
|
||||
quotas: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await fetchLicenseFresh();
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
status: "active",
|
||||
features: expect.objectContaining({ projects: 5 }),
|
||||
})
|
||||
);
|
||||
expect(fetch).toHaveBeenCalled();
|
||||
expect(mockCache.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Environment-based endpoint selection", () => {
|
||||
test("should use staging endpoint when ENVIRONMENT is staging", async () => {
|
||||
vi.resetModules();
|
||||
@@ -712,8 +1059,9 @@ describe("License Core Logic", () => {
|
||||
|
||||
const fetch = (await import("node-fetch")).default as Mock;
|
||||
|
||||
// Mock cache.withCache to execute the function (simulating cache miss)
|
||||
mockCache.withCache.mockImplementation(async (fn) => await fn());
|
||||
// Cache miss so fetchLicense fetches from server
|
||||
mockCache.get.mockResolvedValue({ ok: true, data: null });
|
||||
mockCache.exists.mockResolvedValue({ ok: true, data: false });
|
||||
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
|
||||
@@ -14,12 +14,14 @@ import { getInstanceId } from "@/lib/instance";
|
||||
import {
|
||||
TEnterpriseLicenseDetails,
|
||||
TEnterpriseLicenseFeatures,
|
||||
TEnterpriseLicenseStatusReturn,
|
||||
} from "@/modules/ee/license-check/types/enterprise-license";
|
||||
|
||||
// Configuration
|
||||
const CONFIG = {
|
||||
CACHE: {
|
||||
FETCH_LICENSE_TTL_MS: 24 * 60 * 60 * 1000, // 24 hours
|
||||
FAILED_FETCH_TTL_MS: 10 * 60 * 1000, // 10 minutes for failed/null results
|
||||
PREVIOUS_RESULT_TTL_MS: 4 * 24 * 60 * 60 * 1000, // 4 days
|
||||
GRACE_PERIOD_MS: 3 * 24 * 60 * 60 * 1000, // 3 days
|
||||
MAX_RETRIES: 3,
|
||||
@@ -30,16 +32,20 @@ const CONFIG = {
|
||||
env.ENVIRONMENT === "staging"
|
||||
? "https://staging.ee.formbricks.com/api/licenses/check"
|
||||
: "https://ee.formbricks.com/api/licenses/check",
|
||||
// ENDPOINT: "https://localhost:8080/api/licenses/check",
|
||||
TIMEOUT_MS: 5000,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const GRACE_PERIOD_MS = CONFIG.CACHE.GRACE_PERIOD_MS;
|
||||
|
||||
/** TTL in ms for successful license fetch results (24h). Re-export for use in actions. */
|
||||
export const FETCH_LICENSE_TTL_MS = CONFIG.CACHE.FETCH_LICENSE_TTL_MS;
|
||||
/** TTL in ms for failed license fetch results (10 min). Re-export for use in actions. */
|
||||
export const FAILED_FETCH_TTL_MS = CONFIG.CACHE.FAILED_FETCH_TTL_MS;
|
||||
|
||||
// Types
|
||||
type FallbackLevel = "live" | "cached" | "grace" | "default";
|
||||
|
||||
type TEnterpriseLicenseStatusReturn = "active" | "expired" | "unreachable" | "no-license";
|
||||
|
||||
type TEnterpriseLicenseResult = {
|
||||
active: boolean;
|
||||
features: TEnterpriseLicenseFeatures | null;
|
||||
@@ -55,6 +61,13 @@ type TPreviousResult = {
|
||||
features: TEnterpriseLicenseFeatures | null;
|
||||
};
|
||||
|
||||
// Wrapper type for cached license fetch results.
|
||||
// Storing { value: <result> } instead of <result> directly lets us distinguish
|
||||
// "key not in cache" (get returns null) from "key exists with a null value"
|
||||
// ({ value: null }) in a single cache.get call, eliminating the TOCTOU race
|
||||
// that existed between separate get + exists calls.
|
||||
type TCachedFetchResult = { value: TEnterpriseLicenseDetails | null };
|
||||
|
||||
// Validation schemas
|
||||
const LicenseFeaturesSchema = z.object({
|
||||
isMultiOrgEnabled: z.boolean(),
|
||||
@@ -89,7 +102,7 @@ class LicenseError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
class LicenseApiError extends LicenseError {
|
||||
export class LicenseApiError extends LicenseError {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number
|
||||
@@ -378,12 +391,23 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
|
||||
return fetchLicenseFromServerInternal(retryCount + 1);
|
||||
}
|
||||
|
||||
// 400 = invalid license key — propagate so callers can distinguish from unreachable
|
||||
if (res.status === 400) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (error instanceof LicenseApiError) {
|
||||
throw error;
|
||||
}
|
||||
logger.error(error, "Error while fetching license from server");
|
||||
logger.warn(
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
"License server fetch returned null - server may be unreachable"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -402,13 +426,31 @@ export const fetchLicense = async (): Promise<TEnterpriseLicenseDetails | null>
|
||||
}
|
||||
|
||||
fetchLicensePromise = (async () => {
|
||||
return await cache.withCache(
|
||||
async () => {
|
||||
return await fetchLicenseFromServerInternal();
|
||||
},
|
||||
getCacheKeys().FETCH_LICENSE_CACHE_KEY,
|
||||
CONFIG.CACHE.FETCH_LICENSE_TTL_MS
|
||||
);
|
||||
// Check cache first — a single get call distinguishes "not cached"
|
||||
// (data is null) from "cached null" (data is { value: null }).
|
||||
const cacheKey = getCacheKeys().FETCH_LICENSE_CACHE_KEY;
|
||||
const cached = await cache.get<TCachedFetchResult>(cacheKey);
|
||||
|
||||
if (cached.ok && cached.data !== null && "value" in cached.data) {
|
||||
return cached.data.value;
|
||||
}
|
||||
|
||||
// Cache miss -- fetch fresh
|
||||
const result = await fetchLicenseFromServerInternal();
|
||||
const ttl = result ? CONFIG.CACHE.FETCH_LICENSE_TTL_MS : CONFIG.CACHE.FAILED_FETCH_TTL_MS;
|
||||
|
||||
if (!result) {
|
||||
logger.warn(
|
||||
{
|
||||
ttlMinutes: Math.floor(ttl / 60000),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
"License fetch failed, caching null result with short TTL for faster retry"
|
||||
);
|
||||
}
|
||||
|
||||
await cache.set(getCacheKeys().FETCH_LICENSE_CACHE_KEY, { value: result }, ttl);
|
||||
return result;
|
||||
})();
|
||||
|
||||
fetchLicensePromise
|
||||
@@ -420,6 +462,115 @@ export const fetchLicense = async (): Promise<TEnterpriseLicenseDetails | null>
|
||||
return fetchLicensePromise;
|
||||
};
|
||||
|
||||
/**
|
||||
* Core license state evaluation logic.
|
||||
* Accepts pre-fetched license details and applies fallback / grace-period rules.
|
||||
* Sets the in-process memoryCache as a side effect so subsequent requests benefit.
|
||||
*/
|
||||
const computeLicenseState = async (
|
||||
liveLicenseDetails: TEnterpriseLicenseDetails | null
|
||||
): Promise<TEnterpriseLicenseResult> => {
|
||||
validateConfig();
|
||||
|
||||
if (!env.ENTERPRISE_LICENSE_KEY || env.ENTERPRISE_LICENSE_KEY.length === 0) {
|
||||
return {
|
||||
active: false,
|
||||
features: null,
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
status: "no-license" as const,
|
||||
};
|
||||
}
|
||||
|
||||
const currentTime = new Date();
|
||||
const previousResult = await getPreviousResult();
|
||||
const fallbackLevel = getFallbackLevel(liveLicenseDetails, previousResult, currentTime);
|
||||
trackFallbackUsage(fallbackLevel);
|
||||
|
||||
let currentLicenseState: TPreviousResult | undefined;
|
||||
|
||||
switch (fallbackLevel) {
|
||||
case "live": {
|
||||
if (!liveLicenseDetails) throw new Error("Invalid state: live license expected");
|
||||
currentLicenseState = {
|
||||
active: liveLicenseDetails.status === "active",
|
||||
features: liveLicenseDetails.features,
|
||||
lastChecked: currentTime,
|
||||
};
|
||||
|
||||
// Only update previous result if it's actually different or if it's old (1 hour)
|
||||
// This prevents hammering Redis on every request when the license is active
|
||||
if (
|
||||
!previousResult.active ||
|
||||
previousResult.active !== currentLicenseState.active ||
|
||||
currentTime.getTime() - previousResult.lastChecked.getTime() > 60 * 60 * 1000
|
||||
) {
|
||||
await setPreviousResult(currentLicenseState);
|
||||
}
|
||||
|
||||
const liveResult: TEnterpriseLicenseResult = {
|
||||
active: currentLicenseState.active,
|
||||
features: currentLicenseState.features,
|
||||
lastChecked: currentTime,
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live" as const,
|
||||
status: liveLicenseDetails.status,
|
||||
};
|
||||
memoryCache = { data: liveResult, timestamp: Date.now() };
|
||||
return liveResult;
|
||||
}
|
||||
|
||||
case "grace": {
|
||||
if (!validateFallback(previousResult)) {
|
||||
return await handleInitialFailure(currentTime);
|
||||
}
|
||||
logger.warn(
|
||||
{
|
||||
lastChecked: previousResult.lastChecked.toISOString(),
|
||||
gracePeriodEnds: new Date(
|
||||
previousResult.lastChecked.getTime() + CONFIG.CACHE.GRACE_PERIOD_MS
|
||||
).toISOString(),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
"License server unreachable, using grace period. Will retry in ~10 minutes."
|
||||
);
|
||||
const graceResult: TEnterpriseLicenseResult = {
|
||||
active: previousResult.active,
|
||||
features: previousResult.features,
|
||||
lastChecked: previousResult.lastChecked,
|
||||
isPendingDowngrade: true,
|
||||
fallbackLevel: "grace" as const,
|
||||
status: (liveLicenseDetails?.status as TEnterpriseLicenseStatusReturn) ?? "unreachable",
|
||||
};
|
||||
memoryCache = { data: graceResult, timestamp: Date.now() };
|
||||
return graceResult;
|
||||
}
|
||||
|
||||
case "default": {
|
||||
if (liveLicenseDetails?.status === "expired") {
|
||||
const expiredResult: TEnterpriseLicenseResult = {
|
||||
active: false,
|
||||
features: DEFAULT_FEATURES,
|
||||
lastChecked: currentTime,
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
status: "expired" as const,
|
||||
};
|
||||
memoryCache = { data: expiredResult, timestamp: Date.now() };
|
||||
return expiredResult;
|
||||
}
|
||||
const failResult = await handleInitialFailure(currentTime);
|
||||
memoryCache = { data: failResult, timestamp: Date.now() };
|
||||
return failResult;
|
||||
}
|
||||
}
|
||||
|
||||
const finalFailResult = await handleInitialFailure(currentTime);
|
||||
memoryCache = { data: finalFailResult, timestamp: Date.now() };
|
||||
return finalFailResult;
|
||||
};
|
||||
|
||||
export const getEnterpriseLicense = reactCache(async (): Promise<TEnterpriseLicenseResult> => {
|
||||
if (
|
||||
process.env.NODE_ENV !== "test" &&
|
||||
@@ -432,95 +583,27 @@ export const getEnterpriseLicense = reactCache(async (): Promise<TEnterpriseLice
|
||||
if (getEnterpriseLicensePromise) return getEnterpriseLicensePromise;
|
||||
|
||||
getEnterpriseLicensePromise = (async () => {
|
||||
validateConfig();
|
||||
let liveLicenseDetails: TEnterpriseLicenseDetails | null = null;
|
||||
|
||||
if (!env.ENTERPRISE_LICENSE_KEY || env.ENTERPRISE_LICENSE_KEY.length === 0) {
|
||||
return {
|
||||
active: false,
|
||||
features: null,
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
status: "no-license" as const,
|
||||
};
|
||||
}
|
||||
const currentTime = new Date();
|
||||
const [liveLicenseDetails, previousResult] = await Promise.all([fetchLicense(), getPreviousResult()]);
|
||||
const fallbackLevel = getFallbackLevel(liveLicenseDetails, previousResult, currentTime);
|
||||
|
||||
trackFallbackUsage(fallbackLevel);
|
||||
|
||||
let currentLicenseState: TPreviousResult | undefined;
|
||||
|
||||
switch (fallbackLevel) {
|
||||
case "live": {
|
||||
if (!liveLicenseDetails) throw new Error("Invalid state: live license expected");
|
||||
currentLicenseState = {
|
||||
active: liveLicenseDetails.status === "active",
|
||||
features: liveLicenseDetails.features,
|
||||
lastChecked: currentTime,
|
||||
};
|
||||
|
||||
// Only update previous result if it's actually different or if it's old (1 hour)
|
||||
// This prevents hammering Redis on every request when the license is active
|
||||
if (
|
||||
!previousResult.active ||
|
||||
previousResult.active !== currentLicenseState.active ||
|
||||
currentTime.getTime() - previousResult.lastChecked.getTime() > 60 * 60 * 1000
|
||||
) {
|
||||
await setPreviousResult(currentLicenseState);
|
||||
}
|
||||
|
||||
const liveResult: TEnterpriseLicenseResult = {
|
||||
active: currentLicenseState.active,
|
||||
features: currentLicenseState.features,
|
||||
lastChecked: currentTime,
|
||||
try {
|
||||
liveLicenseDetails = await fetchLicense();
|
||||
} catch (error) {
|
||||
if (error instanceof LicenseApiError && error.status === 400) {
|
||||
const invalidResult: TEnterpriseLicenseResult = {
|
||||
active: false,
|
||||
features: DEFAULT_FEATURES,
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live" as const,
|
||||
status: liveLicenseDetails.status,
|
||||
fallbackLevel: "default" as const,
|
||||
status: "invalid_license" as const,
|
||||
};
|
||||
memoryCache = { data: liveResult, timestamp: Date.now() };
|
||||
return liveResult;
|
||||
}
|
||||
|
||||
case "grace": {
|
||||
if (!validateFallback(previousResult)) {
|
||||
return await handleInitialFailure(currentTime);
|
||||
}
|
||||
const graceResult: TEnterpriseLicenseResult = {
|
||||
active: previousResult.active,
|
||||
features: previousResult.features,
|
||||
lastChecked: previousResult.lastChecked,
|
||||
isPendingDowngrade: true,
|
||||
fallbackLevel: "grace" as const,
|
||||
status: (liveLicenseDetails?.status as TEnterpriseLicenseStatusReturn) ?? "unreachable",
|
||||
};
|
||||
memoryCache = { data: graceResult, timestamp: Date.now() };
|
||||
return graceResult;
|
||||
}
|
||||
|
||||
case "default": {
|
||||
if (liveLicenseDetails?.status === "expired") {
|
||||
const expiredResult: TEnterpriseLicenseResult = {
|
||||
active: false,
|
||||
features: DEFAULT_FEATURES,
|
||||
lastChecked: currentTime,
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
status: "expired" as const,
|
||||
};
|
||||
memoryCache = { data: expiredResult, timestamp: Date.now() };
|
||||
return expiredResult;
|
||||
}
|
||||
const failResult = await handleInitialFailure(currentTime);
|
||||
memoryCache = { data: failResult, timestamp: Date.now() };
|
||||
return failResult;
|
||||
memoryCache = { data: invalidResult, timestamp: Date.now() };
|
||||
return invalidResult;
|
||||
}
|
||||
// Other errors: liveLicenseDetails stays null (treated as unreachable)
|
||||
}
|
||||
|
||||
const finalFailResult = await handleInitialFailure(currentTime);
|
||||
memoryCache = { data: finalFailResult, timestamp: Date.now() };
|
||||
return finalFailResult;
|
||||
return computeLicenseState(liveLicenseDetails);
|
||||
})();
|
||||
|
||||
getEnterpriseLicensePromise
|
||||
@@ -542,4 +625,55 @@ export const getLicenseFeatures = async (): Promise<TEnterpriseLicenseFeatures |
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear license fetch cache (but preserve previous result cache for grace period)
|
||||
* Used by the recheck license action to force a fresh fetch without losing grace period
|
||||
*/
|
||||
export const clearLicenseCache = async (): Promise<void> => {
|
||||
memoryCache = null;
|
||||
const cacheKeys = getCacheKeys();
|
||||
// Only clear the main fetch cache, NOT the previous result cache
|
||||
// This preserves the grace period fallback if the server is unreachable
|
||||
const delResult = await cache.del([cacheKeys.FETCH_LICENSE_CACHE_KEY]);
|
||||
if (!delResult.ok) {
|
||||
logger.warn({ error: delResult.error }, "Failed to delete license cache");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch license directly from server without using cache.
|
||||
* Used by the recheck license action for a fresh check.
|
||||
* Concurrent callers share a single in-flight request to avoid
|
||||
* hammering the license server (e.g. multiple managers rechecking).
|
||||
*/
|
||||
let fetchLicenseFreshPromise: Promise<TEnterpriseLicenseDetails | null> | null = null;
|
||||
|
||||
export const fetchLicenseFresh = async (): Promise<TEnterpriseLicenseDetails | null> => {
|
||||
if (fetchLicenseFreshPromise) return fetchLicenseFreshPromise;
|
||||
|
||||
fetchLicenseFreshPromise = fetchLicenseFromServerInternal();
|
||||
|
||||
fetchLicenseFreshPromise
|
||||
.finally(() => {
|
||||
fetchLicenseFreshPromise = null;
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return fetchLicenseFreshPromise;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute license state from pre-fetched license data, bypassing React cache
|
||||
* and the in-process memory cache. Used by the recheck action to guarantee
|
||||
* fresh evaluation after clearing caches and fetching new data.
|
||||
* Refreshes the in-process memory cache as a side effect so subsequent
|
||||
* requests benefit from the fresh result.
|
||||
*/
|
||||
export const computeFreshLicenseState = async (
|
||||
freshLicense: TEnterpriseLicenseDetails | null
|
||||
): Promise<TEnterpriseLicenseResult> => {
|
||||
memoryCache = null;
|
||||
return computeLicenseState(freshLicense);
|
||||
};
|
||||
|
||||
// All permission checking functions and their helpers have been moved to utils.ts
|
||||
|
||||
@@ -29,3 +29,5 @@ export const ZEnterpriseLicenseDetails = z.object({
|
||||
});
|
||||
|
||||
export type TEnterpriseLicenseDetails = z.infer<typeof ZEnterpriseLicenseDetails>;
|
||||
|
||||
export type TEnterpriseLicenseStatusReturn = "active" | "expired" | "unreachable" | "invalid_license" | "no-license";
|
||||
|
||||
@@ -15,7 +15,7 @@ type TEnterpriseLicense = {
|
||||
lastChecked: Date;
|
||||
isPendingDowngrade: boolean;
|
||||
fallbackLevel: string;
|
||||
status: "active" | "expired" | "unreachable" | "no-license";
|
||||
status: "active" | "expired" | "unreachable" | "no-license" | "invalid_license";
|
||||
};
|
||||
|
||||
export const ZEnvironmentAuth = z.object({
|
||||
|
||||
@@ -12,7 +12,7 @@ interface PendingDowngradeBannerProps {
|
||||
isPendingDowngrade: boolean;
|
||||
environmentId: string;
|
||||
locale: TUserLocale;
|
||||
status: "active" | "expired" | "unreachable" | "no-license";
|
||||
status: "active" | "expired" | "unreachable" | "no-license" | "invalid_license";
|
||||
}
|
||||
|
||||
export const PendingDowngradeBanner = ({
|
||||
|
||||
Reference in New Issue
Block a user