Skip to content

Commit ece54b2

Browse files
feat(shared): decouple offline anonymous access from seat cap (#1349)
* feat(shared): decouple offline anonymous access from seat cap 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) <noreply@anthropic.com> * docs: add CHANGELOG entry for anonymous-access decoupling [#1349] Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f0bb90b commit ece54b2

3 files changed

Lines changed: 34 additions & 8 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Changed
11+
- Decoupled offline-license anonymous access from the seat cap. [#1349](https://github.com/sourcebot-dev/sourcebot/pull/1349)
12+
1013
### Added
1114
- 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)
1215

packages/shared/src/entitlements.test.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,12 @@ const encodeOfflineKey = (payload: object): string => {
4040
const futureDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * 365).toISOString();
4141
const pastDate = new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString();
4242

43-
const validOfflineKey = (overrides: { seats?: number; expiryDate?: string } = {}) =>
43+
const validOfflineKey = (overrides: { seats?: number; anonymousAccess?: boolean; expiryDate?: string } = {}) =>
4444
encodeOfflineKey({
4545
id: 'test-customer',
4646
expiryDate: overrides.expiryDate ?? futureDate,
4747
...(overrides.seats !== undefined ? { seats: overrides.seats } : {}),
48+
...(overrides.anonymousAccess !== undefined ? { anonymousAccess: overrides.anonymousAccess } : {}),
4849
sig: 'fake-sig',
4950
});
5051

@@ -112,23 +113,35 @@ describe('isAnonymousAccessAvailable', () => {
112113
});
113114

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

120-
test('returns true when offline key has no seat count (unlimited)', () => {
121+
test('returns false when offline key is uncapped but does not grant anonymous access', () => {
122+
// Uncapped (no seats) no longer implies anonymous access — it must
123+
// be granted explicitly via the `anonymousAccess` flag.
121124
mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey();
125+
expect(isAnonymousAccessAvailable(null)).toBe(false);
126+
});
127+
128+
test('returns true when offline key explicitly grants anonymous access', () => {
129+
mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ anonymousAccess: true });
122130
expect(isAnonymousAccessAvailable(null)).toBe(true);
123131
});
124132

125-
test('unlimited offline key beats an active online license', () => {
126-
mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey();
133+
test('anonymous access is independent of the seat cap', () => {
134+
mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 100, anonymousAccess: true });
135+
expect(isAnonymousAccessAvailable(null)).toBe(true);
136+
});
137+
138+
test('anonymous-access offline key beats an active online license', () => {
139+
mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ anonymousAccess: true });
127140
expect(isAnonymousAccessAvailable(makeLicense({ status: 'active' }))).toBe(true);
128141
});
129142

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

146159
test('falls through when offline key signature is invalid', () => {
147-
mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 100 });
160+
mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ anonymousAccess: true });
148161
mocks.verifySignature.mockReturnValue(false);
149162
expect(isAnonymousAccessAvailable(null)).toBe(true);
150163
});

packages/shared/src/entitlements.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ const offlineLicensePrefix = "sourcebot_ee_";
1212
const offlineLicensePayloadSchema = z.object({
1313
id: z.string(),
1414
seats: z.number().optional(),
15+
// Whether anonymous (unauthenticated) access is permitted.
16+
anonymousAccess: z.boolean().optional(),
1517
// ISO 8601 date string
1618
expiryDate: z.string().datetime(),
1719
sig: z.string(),
@@ -50,7 +52,13 @@ const decodeOfflineLicenseKeyPayload = (payload: string): getValidOfflineLicense
5052
const payloadJson = JSON.parse(decodedPayload);
5153
const licenseData = offlineLicensePayloadSchema.parse(payloadJson);
5254

55+
// Keys are listed alphabetically to match the canonical JSON the
56+
// signer produces (Python `json.dumps(..., sort_keys=True)`).
57+
// `JSON.stringify` drops `undefined` values, so omitted optional
58+
// fields (e.g. a legacy key without `anonymousAccess`) verify exactly
59+
// as they were originally signed.
5360
const dataToVerify = JSON.stringify({
61+
anonymousAccess: licenseData.anonymousAccess,
5462
expiryDate: licenseData.expiryDate,
5563
id: licenseData.id,
5664
seats: licenseData.seats
@@ -138,7 +146,7 @@ export const isValidLicenseActive = (_license: License | null): boolean => {
138146
export const isAnonymousAccessAvailable = (_license: License | null): boolean => {
139147
const offlineKey = getValidOfflineLicense();
140148
if (offlineKey) {
141-
return offlineKey.seats === undefined;
149+
return offlineKey.anonymousAccess === true;
142150
}
143151

144152
const onlineLicense = getValidOnlineLicense(_license);
@@ -171,6 +179,7 @@ export const hasEntitlement = (entitlement: Entitlement, _license: License | nul
171179
export type OfflineLicenseMetadata = {
172180
id: string;
173181
seats?: number;
182+
anonymousAccess?: boolean;
174183
expiryDate: string;
175184
}
176185

@@ -186,6 +195,7 @@ export const getOfflineLicenseMetadata = (): OfflineLicenseMetadata | null => {
186195
return {
187196
id: license.id,
188197
seats: license.seats,
198+
anonymousAccess: license.anonymousAccess,
189199
expiryDate: license.expiryDate,
190200
};
191201
}

0 commit comments

Comments
 (0)