From d33f033fb4a7fe8b3887bda609bf8cf445b724c3 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Mon, 13 Jul 2026 22:17:35 -0700 Subject: [PATCH 1/3] fix: verify signed online license entitlements --- .../migration.sql | 2 + packages/db/prisma/schema.prisma | 3 + packages/shared/src/entitlements.test.ts | 76 ++++++++++++ packages/shared/src/entitlements.ts | 109 +++++++++++++++++- packages/shared/src/index.server.ts | 2 + .../components/banners/bannerResolver.test.ts | 1 + .../web/src/features/billing/servicePing.ts | 11 +- packages/web/src/features/billing/types.ts | 2 + 8 files changed, 202 insertions(+), 4 deletions(-) create mode 100644 packages/db/prisma/migrations/20260713000000_add_license_assertion/migration.sql diff --git a/packages/db/prisma/migrations/20260713000000_add_license_assertion/migration.sql b/packages/db/prisma/migrations/20260713000000_add_license_assertion/migration.sql new file mode 100644 index 000000000..e20e382da --- /dev/null +++ b/packages/db/prisma/migrations/20260713000000_add_license_assertion/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "License" ADD COLUMN "licenseAssertion" TEXT; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 0f72c97d0..9ff5ebaa0 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -375,6 +375,9 @@ model License { lastSyncAt DateTime? lastSyncErrorCode String? + /// Compact, signed online-license assertion returned by Lighthouse. + /// Authorization must be derived from this assertion when it is present. + licenseAssertion String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } diff --git a/packages/shared/src/entitlements.test.ts b/packages/shared/src/entitlements.test.ts index 89b1bcfbe..3b95f706e 100644 --- a/packages/shared/src/entitlements.test.ts +++ b/packages/shared/src/entitlements.test.ts @@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({ env: { SOURCEBOT_PUBLIC_KEY_PATH: '/tmp/test-key', SOURCEBOT_EE_LICENSE_KEY: undefined as string | undefined, + SOURCEBOT_INSTALL_ID: 'test-install', } as Record, verifySignature: vi.fn(() => true), })); @@ -78,11 +79,29 @@ const makeLicense = (overrides: Partial = {}): License => ({ yearlyPeakSeats: null, lastSyncAt: new Date(), lastSyncErrorCode: null, + licenseAssertion: null, createdAt: new Date(), updatedAt: new Date(), ...overrides, }); +const onlineAssertion = (overrides: Record = {}): string => { + const payload = { + version: 1, + audience: 'sourcebot-online-license', + licenseId: 'subscription-1', + installId: 'test-install', + status: 'active', + entitlements: ['sso'], + seats: 10, + issuedAt: new Date(Date.now() - 60 * 1000).toISOString(), + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + ...overrides, + }; + + return `${Buffer.from(JSON.stringify(payload)).toString('base64url')}.fake-signature`; +}; + beforeEach(() => { mocks.env.SOURCEBOT_EE_LICENSE_KEY = undefined; mocks.verifySignature.mockReturnValue(true); @@ -178,6 +197,63 @@ describe('getEntitlements', () => { expect(getEntitlements(makeLicense({ entitlements: ['sso'] }))).toEqual([]); }); + describe('signed online assertions', () => { + test('uses entitlements from a valid assertion instead of mutable columns', () => { + const license = makeLicense({ + status: 'active', + entitlements: ['audit'], + licenseAssertion: onlineAssertion({ entitlements: ['sso'] }), + }); + + expect(getEntitlements(license)).toEqual(['sso']); + }); + + test('does not fall back to mutable columns when the signature is invalid', () => { + mocks.verifySignature.mockReturnValue(false); + const license = makeLicense({ + status: 'active', + entitlements: ['audit'], + licenseAssertion: onlineAssertion(), + }); + + expect(getEntitlements(license)).toEqual([]); + }); + + test('rejects an assertion issued for another installation', () => { + const license = makeLicense({ + status: 'active', + entitlements: ['audit'], + licenseAssertion: onlineAssertion({ installId: 'different-install' }), + }); + + expect(getEntitlements(license)).toEqual([]); + }); + + test('rejects an expired assertion even when lastSyncAt was forged', () => { + const license = makeLicense({ + status: 'active', + entitlements: ['audit'], + lastSyncAt: new Date(), + licenseAssertion: onlineAssertion({ + issuedAt: new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(), + expiresAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), + }), + }); + + expect(getEntitlements(license)).toEqual([]); + }); + + test('rejects active mutable columns when the signed status is canceled', () => { + const license = makeLicense({ + status: 'active', + entitlements: ['audit'], + licenseAssertion: onlineAssertion({ status: 'canceled' }), + }); + + expect(getEntitlements(license)).toEqual([]); + }); + }); + test('returns all entitlements when offline key is valid', () => { mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 50 }); const result = getEntitlements(null); diff --git a/packages/shared/src/entitlements.ts b/packages/shared/src/entitlements.ts index 85982c147..61b8e11c3 100644 --- a/packages/shared/src/entitlements.ts +++ b/packages/shared/src/entitlements.ts @@ -27,6 +27,14 @@ const ACTIVE_ONLINE_LICENSE_STATUSES: LicenseStatus[] = [ 'past_due', ]; +const ONLINE_LICENSE_ASSERTION_AUDIENCE = 'sourcebot-online-license'; +const ONLINE_LICENSE_ASSERTION_CLOCK_SKEW_MS = 5 * 60 * 1000; + +// Compatibility switch for the first release that understands signed online +// licenses. Set this to false in the enforcement release, after Lighthouse has +// been returning assertions for at least one full online-license TTL. +const ALLOW_LEGACY_UNSIGNED_ONLINE_LICENSES = true; + // @WARNING: when adding a new entitlement to this list, make sure // lighthouse/lambda/entitlements.ts is also updated && deployed // prior to rolling a new Sourcebot version. @@ -47,6 +55,74 @@ const ALL_ENTITLEMENTS = [ ] as const; export type Entitlement = (typeof ALL_ENTITLEMENTS)[number]; +const onlineLicenseAssertionPayloadSchema = z.object({ + version: z.literal(1), + audience: z.literal(ONLINE_LICENSE_ASSERTION_AUDIENCE), + licenseId: z.string().min(1), + installId: z.string().min(1), + status: z.enum([ + 'active', + 'trialing', + 'past_due', + 'unpaid', + 'canceled', + 'incomplete', + 'incomplete_expired', + 'paused', + ]), + entitlements: z.array(z.enum(ALL_ENTITLEMENTS)), + seats: z.number().int().nonnegative(), + issuedAt: z.string().datetime(), + expiresAt: z.string().datetime(), +}).strict(); + +export type OnlineLicenseAssertionPayload = z.infer; + +/** + * Verifies and decodes an online-license assertion. The signature covers the + * encoded payload itself, avoiding cross-language JSON canonicalization. + */ +export const verifyOnlineLicenseAssertion = (assertion: string): OnlineLicenseAssertionPayload | null => { + try { + const parts = assertion.split('.'); + if (parts.length !== 2) { + return null; + } + + const [encodedPayload, signature] = parts; + if (!encodedPayload || !signature) { + return null; + } + + if (!verifySignature(encodedPayload, signature, env.SOURCEBOT_PUBLIC_KEY_PATH)) { + logger.error('Online license assertion signature verification failed'); + return null; + } + + const decodedPayload = Buffer.from(encodedPayload, 'base64url').toString('utf8'); + const payload = onlineLicenseAssertionPayloadSchema.parse(JSON.parse(decodedPayload)); + const issuedAt = new Date(payload.issuedAt).getTime(); + const expiresAt = new Date(payload.expiresAt).getTime(); + const now = Date.now(); + + if ( + payload.installId !== env.SOURCEBOT_INSTALL_ID || + issuedAt > now + ONLINE_LICENSE_ASSERTION_CLOCK_SKEW_MS || + expiresAt <= now || + expiresAt <= issuedAt || + (expiresAt - issuedAt) > STALE_ONLINE_LICENSE_THRESHOLD_MS + ) { + logger.error('Online license assertion claims are invalid'); + return null; + } + + return payload; + } catch (error) { + logger.error(`Failed to verify online license assertion: ${error}`); + return null; + } +}; + const decodeOfflineLicenseKeyPayload = (payload: string): getValidOfflineLicense | null => { try { const decodedPayload = base64Decode(payload); @@ -114,7 +190,9 @@ export const STALE_ONLINE_LICENSE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; // so the warning has a chance to fire before entitlements are stripped. export const STALE_ONLINE_LICENSE_WARNING_THRESHOLD_MS = 48 * 60 * 60 * 1000; -const getValidOnlineLicense = (_license: License | null): License | null => { +type ValidOnlineLicense = Pick; + +const getValidLegacyOnlineLicense = (_license: License | null): ValidOnlineLicense | null => { if ( _license && _license.status && @@ -123,7 +201,32 @@ const getValidOnlineLicense = (_license: License | null): License | null => { (Date.now() - _license.lastSyncAt.getTime()) <= STALE_ONLINE_LICENSE_THRESHOLD_MS && _license.lastSyncErrorCode !== 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE' ) { - return _license; + return { + entitlements: _license.entitlements as Entitlement[], + status: _license.status as LicenseStatus, + }; + } + + return null; +} + +const getValidOnlineLicense = (_license: License | null): ValidOnlineLicense | null => { + // A present but invalid assertion must never fall back to unsigned columns. + if (_license?.licenseAssertion !== null && _license?.licenseAssertion !== undefined) { + if (_license.lastSyncErrorCode === 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE') { + return null; + } + + const assertion = verifyOnlineLicenseAssertion(_license.licenseAssertion); + if (assertion && ACTIVE_ONLINE_LICENSE_STATUSES.includes(assertion.status)) { + return assertion; + } + + return null; + } + + if (ALLOW_LEGACY_UNSIGNED_ONLINE_LICENSES) { + return getValidLegacyOnlineLicense(_license); } return null; @@ -208,4 +311,4 @@ export const getSeatCap = (): number | undefined => { } return undefined; -} \ No newline at end of file +} diff --git a/packages/shared/src/index.server.ts b/packages/shared/src/index.server.ts index 80fe65ce8..9c267ef34 100644 --- a/packages/shared/src/index.server.ts +++ b/packages/shared/src/index.server.ts @@ -12,10 +12,12 @@ export { getOfflineLicenseMetadata, STALE_ONLINE_LICENSE_THRESHOLD_MS, STALE_ONLINE_LICENSE_WARNING_THRESHOLD_MS, + verifyOnlineLicenseAssertion, } from "./entitlements.js"; export type { Entitlement, OfflineLicenseMetadata, + OnlineLicenseAssertionPayload, } from "./entitlements.js"; export type { RepoMetadata, diff --git a/packages/web/src/app/(app)/components/banners/bannerResolver.test.ts b/packages/web/src/app/(app)/components/banners/bannerResolver.test.ts index 40bd558ed..399d3f19b 100644 --- a/packages/web/src/app/(app)/components/banners/bannerResolver.test.ts +++ b/packages/web/src/app/(app)/components/banners/bannerResolver.test.ts @@ -60,6 +60,7 @@ const makeLicense = (overrides: Partial = {}): License => ({ yearlyPeakSeats: null, lastSyncAt: NOW, lastSyncErrorCode: null, + licenseAssertion: null, createdAt: NOW, updatedAt: NOW, ...overrides, diff --git a/packages/web/src/features/billing/servicePing.ts b/packages/web/src/features/billing/servicePing.ts index 6a0290c90..cb9bddad3 100644 --- a/packages/web/src/features/billing/servicePing.ts +++ b/packages/web/src/features/billing/servicePing.ts @@ -7,7 +7,8 @@ import { decryptActivationCode, env, SOURCEBOT_VERSION, - isValidOfflineLicenseActive + isValidOfflineLicenseActive, + verifyOnlineLicenseAssertion, } from "@sourcebot/shared"; import { client } from "./client"; import { ServicePingRequest } from "./types"; @@ -127,6 +128,13 @@ export const syncWithLighthouse = async (orgId: number) => { // If we have a license and Lighthouse returned license data, sync it if (license && response.license) { + if (response.licenseAssertion && !verifyOnlineLicenseAssertion(response.licenseAssertion)) { + // Never persist an assertion we cannot authenticate. In particular, + // do not silently write only the legacy fields and create a + // signature-downgrade path. + throw new Error('Lighthouse returned an invalid online license assertion'); + } + const { entitlements, seats, @@ -174,6 +182,7 @@ export const syncWithLighthouse = async (orgId: number) => { yearlyPeakSeats: yearlyTermStatus?.peakSeats ?? null, lastSyncAt: new Date(), lastSyncErrorCode: null, + ...(response.licenseAssertion && { licenseAssertion: response.licenseAssertion }), }, }); diff --git a/packages/web/src/features/billing/types.ts b/packages/web/src/features/billing/types.ts index f97523fc3..770594210 100644 --- a/packages/web/src/features/billing/types.ts +++ b/packages/web/src/features/billing/types.ts @@ -102,6 +102,8 @@ export const servicePingResponseSchema = z.object({ hasPaymentMethod: z.boolean(), yearlyTermStatus: yearlyTermStatusSchema.optional(), }).optional(), + // Optional while older Lighthouse deployments are being upgraded. + licenseAssertion: z.string().optional(), }); export type ServicePingResponse = z.infer; From fe50da2b63e16b307a7210df39f256dbf8c50956 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Mon, 13 Jul 2026 22:18:45 -0700 Subject: [PATCH 2/3] chore: update changelog for #1442 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 429b650f3..1dca4d16d 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] +### Fixed +- [EE] Verified signed online license assertions before granting paid feature entitlements. [#1442](https://github.com/sourcebot-dev/sourcebot/pull/1442) + ## [5.1.0] - 2026-07-10 ### Changed From 6fdad4ffc0ada755631604bb6ac1341038bea226 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 14 Jul 2026 19:55:34 -0700 Subject: [PATCH 3/3] fix: verify signed online licenses (SOU-1465) --- packages/backend/package.json | 2 +- packages/db/prisma/schema.prisma | 5 +- packages/shared/package.json | 2 +- packages/shared/src/entitlements.test.ts | 254 +++++++++++++----- packages/shared/src/entitlements.ts | 95 +++---- packages/shared/src/index.client.ts | 3 +- packages/shared/src/index.server.ts | 2 +- .../src/lighthouseTypes.ts} | 86 +++--- .../settings/license/recentInvoicesCard.tsx | 3 +- .../src/app/(app)/settings/license/types.ts | 4 +- packages/web/src/app/api/(client)/client.ts | 2 +- .../web/src/ee/features/lighthouse/actions.ts | 2 +- packages/web/src/features/billing/CLAUDE.md | 8 +- packages/web/src/features/billing/client.ts | 2 +- .../features/billing/planComparisonTable.tsx | 2 +- .../web/src/features/billing/servicePing.ts | 30 ++- .../web/src/features/billing/systemInfo.ts | 3 +- .../web/src/features/billing/upsellDialog.tsx | 2 +- packages/web/src/initialize.test.ts | 80 ++++++ packages/web/src/initialize.ts | 32 +-- packages/web/src/instrumentation.ts | 3 +- yarn.lock | 11 +- 22 files changed, 412 insertions(+), 221 deletions(-) rename packages/{web/src/features/billing/types.ts => shared/src/lighthouseTypes.ts} (78%) create mode 100644 packages/web/src/initialize.test.ts diff --git a/packages/backend/package.json b/packages/backend/package.json index 13640cef7..7345ec2a2 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -54,6 +54,6 @@ "prom-client": "^15.1.3", "redlock": "5.0.0-beta.2", "simple-git": "^3.36.0", - "zod": "^3.25.74" + "zod": "^3.25.76" } } diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 9ff5ebaa0..1853ec5ce 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -373,11 +373,12 @@ model License { yearlyBillableOverageSeats Int? yearlyPeakSeats Int? - lastSyncAt DateTime? - lastSyncErrorCode String? /// Compact, signed online-license assertion returned by Lighthouse. /// Authorization must be derived from this assertion when it is present. licenseAssertion String? + + lastSyncAt DateTime? + lastSyncErrorCode String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } diff --git a/packages/shared/package.json b/packages/shared/package.json index 6ded4e24b..2a16111ff 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -23,7 +23,7 @@ "strip-json-comments": "^5.0.1", "triple-beam": "^1.4.1", "winston": "^3.15.0", - "zod": "^3.25.74" + "zod": "^3.25.76" }, "devDependencies": { "@types/micromatch": "^4.0.9", diff --git a/packages/shared/src/entitlements.test.ts b/packages/shared/src/entitlements.test.ts index 3b95f706e..a008c794f 100644 --- a/packages/shared/src/entitlements.test.ts +++ b/packages/shared/src/entitlements.test.ts @@ -31,6 +31,7 @@ import { isAnonymousAccessAvailable, getEntitlements, hasEntitlement, + verifyOnlineLicenseAssertion, } from './entitlements.js'; const encodeOfflineKey = (payload: object): string => { @@ -85,18 +86,36 @@ const makeLicense = (overrides: Partial = {}): License => ({ ...overrides, }); -const onlineAssertion = (overrides: Record = {}): string => { +const onlineAssertion = ( + overrides: { license?: Record; omitLicense?: boolean } & Record = {}, +): string => { + const { license: licenseOverrides, omitLicense, ...payloadOverrides } = overrides; const payload = { version: 1, audience: 'sourcebot-online-license', licenseId: 'subscription-1', installId: 'test-install', - status: 'active', - entitlements: ['sso'], - seats: 10, issuedAt: new Date(Date.now() - 60 * 1000).toISOString(), expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), - ...overrides, + ...(!omitLicense && { + license: { + status: 'active', + entitlements: ['sso'], + seats: 10, + planName: 'Enterprise', + unitAmount: 10000, + currency: 'usd', + interval: 'month', + intervalCount: 1, + nextRenewalAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), + nextRenewalAmount: 10000, + cancelAt: null, + trialEnd: null, + hasPaymentMethod: true, + ...licenseOverrides, + }, + }), + ...payloadOverrides, }; return `${Buffer.from(JSON.stringify(payload)).toString('base64url')}.fake-signature`; @@ -122,11 +141,13 @@ describe('isAnonymousAccessAvailable', () => { }); }); - describe('with an active online license', () => { + describe('with a signed active online license', () => { test.each(['active', 'trialing', 'past_due'] as const)( 'returns false when status is %s', (status) => { - expect(isAnonymousAccessAvailable(makeLicense({ status }))).toBe(false); + expect(isAnonymousAccessAvailable(makeLicense({ + licenseAssertion: onlineAssertion({ license: { status } }), + }))).toBe(false); } ); }); @@ -156,13 +177,17 @@ describe('isAnonymousAccessAvailable', () => { 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); + expect(isAnonymousAccessAvailable(makeLicense({ + licenseAssertion: onlineAssertion(), + }))).toBe(true); }); test('falls through to online license check when offline key is expired', () => { mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ anonymousAccess: true, expiryDate: pastDate }); expect(isAnonymousAccessAvailable(null)).toBe(true); - expect(isAnonymousAccessAvailable(makeLicense({ status: 'active' }))).toBe(false); + expect(isAnonymousAccessAvailable(makeLicense({ + licenseAssertion: onlineAssertion(), + }))).toBe(false); }); test('falls through when offline key is malformed', () => { @@ -188,9 +213,9 @@ describe('getEntitlements', () => { expect(getEntitlements(null)).toEqual([]); }); - test('returns license.entitlements when license is active', () => { + test('does not trust unsigned entitlements when license status is active', () => { const license = makeLicense({ status: 'active', entitlements: ['sso', 'audit'] }); - expect(getEntitlements(license)).toEqual(['sso', 'audit']); + expect(getEntitlements(license)).toEqual([]); }); test('returns empty when license has no status', () => { @@ -198,11 +223,137 @@ describe('getEntitlements', () => { }); describe('signed online assertions', () => { + describe('claim validation', () => { + test('rejects an unsupported version', () => { + expect(verifyOnlineLicenseAssertion(onlineAssertion({ version: 2 }))).toBeNull(); + }); + + test('rejects an assertion for another audience', () => { + expect(verifyOnlineLicenseAssertion(onlineAssertion({ audience: 'another-service' }))).toBeNull(); + }); + + test.each([ + ['missing', undefined], + ['empty', ''], + ])('rejects a %s licenseId', (_description, licenseId) => { + expect(verifyOnlineLicenseAssertion(onlineAssertion({ licenseId }))).toBeNull(); + }); + + test.each([ + ['missing', undefined], + ['empty', ''], + ['different', 'different-install'], + ])('rejects a %s installId', (_description, installId) => { + expect(verifyOnlineLicenseAssertion(onlineAssertion({ installId }))).toBeNull(); + }); + + test('rejects an invalid issuedAt timestamp', () => { + expect(verifyOnlineLicenseAssertion(onlineAssertion({ issuedAt: 'not-a-date' }))).toBeNull(); + }); + + test('rejects an assertion issued beyond the clock-skew allowance', () => { + expect(verifyOnlineLicenseAssertion(onlineAssertion({ + issuedAt: new Date(Date.now() + 6 * 60 * 1000).toISOString(), + }))).toBeNull(); + }); + + test('rejects an invalid expiresAt timestamp', () => { + expect(verifyOnlineLicenseAssertion(onlineAssertion({ expiresAt: 'not-a-date' }))).toBeNull(); + }); + + test('rejects an expired assertion', () => { + expect(verifyOnlineLicenseAssertion(onlineAssertion({ + issuedAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), + expiresAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + }))).toBeNull(); + }); + + test('rejects an assertion that does not expire after it was issued', () => { + const timestamp = new Date(Date.now() + 60 * 1000).toISOString(); + expect(verifyOnlineLicenseAssertion(onlineAssertion({ + issuedAt: timestamp, + expiresAt: timestamp, + }))).toBeNull(); + }); + + test('rejects an assertion valid for longer than seven days', () => { + const issuedAt = new Date(); + expect(verifyOnlineLicenseAssertion(onlineAssertion({ + issuedAt: issuedAt.toISOString(), + expiresAt: new Date(issuedAt.getTime() + 7 * 24 * 60 * 60 * 1000 + 1).toISOString(), + }))).toBeNull(); + }); + + test('rejects a missing license snapshot', () => { + expect(verifyOnlineLicenseAssertion(onlineAssertion({ omitLicense: true }))).toBeNull(); + }); + + test.each([ + ['entitlements', [1]], + ['seats', -1], + ['status', null], + ['planName', null], + ['unitAmount', 1.5], + ['currency', null], + ['interval', null], + ['intervalCount', 1.5], + ['nextRenewalAt', 'not-a-date'], + ['nextRenewalAmount', 1.5], + ['cancelAt', 'not-a-date'], + ['trialEnd', 'not-a-date'], + ['hasPaymentMethod', 'yes'], + ['yearlyTermStatus', {}], + ])('rejects an invalid license.%s claim', (field, value) => { + expect(verifyOnlineLicenseAssertion(onlineAssertion({ + license: { [field]: value }, + }))).toBeNull(); + }); + }); + test('uses entitlements from a valid assertion instead of mutable columns', () => { const license = makeLicense({ status: 'active', entitlements: ['audit'], - licenseAssertion: onlineAssertion({ entitlements: ['sso'] }), + licenseAssertion: onlineAssertion({ license: { entitlements: ['sso'] } }), + }); + + expect(getEntitlements(license)).toEqual(['sso']); + }); + + test('preserves the complete signed license snapshot', () => { + const assertion = onlineAssertion({ + license: { + planName: 'Signed Enterprise', + unitAmount: 25000, + nextRenewalAmount: 50000, + hasPaymentMethod: false, + }, + }); + + expect(verifyOnlineLicenseAssertion(assertion)?.license).toMatchObject({ + entitlements: ['sso'], + seats: 10, + status: 'active', + planName: 'Signed Enterprise', + unitAmount: 25000, + currency: 'usd', + interval: 'month', + intervalCount: 1, + nextRenewalAmount: 50000, + cancelAt: null, + trialEnd: null, + hasPaymentMethod: false, + }); + }); + + test('ignores unknown future entitlements while preserving known entitlements', () => { + const license = makeLicense({ + status: 'active', + licenseAssertion: onlineAssertion({ + license: { + entitlements: ['sso', 'future-entitlement'], + }, + }), }); expect(getEntitlements(license)).toEqual(['sso']); @@ -247,9 +398,23 @@ describe('getEntitlements', () => { const license = makeLicense({ status: 'active', entitlements: ['audit'], - licenseAssertion: onlineAssertion({ status: 'canceled' }), + licenseAssertion: onlineAssertion({ license: { status: 'canceled' } }), + }); + + expect(getEntitlements(license)).toEqual([]); + }); + + test('parses an unknown future status but grants no entitlements', () => { + const licenseAssertion = onlineAssertion({ + license: { status: 'future-status' }, + }); + const license = makeLicense({ + status: 'active', + entitlements: ['sso'], + licenseAssertion, }); + expect(verifyOnlineLicenseAssertion(licenseAssertion)?.license.status).toBe('future-status'); expect(getEntitlements(license)).toEqual([]); }); }); @@ -262,12 +427,12 @@ describe('getEntitlements', () => { expect(result).toContain('search-contexts'); }); - test('falls through when offline key is expired', () => { + test('does not fall through to unsigned columns when an offline key is expired', () => { mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 50, expiryDate: pastDate }); expect(getEntitlements(null)).toEqual([]); expect( getEntitlements(makeLicense({ status: 'active', entitlements: ['sso'] })) - ).toEqual(['sso']); + ).toEqual([]); }); test('falls through when offline key is malformed', () => { @@ -275,53 +440,10 @@ describe('getEntitlements', () => { expect(getEntitlements(null)).toEqual([]); }); - describe('online license staleness', () => { - const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; - - test('returns entitlements when lastSyncAt is recent', () => { - const license = makeLicense({ - status: 'active', - entitlements: ['sso'], - lastSyncAt: new Date(Date.now() - 24 * 60 * 60 * 1000), // 1 day ago - }); - expect(getEntitlements(license)).toEqual(['sso']); - }); - - test('returns empty when lastSyncAt is past the stale threshold', () => { - const license = makeLicense({ - status: 'active', - entitlements: ['sso'], - lastSyncAt: new Date(Date.now() - STALE_THRESHOLD_MS - 60 * 1000), // 7d + 1min - }); - expect(getEntitlements(license)).toEqual([]); - }); - - test('returns empty when lastSyncAt is null', () => { - const license = makeLicense({ - status: 'active', - entitlements: ['sso'], - lastSyncAt: null, - }); - expect(getEntitlements(license)).toEqual([]); - }); - - test('returns entitlements at the threshold boundary', () => { - // Exactly at the threshold should still be treated as valid (<=). - const license = makeLicense({ - status: 'active', - entitlements: ['sso'], - lastSyncAt: new Date(Date.now() - STALE_THRESHOLD_MS + 1000), - }); - expect(getEntitlements(license)).toEqual(['sso']); - }); - }); - describe('online license rebound elsewhere', () => { test('returns empty when lastSyncErrorCode is ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE', () => { const license = makeLicense({ - status: 'active', - entitlements: ['sso'], - lastSyncAt: new Date(), + licenseAssertion: onlineAssertion(), lastSyncErrorCode: 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE', }); expect(getEntitlements(license)).toEqual([]); @@ -333,9 +455,7 @@ describe('getEntitlements', () => { // don't strip entitlements (avoids paging operators on transient // upstream issues). const license = makeLicense({ - status: 'active', - entitlements: ['sso'], - lastSyncAt: new Date(), + licenseAssertion: onlineAssertion(), lastSyncErrorCode: 'UNKNOWN_STRIPE_PRODUCT', }); expect(getEntitlements(license)).toEqual(['sso']); @@ -346,7 +466,7 @@ describe('getEntitlements', () => { // should not affect them. mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey(); const license = makeLicense({ - status: 'active', + licenseAssertion: onlineAssertion(), lastSyncErrorCode: 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE', }); expect(getEntitlements(license).length).toBeGreaterThan(0); @@ -355,15 +475,15 @@ describe('getEntitlements', () => { }); describe('hasEntitlement', () => { - test('returns true when entitlement is present in license', () => { + test('returns true when entitlement is present in a signed assertion', () => { expect( - hasEntitlement('sso', makeLicense({ status: 'active', entitlements: ['sso'] })) + hasEntitlement('sso', makeLicense({ licenseAssertion: onlineAssertion() })) ).toBe(true); }); - test('returns false when entitlement is absent from license', () => { + test('returns false when entitlement is absent from a signed assertion', () => { expect( - hasEntitlement('audit', makeLicense({ status: 'active', entitlements: ['sso'] })) + hasEntitlement('audit', makeLicense({ licenseAssertion: onlineAssertion() })) ).toBe(false); }); diff --git a/packages/shared/src/entitlements.ts b/packages/shared/src/entitlements.ts index 61b8e11c3..039394653 100644 --- a/packages/shared/src/entitlements.ts +++ b/packages/shared/src/entitlements.ts @@ -5,6 +5,10 @@ import { env } from "./env.server.js"; import { verifySignature } from "./crypto.js"; import { License } from "@sourcebot/db"; import { LicenseStatus } from "./types.js"; +import { + onlineLicenseAssertionClaimsSchema, + type OnlineLicenseAssertionClaims, +} from './lighthouseTypes.js'; const logger = createLogger('entitlements'); @@ -27,13 +31,10 @@ const ACTIVE_ONLINE_LICENSE_STATUSES: LicenseStatus[] = [ 'past_due', ]; -const ONLINE_LICENSE_ASSERTION_AUDIENCE = 'sourcebot-online-license'; -const ONLINE_LICENSE_ASSERTION_CLOCK_SKEW_MS = 5 * 60 * 1000; +const isActiveOnlineLicenseStatus = (status: string): status is LicenseStatus => + ACTIVE_ONLINE_LICENSE_STATUSES.includes(status as LicenseStatus); -// Compatibility switch for the first release that understands signed online -// licenses. Set this to false in the enforcement release, after Lighthouse has -// been returning assertions for at least one full online-license TTL. -const ALLOW_LEGACY_UNSIGNED_ONLINE_LICENSES = true; +const ONLINE_LICENSE_ASSERTION_CLOCK_SKEW_MS = 5 * 60 * 1000; // @WARNING: when adding a new entitlement to this list, make sure // lighthouse/lambda/entitlements.ts is also updated && deployed @@ -55,34 +56,14 @@ const ALL_ENTITLEMENTS = [ ] as const; export type Entitlement = (typeof ALL_ENTITLEMENTS)[number]; -const onlineLicenseAssertionPayloadSchema = z.object({ - version: z.literal(1), - audience: z.literal(ONLINE_LICENSE_ASSERTION_AUDIENCE), - licenseId: z.string().min(1), - installId: z.string().min(1), - status: z.enum([ - 'active', - 'trialing', - 'past_due', - 'unpaid', - 'canceled', - 'incomplete', - 'incomplete_expired', - 'paused', - ]), - entitlements: z.array(z.enum(ALL_ENTITLEMENTS)), - seats: z.number().int().nonnegative(), - issuedAt: z.string().datetime(), - expiresAt: z.string().datetime(), -}).strict(); - -export type OnlineLicenseAssertionPayload = z.infer; +const isKnownEntitlement = (entitlement: string): entitlement is Entitlement => + (ALL_ENTITLEMENTS as readonly string[]).includes(entitlement); /** * Verifies and decodes an online-license assertion. The signature covers the * encoded payload itself, avoiding cross-language JSON canonicalization. */ -export const verifyOnlineLicenseAssertion = (assertion: string): OnlineLicenseAssertionPayload | null => { +export const verifyOnlineLicenseAssertion = (assertion: string): OnlineLicenseAssertionClaims | null => { try { const parts = assertion.split('.'); if (parts.length !== 2) { @@ -100,16 +81,24 @@ export const verifyOnlineLicenseAssertion = (assertion: string): OnlineLicenseAs } const decodedPayload = Buffer.from(encodedPayload, 'base64url').toString('utf8'); - const payload = onlineLicenseAssertionPayloadSchema.parse(JSON.parse(decodedPayload)); + const payload = onlineLicenseAssertionClaimsSchema.parse(JSON.parse(decodedPayload)); const issuedAt = new Date(payload.issuedAt).getTime(); const expiresAt = new Date(payload.expiresAt).getTime(); const now = Date.now(); + // A license is considered invalid under the following + // circumstances: if ( + // 1. It was issued for a different Sourcebot installation. payload.installId !== env.SOURCEBOT_INSTALL_ID || + // 2. It claims to have been issued too far in the future. A small + // clock-skew allowance accounts for clocks being slightly out of sync. issuedAt > now + ONLINE_LICENSE_ASSERTION_CLOCK_SKEW_MS || + // 3. Its expiration time has already passed. expiresAt <= now || + // 4. It expires at or before the time it claims to have been issued. expiresAt <= issuedAt || + // 5. Its total validity period exceeds the maximum allowed lifetime. (expiresAt - issuedAt) > STALE_ONLINE_LICENSE_THRESHOLD_MS ) { logger.error('Online license assertion claims are invalid'); @@ -190,43 +179,27 @@ export const STALE_ONLINE_LICENSE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; // so the warning has a chance to fire before entitlements are stripped. export const STALE_ONLINE_LICENSE_WARNING_THRESHOLD_MS = 48 * 60 * 60 * 1000; -type ValidOnlineLicense = Pick; - -const getValidLegacyOnlineLicense = (_license: License | null): ValidOnlineLicense | null => { - if ( - _license && - _license.status && - ACTIVE_ONLINE_LICENSE_STATUSES.includes(_license.status as LicenseStatus) && - _license.lastSyncAt && - (Date.now() - _license.lastSyncAt.getTime()) <= STALE_ONLINE_LICENSE_THRESHOLD_MS && - _license.lastSyncErrorCode !== 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE' - ) { - return { - entitlements: _license.entitlements as Entitlement[], - status: _license.status as LicenseStatus, - }; - } - - return null; -} +type ValidOnlineLicense = { + entitlements: Entitlement[]; + status: LicenseStatus; +}; const getValidOnlineLicense = (_license: License | null): ValidOnlineLicense | null => { - // A present but invalid assertion must never fall back to unsigned columns. - if (_license?.licenseAssertion !== null && _license?.licenseAssertion !== undefined) { - if (_license.lastSyncErrorCode === 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE') { - return null; - } - - const assertion = verifyOnlineLicenseAssertion(_license.licenseAssertion); - if (assertion && ACTIVE_ONLINE_LICENSE_STATUSES.includes(assertion.status)) { - return assertion; - } + // Unsigned database columns never grant online entitlements. + if (!_license?.licenseAssertion) { + return null; + } + if (_license.lastSyncErrorCode === 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE') { return null; } - if (ALLOW_LEGACY_UNSIGNED_ONLINE_LICENSES) { - return getValidLegacyOnlineLicense(_license); + const assertion = verifyOnlineLicenseAssertion(_license.licenseAssertion); + if (assertion && isActiveOnlineLicenseStatus(assertion.license.status)) { + return { + entitlements: assertion.license.entitlements.filter(isKnownEntitlement), + status: assertion.license.status, + }; } return null; diff --git a/packages/shared/src/index.client.ts b/packages/shared/src/index.client.ts index 05a248168..ea6c82778 100644 --- a/packages/shared/src/index.client.ts +++ b/packages/shared/src/index.client.ts @@ -10,4 +10,5 @@ export { formatVersion, compareVersions, } from "./versionUtils.js"; -export type { Version } from "./versionUtils.js"; \ No newline at end of file +export type { Version } from "./versionUtils.js"; +export * from './lighthouseTypes.js'; diff --git a/packages/shared/src/index.server.ts b/packages/shared/src/index.server.ts index 9c267ef34..6c1d8d723 100644 --- a/packages/shared/src/index.server.ts +++ b/packages/shared/src/index.server.ts @@ -17,8 +17,8 @@ export { export type { Entitlement, OfflineLicenseMetadata, - OnlineLicenseAssertionPayload, } from "./entitlements.js"; +export * from './lighthouseTypes.js'; export type { RepoMetadata, RepoIndexingJobMetadata, diff --git a/packages/web/src/features/billing/types.ts b/packages/shared/src/lighthouseTypes.ts similarity index 78% rename from packages/web/src/features/billing/types.ts rename to packages/shared/src/lighthouseTypes.ts index 770594210..8dcaf9c62 100644 --- a/packages/web/src/features/billing/types.ts +++ b/packages/shared/src/lighthouseTypes.ts @@ -1,4 +1,53 @@ -import { z } from "zod"; +import { z } from 'zod'; + +// @note: keep these schemas in sync with lighthouse/lambda/routes/*.ts. +export const ONLINE_LICENSE_ASSERTION_AUDIENCE = 'sourcebot-online-license'; + +export const yearlyTermStatusSchema = z.object({ + termStartedAt: z.string().datetime(), + termEndsAt: z.string().datetime(), + totalQuartersInTerm: z.number().int(), + currentQuarterNumber: z.number().int(), + currentQuarterStartedAt: z.string().datetime(), + currentQuarterEndsAt: z.string().datetime(), + committedSeats: z.number().int(), + overageSeats: z.number().int(), + billableOverageSeats: z.number().int(), + peakSeats: z.number().int(), +}); +export type YearlyTermStatus = z.infer; + +export const onlineLicenseSnapshotSchema = z.object({ + entitlements: z.array(z.string()), + seats: z.number().int().nonnegative(), + status: z.string(), + planName: z.string(), + unitAmount: z.number().int(), + currency: z.string(), + interval: z.string(), + intervalCount: z.number().int(), + nextRenewalAt: z.string().datetime().nullable(), + nextRenewalAmount: z.number().int().nullable(), + cancelAt: z.string().datetime().nullable(), + trialEnd: z.string().datetime().nullable(), + hasPaymentMethod: z.boolean(), + + // Yearly-only billing state. Absent for non-yearly subscriptions or when + // reconciliation state has not yet been initialized. + yearlyTermStatus: yearlyTermStatusSchema.optional(), +}); +export type OnlineLicenseSnapshot = z.infer; + +export const onlineLicenseAssertionClaimsSchema = z.object({ + version: z.literal(1), + audience: z.literal(ONLINE_LICENSE_ASSERTION_AUDIENCE), + licenseId: z.string().min(1), + installId: z.string().min(1), + issuedAt: z.string().datetime(), + expiresAt: z.string().datetime(), + license: onlineLicenseSnapshotSchema, +}); +export type OnlineLicenseAssertionClaims = z.infer; /** * A best-effort snapshot of the host / container resources the deployment is @@ -43,7 +92,7 @@ export const servicePingRequestSchema = z.object({ isTelemetryEnabled: z.boolean(), isLanguageModelConfigured: z.boolean(), activationCode: z.string().optional(), - // optional for back-compat with Lighthouse deployments that predate it. + // Optional for back-compat with Lighthouse deployments that predate it. systemInfo: systemInfoSchema.optional(), }); export type ServicePingRequest = z.infer; @@ -71,38 +120,9 @@ export const claimActivationCodeResponseSchema = z.object({ }); export type ClaimActivationCodeResponse = z.infer; -export const yearlyTermStatusSchema = z.object({ - termStartedAt: z.string().datetime(), - termEndsAt: z.string().datetime(), - totalQuartersInTerm: z.number().int(), - currentQuarterNumber: z.number().int(), - currentQuarterStartedAt: z.string().datetime(), - currentQuarterEndsAt: z.string().datetime(), - committedSeats: z.number().int(), - overageSeats: z.number().int(), - billableOverageSeats: z.number().int(), - peakSeats: z.number().int(), -}); -export type YearlyTermStatus = z.infer; - export const servicePingResponseSchema = z.object({ - license: z.object({ - entitlements: z.string().array(), - seats: z.number(), - status: z.string(), - planName: z.string(), - unitAmount: z.number().int(), - currency: z.string(), - interval: z.string(), - intervalCount: z.number().int(), - nextRenewalAt: z.string().datetime().nullable(), - nextRenewalAmount: z.number().int().nullable(), - cancelAt: z.string().datetime().nullable(), - trialEnd: z.string().datetime().nullable(), - hasPaymentMethod: z.boolean(), - yearlyTermStatus: yearlyTermStatusSchema.optional(), - }).optional(), - // Optional while older Lighthouse deployments are being upgraded. + status: z.literal('ok'), + license: onlineLicenseSnapshotSchema.optional(), licenseAssertion: z.string().optional(), }); export type ServicePingResponse = z.infer; diff --git a/packages/web/src/app/(app)/settings/license/recentInvoicesCard.tsx b/packages/web/src/app/(app)/settings/license/recentInvoicesCard.tsx index 68906501e..448339b85 100644 --- a/packages/web/src/app/(app)/settings/license/recentInvoicesCard.tsx +++ b/packages/web/src/app/(app)/settings/license/recentInvoicesCard.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { ExternalLink } from "lucide-react"; -import { Invoice } from "@/features/billing/types"; +import type { Invoice } from "@sourcebot/shared/client"; import { SettingsCard, SettingsCardGroup } from "../components/settingsCard"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -90,4 +90,3 @@ function formatDate(isoDate: string): string { year: 'numeric', }); } - diff --git a/packages/web/src/app/(app)/settings/license/types.ts b/packages/web/src/app/(app)/settings/license/types.ts index d7660d40a..54c0a1e42 100644 --- a/packages/web/src/app/(app)/settings/license/types.ts +++ b/packages/web/src/app/(app)/settings/license/types.ts @@ -1,4 +1,4 @@ -import { YearlyTermStatus as RawYearlyTermStatus } from "@/features/billing/types"; +import type { YearlyTermStatus as RawYearlyTermStatus } from "@sourcebot/shared/client"; import { License } from "@sourcebot/db"; export type YearlyTermStatus = Omit & { @@ -38,4 +38,4 @@ export function getYearlyTermStatus( termStartedAt: license.yearlyTermStartedAt, termEndsAt: license.yearlyTermEndsAt, }; -} \ No newline at end of file +} diff --git a/packages/web/src/app/api/(client)/client.ts b/packages/web/src/app/api/(client)/client.ts index 8077446b5..f2bc54b4c 100644 --- a/packages/web/src/app/api/(client)/client.ts +++ b/packages/web/src/app/api/(client)/client.ts @@ -36,7 +36,7 @@ import type { SearchChatShareableMembersQueryParams, SearchChatShareableMembersResponse, } from "../(server)/ee/chat/[chatId]/searchMembers/route"; -import { OffersResponse } from "@/features/billing/types"; +import type { OffersResponse } from "@sourcebot/shared/client"; import { ConnectMcpResponse } from "../(server)/ee/askmcp/connect/types"; import type { GetMcpServersResponse } from "../(server)/ee/askmcp/servers/route"; import type { GetMcpConfigurationResponse, GetMcpServerToolPermissionsResponse, GetMcpToolsResponse } from "@/ee/features/chat/mcp/types"; diff --git a/packages/web/src/ee/features/lighthouse/actions.ts b/packages/web/src/ee/features/lighthouse/actions.ts index df1b98c90..a266da6d1 100644 --- a/packages/web/src/ee/features/lighthouse/actions.ts +++ b/packages/web/src/ee/features/lighthouse/actions.ts @@ -11,7 +11,7 @@ import { decryptActivationCode, env } from "@sourcebot/shared"; import { syncWithLighthouse } from "@/features/billing/servicePing"; import { isServiceError } from "@/lib/utils"; import { client } from "@/features/billing/client"; -import { Invoice } from "@/features/billing/types"; +import type { Invoice } from "@sourcebot/shared"; export const refreshLicense = async (): Promise<{ success: boolean } | ServiceError> => sew(() => withAuth(async ({ org, role, prisma }) => diff --git a/packages/web/src/features/billing/CLAUDE.md b/packages/web/src/features/billing/CLAUDE.md index 63b09da56..75451726e 100644 --- a/packages/web/src/features/billing/CLAUDE.md +++ b/packages/web/src/features/billing/CLAUDE.md @@ -2,15 +2,15 @@ ## Keeping types in sync with the lighthouse service -The Zod schemas in `types.ts` mirror the request/response schemas defined in the lighthouse service (`sourcebot-dev/lighthouse`, under `lambda/routes/`). They MUST stay in lockstep. +The Zod schemas in `packages/shared/src/lighthouseTypes.ts` mirror the request/response schemas defined in the lighthouse service (`sourcebot-dev/lighthouse`, under `lambda/routes/`). They MUST stay in lockstep. -Whenever you change a schema in `types.ts`, you MUST also update the corresponding schema in: +Whenever you change a schema in `packages/shared/src/lighthouseTypes.ts`, you MUST also update the corresponding schema in: ``` lighthouse: lambda/routes/.ts ``` -Conversely, if a schema changes in the lighthouse service, update `types.ts` here to match. +Conversely, if a schema changes in the lighthouse service, update `packages/shared/src/lighthouseTypes.ts` to match. This applies to: - Adding, removing, or renaming fields on any `*RequestSchema` / `*ResponseSchema`. @@ -19,7 +19,7 @@ This applies to: ## Keeping the Service Ping docs in sync -Whenever you change the `servicePingRequestSchema` (the `/ping` request payload) in `types.ts`, you MUST also update the user-facing Service Ping documentation: +Whenever you change the `servicePingRequestSchema` (the `/ping` request payload) in `packages/shared/src/lighthouseTypes.ts`, you MUST also update the user-facing Service Ping documentation: ``` docs/docs/misc/service-ping.mdx diff --git a/packages/web/src/features/billing/client.ts b/packages/web/src/features/billing/client.ts index c87415e6c..e5a12ca9c 100644 --- a/packages/web/src/features/billing/client.ts +++ b/packages/web/src/features/billing/client.ts @@ -22,7 +22,7 @@ import { ServicePingRequest, ServicePingResponse, servicePingResponseSchema, -} from "./types"; +} from "@sourcebot/shared/client"; import { lighthouseUnreachable, ServiceError } from "@/lib/serviceError"; import { ErrorCode } from "@/lib/errorCodes"; import { StatusCodes } from "http-status-codes"; diff --git a/packages/web/src/features/billing/planComparisonTable.tsx b/packages/web/src/features/billing/planComparisonTable.tsx index d26bb1758..1fea335d3 100644 --- a/packages/web/src/features/billing/planComparisonTable.tsx +++ b/packages/web/src/features/billing/planComparisonTable.tsx @@ -11,7 +11,7 @@ import { TableRow, } from "@/components/ui/table"; import { cn, formatCurrency } from "@/lib/utils"; -import { OffersResponse } from "@/features/billing/types"; +import type { OffersResponse } from "@sourcebot/shared/client"; interface FeatureLinkProps { text: string; diff --git a/packages/web/src/features/billing/servicePing.ts b/packages/web/src/features/billing/servicePing.ts index cb9bddad3..1e637d81e 100644 --- a/packages/web/src/features/billing/servicePing.ts +++ b/packages/web/src/features/billing/servicePing.ts @@ -9,9 +9,9 @@ import { SOURCEBOT_VERSION, isValidOfflineLicenseActive, verifyOnlineLicenseAssertion, + type ServicePingRequest, } from "@sourcebot/shared"; import { client } from "./client"; -import { ServicePingRequest } from "./types"; import { ServiceErrorException } from "@/lib/serviceError"; import { getConfiguredLanguageModels } from "@/features/chat/utils.server"; import { activeMembershipWhere } from "@/features/membership/utils"; @@ -126,15 +126,24 @@ export const syncWithLighthouse = async (orgId: number) => { logger.info(`Service ping sent successfully`); + const licenseAssertion = response.licenseAssertion; + const verifiedAssertion = licenseAssertion !== undefined + ? verifyOnlineLicenseAssertion(licenseAssertion) + : null; + + if (licenseAssertion !== undefined && !verifiedAssertion) { + // Never persist an assertion we cannot authenticate. In particular, + // do not silently write only the legacy fields and create a + // signature-downgrade path. + throw new Error('Lighthouse returned an invalid online license assertion'); + } + + if (licenseAssertion !== undefined && !response.license) { + throw new Error('Lighthouse returned an online license assertion without license data'); + } + // If we have a license and Lighthouse returned license data, sync it if (license && response.license) { - if (response.licenseAssertion && !verifyOnlineLicenseAssertion(response.licenseAssertion)) { - // Never persist an assertion we cannot authenticate. In particular, - // do not silently write only the legacy fields and create a - // signature-downgrade path. - throw new Error('Lighthouse returned an invalid online license assertion'); - } - const { entitlements, seats, @@ -150,7 +159,7 @@ export const syncWithLighthouse = async (orgId: number) => { trialEnd, hasPaymentMethod, yearlyTermStatus, - } = response.license; + } = verifiedAssertion?.license ?? response.license; await __unsafePrisma.license.update({ where: { @@ -182,7 +191,7 @@ export const syncWithLighthouse = async (orgId: number) => { yearlyPeakSeats: yearlyTermStatus?.peakSeats ?? null, lastSyncAt: new Date(), lastSyncErrorCode: null, - ...(response.licenseAssertion && { licenseAssertion: response.licenseAssertion }), + ...(licenseAssertion !== undefined && { licenseAssertion }), }, }); @@ -191,7 +200,6 @@ export const syncWithLighthouse = async (orgId: number) => { }; export const startServicePingCronJob = () => { - syncWithLighthouse(SINGLE_TENANT_ORG_ID).catch(() => { /* ignore error */ }) setInterval( () => syncWithLighthouse(SINGLE_TENANT_ORG_ID).catch(() => { /* ignore error */ }), SERVICE_PING_INTERVAL_MS diff --git a/packages/web/src/features/billing/systemInfo.ts b/packages/web/src/features/billing/systemInfo.ts index ca9051401..b72e3fd55 100644 --- a/packages/web/src/features/billing/systemInfo.ts +++ b/packages/web/src/features/billing/systemInfo.ts @@ -1,7 +1,6 @@ import os from "node:os"; import fs from "node:fs/promises"; -import { env, createLogger } from "@sourcebot/shared"; -import { SystemInfo } from "./types"; +import { env, createLogger, type SystemInfo } from "@sourcebot/shared"; const logger = createLogger('system-info'); diff --git a/packages/web/src/features/billing/upsellDialog.tsx b/packages/web/src/features/billing/upsellDialog.tsx index 2e523ddfd..9cb676a81 100644 --- a/packages/web/src/features/billing/upsellDialog.tsx +++ b/packages/web/src/features/billing/upsellDialog.tsx @@ -14,7 +14,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { createCheckoutSession } from "@/features/billing/actions"; import { useHasLicense } from "@/features/billing/hasLicenseProvider"; import { BillingInterval, PlanComparisonTable } from "@/features/billing/planComparisonTable"; -import { OffersResponse } from "@/features/billing/types"; +import type { OffersResponse } from "@sourcebot/shared/client"; import { useOffers } from "@/features/billing/useOffers"; import { useRole } from "@/features/auth/useRole"; import useCaptureEvent from "@/hooks/useCaptureEvent"; diff --git a/packages/web/src/initialize.test.ts b/packages/web/src/initialize.test.ts new file mode 100644 index 000000000..1b839ba41 --- /dev/null +++ b/packages/web/src/initialize.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + captureException: vi.fn(), + logger: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }, + startChangelogPollingJob: vi.fn(), + startServicePingCronJob: vi.fn(), + syncWithLighthouse: vi.fn(), + warmModelCapabilitiesCatalog: vi.fn(), +})); + +vi.mock('@/prisma', () => ({ + __unsafePrisma: {}, +})); + +vi.mock('@/features/billing/servicePing', () => ({ + startServicePingCronJob: mocks.startServicePingCronJob, + syncWithLighthouse: mocks.syncWithLighthouse, +})); + +vi.mock('@/features/changelog/pollChangelog', () => ({ + startChangelogPollingJob: mocks.startChangelogPollingJob, +})); + +vi.mock('@sourcebot/shared', () => ({ + createLogger: () => mocks.logger, + env: {}, +})); + +vi.mock('./lib/constants', () => ({ + SINGLE_TENANT_ORG_ID: 1, +})); + +vi.mock('@/features/chat/utils.server', () => ({ + warmModelCapabilitiesCatalog: mocks.warmModelCapabilitiesCatalog, +})); + +vi.mock('@sentry/nextjs', () => ({ + captureException: mocks.captureException, +})); + +import { initialize } from './initialize'; + +describe('initialize', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.syncWithLighthouse.mockResolvedValue(undefined); + }); + + test('refreshes the license before starting background jobs', async () => { + await initialize(); + + expect(mocks.syncWithLighthouse).toHaveBeenCalledWith(1); + expect(mocks.syncWithLighthouse.mock.invocationCallOrder[0]) + .toBeLessThan(mocks.startServicePingCronJob.mock.invocationCallOrder[0]!); + expect(mocks.startServicePingCronJob).toHaveBeenCalledOnce(); + expect(mocks.startChangelogPollingJob).toHaveBeenCalledOnce(); + expect(mocks.warmModelCapabilitiesCatalog).toHaveBeenCalledOnce(); + }); + + test('logs and reports a failed license refresh without blocking startup', async () => { + const error = new Error('Lighthouse unavailable'); + mocks.syncWithLighthouse.mockRejectedValue(error); + + await expect(initialize()).resolves.toBeUndefined(); + + expect(mocks.logger.error).toHaveBeenCalledWith( + 'Startup Lighthouse sync failed: Lighthouse unavailable', + ); + expect(mocks.captureException).toHaveBeenCalledWith(error); + expect(mocks.startServicePingCronJob).toHaveBeenCalledOnce(); + expect(mocks.startChangelogPollingJob).toHaveBeenCalledOnce(); + expect(mocks.warmModelCapabilitiesCatalog).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/web/src/initialize.ts b/packages/web/src/initialize.ts index 406116dee..95acd33be 100644 --- a/packages/web/src/initialize.ts +++ b/packages/web/src/initialize.ts @@ -1,26 +1,14 @@ import { __unsafePrisma } from "@/prisma"; -import { startServicePingCronJob } from '@/features/billing/servicePing'; +import { startServicePingCronJob, syncWithLighthouse } from '@/features/billing/servicePing'; import { startChangelogPollingJob } from '@/features/changelog/pollChangelog'; import { createLogger, env } from "@sourcebot/shared"; -import { hasEntitlement } from '@/lib/entitlements'; import { SINGLE_TENANT_ORG_ID } from './lib/constants'; import { warmModelCapabilitiesCatalog } from '@/features/chat/utils.server'; +import * as Sentry from '@sentry/nextjs'; const logger = createLogger('web-initialize'); -const init = async () => { - // If we don't have the search context entitlement then wipe any existing - // search contexts that may be present in the DB. This could happen if a deployment had - // the entitlement, synced search contexts, and then no longer had the entitlement - const hasSearchContextEntitlement = await hasEntitlement("search-contexts") - if (!hasSearchContextEntitlement) { - await __unsafePrisma.searchContext.deleteMany({ - where: { - orgId: SINGLE_TENANT_ORG_ID, - }, - }); - } - +const syncDeprecatedEnvVars = async () => { // Sync member approval setting from env var (only if explicitly set) if (env.REQUIRE_APPROVAL_NEW_MEMBERS !== undefined) { const requireApprovalNewMembers = env.REQUIRE_APPROVAL_NEW_MEMBERS === 'true'; @@ -74,9 +62,17 @@ const init = async () => { } } -(async () => { - await init(); +export const initialize = async (): Promise => { + await syncDeprecatedEnvVars(); + + try { + await syncWithLighthouse(SINGLE_TENANT_ORG_ID); + } catch (error) { + logger.error(`Startup Lighthouse sync failed: ${error instanceof Error ? error.message : String(error)}`); + Sentry.captureException(error); + } + startServicePingCronJob(); startChangelogPollingJob(); warmModelCapabilitiesCatalog(); -})(); +}; diff --git a/packages/web/src/instrumentation.ts b/packages/web/src/instrumentation.ts index 6e135fd21..238495b4f 100644 --- a/packages/web/src/instrumentation.ts +++ b/packages/web/src/instrumentation.ts @@ -27,7 +27,8 @@ export async function register() { } if (process.env.NEXT_RUNTIME === 'nodejs') { - await import('./initialize'); + const { initialize } = await import('./initialize'); + await initialize(); } } diff --git a/yarn.lock b/yarn.lock index 088a16ec4..54653dc8d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9200,7 +9200,7 @@ __metadata: tsx: "npm:^4.21.0" typescript: "npm:^5.6.2" vitest: "npm:^4.1.4" - zod: "npm:^3.25.74" + zod: "npm:^3.25.76" languageName: unknown linkType: soft @@ -9282,7 +9282,7 @@ __metadata: typescript: "npm:^5.7.3" vitest: "npm:^4.1.4" winston: "npm:^3.15.0" - zod: "npm:^3.25.74" + zod: "npm:^3.25.76" languageName: unknown linkType: soft @@ -24449,13 +24449,6 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.25.74": - version: 3.25.74 - resolution: "zod@npm:3.25.74" - checksum: 10c0/59e38b046ac333b5bd1ba325a83b6798721227cbfb1e69dfc7159bd7824b904241ab923026edb714fafefec3624265ae374a70aee9a5a45b365bd31781ffa105 - languageName: node - linkType: hard - "zwitch@npm:^2.0.0, zwitch@npm:^2.0.4": version: 2.0.4 resolution: "zwitch@npm:2.0.4"