Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed
- Decoupled offline-license anonymous access from the seat cap. [#1349](https://github.com/sourcebot-dev/sourcebot/pull/1349)

### Added
- Recorded service ping history locally and added a "Download usage report" button to the offline license settings page, so offline deployments can export their usage and send it to us. [#1348](https://github.com/sourcebot-dev/sourcebot/pull/1348)

Expand Down
27 changes: 20 additions & 7 deletions packages/shared/src/entitlements.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,12 @@ const encodeOfflineKey = (payload: object): string => {
const futureDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * 365).toISOString();
const pastDate = new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString();

const validOfflineKey = (overrides: { seats?: number; expiryDate?: string } = {}) =>
const validOfflineKey = (overrides: { seats?: number; anonymousAccess?: boolean; expiryDate?: string } = {}) =>
encodeOfflineKey({
id: 'test-customer',
expiryDate: overrides.expiryDate ?? futureDate,
...(overrides.seats !== undefined ? { seats: overrides.seats } : {}),
...(overrides.anonymousAccess !== undefined ? { anonymousAccess: overrides.anonymousAccess } : {}),
sig: 'fake-sig',
});

Expand Down Expand Up @@ -112,23 +113,35 @@ describe('isAnonymousAccessAvailable', () => {
});

describe('with an offline license key', () => {
test('returns false when offline key has a seat count', () => {
test('returns false when offline key does not grant anonymous access', () => {
mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 100 });
expect(isAnonymousAccessAvailable(null)).toBe(false);
});

test('returns true when offline key has no seat count (unlimited)', () => {
test('returns false when offline key is uncapped but does not grant anonymous access', () => {
// Uncapped (no seats) no longer implies anonymous access — it must
// be granted explicitly via the `anonymousAccess` flag.
mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey();
expect(isAnonymousAccessAvailable(null)).toBe(false);
});

test('returns true when offline key explicitly grants anonymous access', () => {
mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ anonymousAccess: true });
expect(isAnonymousAccessAvailable(null)).toBe(true);
});

test('unlimited offline key beats an active online license', () => {
mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey();
test('anonymous access is independent of the seat cap', () => {
mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 100, anonymousAccess: true });
expect(isAnonymousAccessAvailable(null)).toBe(true);
});

test('anonymous-access offline key beats an active online license', () => {
mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ anonymousAccess: true });
expect(isAnonymousAccessAvailable(makeLicense({ status: 'active' }))).toBe(true);
});

test('falls through to online license check when offline key is expired', () => {
mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 100, expiryDate: pastDate });
mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ anonymousAccess: true, expiryDate: pastDate });
expect(isAnonymousAccessAvailable(null)).toBe(true);
expect(isAnonymousAccessAvailable(makeLicense({ status: 'active' }))).toBe(false);
});
Expand All @@ -144,7 +157,7 @@ describe('isAnonymousAccessAvailable', () => {
});

test('falls through when offline key signature is invalid', () => {
mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 100 });
mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ anonymousAccess: true });
mocks.verifySignature.mockReturnValue(false);
expect(isAnonymousAccessAvailable(null)).toBe(true);
});
Expand Down
12 changes: 11 additions & 1 deletion packages/shared/src/entitlements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const offlineLicensePrefix = "sourcebot_ee_";
const offlineLicensePayloadSchema = z.object({
id: z.string(),
seats: z.number().optional(),
// Whether anonymous (unauthenticated) access is permitted.
anonymousAccess: z.boolean().optional(),
// ISO 8601 date string
expiryDate: z.string().datetime(),
sig: z.string(),
Expand Down Expand Up @@ -50,7 +52,13 @@ const decodeOfflineLicenseKeyPayload = (payload: string): getValidOfflineLicense
const payloadJson = JSON.parse(decodedPayload);
const licenseData = offlineLicensePayloadSchema.parse(payloadJson);

// Keys are listed alphabetically to match the canonical JSON the
// signer produces (Python `json.dumps(..., sort_keys=True)`).
// `JSON.stringify` drops `undefined` values, so omitted optional
// fields (e.g. a legacy key without `anonymousAccess`) verify exactly
// as they were originally signed.
const dataToVerify = JSON.stringify({
anonymousAccess: licenseData.anonymousAccess,
expiryDate: licenseData.expiryDate,
id: licenseData.id,
seats: licenseData.seats
Expand Down Expand Up @@ -138,7 +146,7 @@ export const isValidLicenseActive = (_license: License | null): boolean => {
export const isAnonymousAccessAvailable = (_license: License | null): boolean => {
const offlineKey = getValidOfflineLicense();
if (offlineKey) {
return offlineKey.seats === undefined;
return offlineKey.anonymousAccess === true;
}

const onlineLicense = getValidOnlineLicense(_license);
Expand Down Expand Up @@ -171,6 +179,7 @@ export const hasEntitlement = (entitlement: Entitlement, _license: License | nul
export type OfflineLicenseMetadata = {
id: string;
seats?: number;
anonymousAccess?: boolean;
expiryDate: string;
}

Expand All @@ -186,6 +195,7 @@ export const getOfflineLicenseMetadata = (): OfflineLicenseMetadata | null => {
return {
id: license.id,
seats: license.seats,
anonymousAccess: license.anonymousAccess,
expiryDate: license.expiryDate,
};
}
Expand Down
Loading