From 67f124c6cf9eca5ee98b9ac954263dce315b5c43 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Thu, 18 Jun 2026 12:05:36 -0700 Subject: [PATCH 1/2] feat(shared): decouple offline anonymous access from seat cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Offline license anonymous access was derived from `seats === undefined`, which conflated "uncapped seats" with "anonymous access allowed" — an uncapped license inadvertently enabled anonymous access. Add an explicit, signed `anonymousAccess` boolean to the offline license payload and drive `isAnonymousAccessAvailable` from it, so seat-capping and anonymous access can be set independently. Uncapped licenses no longer grant anonymous access unless `anonymousAccess: true` is set. The new field is included in the signed payload alphabetically; since `JSON.stringify` drops `undefined`, legacy keys without the field verify byte-for-byte as before. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/shared/src/entitlements.test.ts | 27 ++++++++++++++++++------ packages/shared/src/entitlements.ts | 12 ++++++++++- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/entitlements.test.ts b/packages/shared/src/entitlements.test.ts index 35fe4a65d..89b1bcfbe 100644 --- a/packages/shared/src/entitlements.test.ts +++ b/packages/shared/src/entitlements.test.ts @@ -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', }); @@ -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); }); @@ -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); }); diff --git a/packages/shared/src/entitlements.ts b/packages/shared/src/entitlements.ts index bcfdac6cd..0480117e0 100644 --- a/packages/shared/src/entitlements.ts +++ b/packages/shared/src/entitlements.ts @@ -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(), @@ -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 @@ -130,7 +138,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); @@ -163,6 +171,7 @@ export const hasEntitlement = (entitlement: Entitlement, _license: License | nul export type OfflineLicenseMetadata = { id: string; seats?: number; + anonymousAccess?: boolean; expiryDate: string; } @@ -178,6 +187,7 @@ export const getOfflineLicenseMetadata = (): OfflineLicenseMetadata | null => { return { id: license.id, seats: license.seats, + anonymousAccess: license.anonymousAccess, expiryDate: license.expiryDate, }; } From 3c47dade1b3c963fa9896ff7f61122427bc53538 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Thu, 18 Jun 2026 12:06:08 -0700 Subject: [PATCH 2/2] docs: add CHANGELOG entry for anonymous-access decoupling [#1349] Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2dd6795c..79d2dc354 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- [EE] Decoupled offline-license anonymous access from the seat cap. Anonymous access is now controlled by an explicit `anonymousAccess` field on the license key instead of being implied by an unlimited (uncapped) seat count. [#1349](https://github.com/sourcebot-dev/sourcebot/pull/1349) + ### Fixed - Upgraded `@grpc/grpc-js` to `^1.14.4`. [#1315](https://github.com/sourcebot-dev/sourcebot/pull/1315) - Upgraded `vite` to `^8.0.16`. [#1313](https://github.com/sourcebot-dev/sourcebot/pull/1313)