Skip to content

Commit 41967e1

Browse files
handle license validation for all paths
1 parent cf6aeaf commit 41967e1

File tree

10 files changed

+282
-68
lines changed

10 files changed

+282
-68
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Changed
1111
- Redesigned the app layout with a new collapsible sidebar navigation, replacing the previous top navigation bar. [#1097](https://github.com/sourcebot-dev/sourcebot/pull/1097)
12+
- Expired offline license keys no longer crash the process. An expired key now degrades to the unlicensed state. [#1109](https://github.com/sourcebot-dev/sourcebot/pull/1109)
1213

1314
## [4.16.9] - 2026-04-15
1415

packages/backend/src/entitlements.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {
22
Entitlement,
3-
hasEntitlement as _hasEntitlement,
4-
getEntitlements as _getEntitlements,
3+
_hasEntitlement,
4+
_getEntitlements,
55
} from "@sourcebot/shared";
66
import { prisma } from "./prisma.js";
77
import { SINGLE_TENANT_ORG_ID } from "./constants.js";
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { describe, test, expect, vi, beforeEach } from 'vitest';
2+
import type { License } from '@sourcebot/db';
3+
4+
const mocks = vi.hoisted(() => ({
5+
env: {
6+
SOURCEBOT_PUBLIC_KEY_PATH: '/tmp/test-key',
7+
SOURCEBOT_EE_LICENSE_KEY: undefined as string | undefined,
8+
} as Record<string, string | undefined>,
9+
verifySignature: vi.fn(() => true),
10+
}));
11+
12+
vi.mock('./env.server.js', () => ({
13+
env: mocks.env,
14+
}));
15+
16+
vi.mock('./crypto.js', () => ({
17+
verifySignature: mocks.verifySignature,
18+
}));
19+
20+
vi.mock('./logger.js', () => ({
21+
createLogger: () => ({
22+
error: vi.fn(),
23+
info: vi.fn(),
24+
warn: vi.fn(),
25+
debug: vi.fn(),
26+
}),
27+
}));
28+
29+
import {
30+
isAnonymousAccessAvailable,
31+
getEntitlements,
32+
hasEntitlement,
33+
} from './entitlements.js';
34+
35+
const encodeOfflineKey = (payload: object): string => {
36+
const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url');
37+
return `sourcebot_ee_${encoded}`;
38+
};
39+
40+
const futureDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * 365).toISOString();
41+
const pastDate = new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString();
42+
43+
const validOfflineKey = (overrides: { seats?: number; expiryDate?: string } = {}) =>
44+
encodeOfflineKey({
45+
id: 'test-customer',
46+
expiryDate: overrides.expiryDate ?? futureDate,
47+
...(overrides.seats !== undefined ? { seats: overrides.seats } : {}),
48+
sig: 'fake-sig',
49+
});
50+
51+
const makeLicense = (overrides: Partial<License> = {}): License => ({
52+
id: 'lic_1',
53+
orgId: 1,
54+
activationCode: 'code',
55+
entitlements: [],
56+
seats: null,
57+
status: null,
58+
lastSyncAt: null,
59+
createdAt: new Date(),
60+
updatedAt: new Date(),
61+
...overrides,
62+
});
63+
64+
beforeEach(() => {
65+
mocks.env.SOURCEBOT_EE_LICENSE_KEY = undefined;
66+
mocks.verifySignature.mockReturnValue(true);
67+
});
68+
69+
describe('isAnonymousAccessAvailable', () => {
70+
describe('without any license', () => {
71+
test('returns true when license is null', () => {
72+
expect(isAnonymousAccessAvailable(null)).toBe(true);
73+
});
74+
75+
test('returns true when license has no status', () => {
76+
expect(isAnonymousAccessAvailable(makeLicense())).toBe(true);
77+
});
78+
79+
test('returns true when license status is canceled', () => {
80+
expect(isAnonymousAccessAvailable(makeLicense({ status: 'canceled' }))).toBe(true);
81+
});
82+
});
83+
84+
describe('with an active online license', () => {
85+
test.each(['active', 'trialing', 'past_due'] as const)(
86+
'returns false when status is %s',
87+
(status) => {
88+
expect(isAnonymousAccessAvailable(makeLicense({ status }))).toBe(false);
89+
}
90+
);
91+
});
92+
93+
describe('with an offline license key', () => {
94+
test('returns false when offline key has a seat count', () => {
95+
mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 100 });
96+
expect(isAnonymousAccessAvailable(null)).toBe(false);
97+
});
98+
99+
test('returns true when offline key has no seat count (unlimited)', () => {
100+
mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey();
101+
expect(isAnonymousAccessAvailable(null)).toBe(true);
102+
});
103+
104+
test('unlimited offline key beats an active online license', () => {
105+
mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey();
106+
expect(isAnonymousAccessAvailable(makeLicense({ status: 'active' }))).toBe(true);
107+
});
108+
109+
test('falls through to online license check when offline key is expired', () => {
110+
mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 100, expiryDate: pastDate });
111+
expect(isAnonymousAccessAvailable(null)).toBe(true);
112+
expect(isAnonymousAccessAvailable(makeLicense({ status: 'active' }))).toBe(false);
113+
});
114+
115+
test('falls through when offline key is malformed', () => {
116+
mocks.env.SOURCEBOT_EE_LICENSE_KEY = 'sourcebot_ee_not-valid-base64-or-json';
117+
expect(isAnonymousAccessAvailable(null)).toBe(true);
118+
});
119+
120+
test('falls through when offline key has wrong prefix', () => {
121+
mocks.env.SOURCEBOT_EE_LICENSE_KEY = 'bogus_prefix_xyz';
122+
expect(isAnonymousAccessAvailable(null)).toBe(true);
123+
});
124+
125+
test('falls through when offline key signature is invalid', () => {
126+
mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 100 });
127+
mocks.verifySignature.mockReturnValue(false);
128+
expect(isAnonymousAccessAvailable(null)).toBe(true);
129+
});
130+
});
131+
});
132+
133+
describe('getEntitlements', () => {
134+
test('returns empty array when no license and no offline key', () => {
135+
expect(getEntitlements(null)).toEqual([]);
136+
});
137+
138+
test('returns license.entitlements when license is active', () => {
139+
const license = makeLicense({ status: 'active', entitlements: ['sso', 'audit'] });
140+
expect(getEntitlements(license)).toEqual(['sso', 'audit']);
141+
});
142+
143+
test('returns empty when license has no status', () => {
144+
expect(getEntitlements(makeLicense({ entitlements: ['sso'] }))).toEqual([]);
145+
});
146+
147+
test('returns all entitlements when offline key is valid', () => {
148+
mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 50 });
149+
const result = getEntitlements(null);
150+
expect(result).toContain('sso');
151+
expect(result).toContain('audit');
152+
expect(result).toContain('search-contexts');
153+
});
154+
155+
test('falls through when offline key is expired', () => {
156+
mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 50, expiryDate: pastDate });
157+
expect(getEntitlements(null)).toEqual([]);
158+
expect(
159+
getEntitlements(makeLicense({ status: 'active', entitlements: ['sso'] }))
160+
).toEqual(['sso']);
161+
});
162+
163+
test('falls through when offline key is malformed', () => {
164+
mocks.env.SOURCEBOT_EE_LICENSE_KEY = 'sourcebot_ee_not-a-valid-payload';
165+
expect(getEntitlements(null)).toEqual([]);
166+
});
167+
});
168+
169+
describe('hasEntitlement', () => {
170+
test('returns true when entitlement is present in license', () => {
171+
expect(
172+
hasEntitlement('sso', makeLicense({ status: 'active', entitlements: ['sso'] }))
173+
).toBe(true);
174+
});
175+
176+
test('returns false when entitlement is absent from license', () => {
177+
expect(
178+
hasEntitlement('audit', makeLicense({ status: 'active', entitlements: ['sso'] }))
179+
).toBe(false);
180+
});
181+
182+
test('returns true for any entitlement when offline key is valid', () => {
183+
mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 50 });
184+
expect(hasEntitlement('sso', null)).toBe(true);
185+
expect(hasEntitlement('audit', null)).toBe(true);
186+
});
187+
});

packages/shared/src/entitlements.ts

Lines changed: 59 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,23 @@ import { base64Decode } from "./utils.js";
22
import { z } from "zod";
33
import { createLogger } from "./logger.js";
44
import { env } from "./env.server.js";
5-
import { SOURCEBOT_SUPPORT_EMAIL } from "./constants.js";
65
import { verifySignature } from "./crypto.js";
76
import { License } from "@sourcebot/db";
87

98
const logger = createLogger('entitlements');
109

11-
const eeLicenseKeyPrefix = "sourcebot_ee_";
12-
13-
const eeLicenseKeyPayloadSchema = z.object({
10+
const offlineLicensePrefix = "sourcebot_ee_";
11+
const offlineLicensePayloadSchema = z.object({
1412
id: z.string(),
1513
seats: z.number().optional(),
1614
// ISO 8601 date string
1715
expiryDate: z.string().datetime(),
1816
sig: z.string(),
1917
});
2018

21-
type LicenseKeyPayload = z.infer<typeof eeLicenseKeyPayloadSchema>;
19+
type getValidOfflineLicense = z.infer<typeof offlineLicensePayloadSchema>;
20+
21+
const ACTIVE_ONLINE_LICENSE_STATUSES = ['active', 'trialing', 'past_due'] as const;
2222

2323
// eslint-disable-next-line @typescript-eslint/no-unused-vars
2424
const ALL_ENTITLEMENTS = [
@@ -35,20 +35,11 @@ const ALL_ENTITLEMENTS = [
3535
] as const;
3636
export type Entitlement = (typeof ALL_ENTITLEMENTS)[number];
3737

38-
const ACTIVE_LICENSE_STATUSES = ['active', 'trialing', 'past_due'] as const;
39-
40-
const isLicenseActive = (license: License): boolean => {
41-
if (!license.status) {
42-
return false;
43-
}
44-
return ACTIVE_LICENSE_STATUSES.includes(license.status as typeof ACTIVE_LICENSE_STATUSES[number]);
45-
}
46-
47-
const decodeLicenseKeyPayload = (payload: string): LicenseKeyPayload => {
38+
const decodeOfflineLicenseKeyPayload = (payload: string): getValidOfflineLicense | null => {
4839
try {
4940
const decodedPayload = base64Decode(payload);
5041
const payloadJson = JSON.parse(decodedPayload);
51-
const licenseData = eeLicenseKeyPayloadSchema.parse(payloadJson);
42+
const licenseData = offlineLicensePayloadSchema.parse(payloadJson);
5243

5344
const dataToVerify = JSON.stringify({
5445
expiryDate: licenseData.expiryDate,
@@ -59,57 +50,85 @@ const decodeLicenseKeyPayload = (payload: string): LicenseKeyPayload => {
5950
const isSignatureValid = verifySignature(dataToVerify, licenseData.sig, env.SOURCEBOT_PUBLIC_KEY_PATH);
6051
if (!isSignatureValid) {
6152
logger.error('License key signature verification failed');
62-
process.exit(1);
53+
return null;
6354
}
6455

6556
return licenseData;
6657
} catch (error) {
6758
logger.error(`Failed to decode license key payload: ${error}`);
68-
process.exit(1);
59+
return null;
6960
}
7061
}
7162

72-
export const getOfflineLicenseKey = (): LicenseKeyPayload | null => {
63+
const getValidOfflineLicense = (): getValidOfflineLicense | null => {
7364
const licenseKey = env.SOURCEBOT_EE_LICENSE_KEY;
74-
if (licenseKey && licenseKey.startsWith(eeLicenseKeyPrefix)) {
75-
const payload = licenseKey.substring(eeLicenseKeyPrefix.length);
76-
return decodeLicenseKeyPayload(payload);
65+
if (!licenseKey || !licenseKey.startsWith(offlineLicensePrefix)) {
66+
return null;
7767
}
78-
return null;
68+
69+
const payload = decodeOfflineLicenseKeyPayload(licenseKey.substring(offlineLicensePrefix.length));
70+
if (!payload) {
71+
return null;
72+
}
73+
74+
const expiryDate = new Date(payload.expiryDate);
75+
if (expiryDate.getTime() < new Date().getTime()) {
76+
return null;
77+
}
78+
79+
return payload;
7980
}
8081

81-
export const hasEntitlement = (entitlement: Entitlement, license: License | null) => {
82-
const entitlements = getEntitlements(license);
83-
return entitlements.includes(entitlement);
82+
const getValidOnlineLicense = (_license: License | null): License | null => {
83+
if (
84+
_license &&
85+
_license.status &&
86+
ACTIVE_ONLINE_LICENSE_STATUSES.includes(_license.status as typeof ACTIVE_ONLINE_LICENSE_STATUSES[number])
87+
) {
88+
return _license;
89+
}
90+
91+
return null;
8492
}
8593

86-
export const isAnonymousAccessAvailable = (license: License | null): boolean => {
87-
const offlineKey = getOfflineLicenseKey();
94+
export const isAnonymousAccessAvailable = (_license: License | null): boolean => {
95+
const offlineKey = getValidOfflineLicense();
8896
if (offlineKey) {
8997
return offlineKey.seats === undefined;
9098
}
9199

92-
if (license && isLicenseActive(license)) {
100+
const onlineLicense = getValidOnlineLicense(_license);
101+
if (onlineLicense) {
93102
return false;
94103
}
95104
return true;
96105
}
97106

98-
export const getEntitlements = (license: License | null): Entitlement[] => {
99-
const licenseKey = getOfflineLicenseKey();
100-
if (licenseKey) {
101-
const expiryDate = new Date(licenseKey.expiryDate);
102-
if (expiryDate.getTime() < new Date().getTime()) {
103-
logger.error(`The provided license key has expired (${expiryDate.toLocaleString()}). Please contact ${SOURCEBOT_SUPPORT_EMAIL} for support.`);
104-
process.exit(1);
105-
}
106-
107+
export const getEntitlements = (_license: License | null): Entitlement[] => {
108+
const offlineLicense = getValidOfflineLicense();
109+
if (offlineLicense) {
107110
return ALL_ENTITLEMENTS as unknown as Entitlement[];
108111
}
109-
else if (license && isLicenseActive(license)) {
110-
return license.entitlements as unknown as Entitlement[];
112+
113+
const onlineLicense = getValidOnlineLicense(_license);
114+
if (onlineLicense) {
115+
return onlineLicense.entitlements as unknown as Entitlement[];
111116
}
112117
else {
113118
return [];
114119
}
115120
}
121+
122+
export const hasEntitlement = (entitlement: Entitlement, _license: License | null) => {
123+
const entitlements = getEntitlements(_license);
124+
return entitlements.includes(entitlement);
125+
}
126+
127+
export const getSeatCap = (): number | undefined => {
128+
const offlineLicense = getValidOfflineLicense();
129+
if (offlineLicense?.seats && offlineLicense.seats > 0) {
130+
return offlineLicense.seats;
131+
}
132+
133+
return undefined;
134+
}

packages/shared/src/index.server.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
// types prefixed with _ are intended to be wrapped
2+
// by the consumer. See web/entitlements.ts and
3+
// backend/entitlements.ts
14
export {
2-
hasEntitlement,
3-
getOfflineLicenseKey,
4-
getEntitlements,
5-
isAnonymousAccessAvailable as isAnonymousAccessEnabled,
5+
hasEntitlement as _hasEntitlement,
6+
getEntitlements as _getEntitlements,
7+
isAnonymousAccessAvailable as _isAnonymousAccessAvailable,
8+
getSeatCap,
69
} from "./entitlements.js";
710
export type {
811
Entitlement,

0 commit comments

Comments
 (0)