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 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/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..1853ec5ce 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -373,6 +373,10 @@ model License { yearlyBillableOverageSeats Int? yearlyPeakSeats Int? + /// 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()) 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 89b1bcfbe..a008c794f 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), })); @@ -30,6 +31,7 @@ import { isAnonymousAccessAvailable, getEntitlements, hasEntitlement, + verifyOnlineLicenseAssertion, } from './entitlements.js'; const encodeOfflineKey = (payload: object): string => { @@ -78,11 +80,47 @@ const makeLicense = (overrides: Partial = {}): License => ({ yearlyPeakSeats: null, lastSyncAt: new Date(), lastSyncErrorCode: null, + licenseAssertion: null, createdAt: new Date(), updatedAt: new Date(), ...overrides, }); +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', + issuedAt: new Date(Date.now() - 60 * 1000).toISOString(), + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + ...(!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`; +}; + beforeEach(() => { mocks.env.SOURCEBOT_EE_LICENSE_KEY = undefined; mocks.verifySignature.mockReturnValue(true); @@ -103,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); } ); }); @@ -137,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', () => { @@ -169,83 +213,237 @@ 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', () => { expect(getEntitlements(makeLicense({ entitlements: ['sso'] }))).toEqual([]); }); - test('returns all entitlements when offline key is valid', () => { - mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 50 }); - const result = getEntitlements(null); - expect(result).toContain('sso'); - expect(result).toContain('audit'); - expect(result).toContain('search-contexts'); - }); + describe('signed online assertions', () => { + describe('claim validation', () => { + test('rejects an unsupported version', () => { + expect(verifyOnlineLicenseAssertion(onlineAssertion({ version: 2 }))).toBeNull(); + }); - test('falls through when 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']); - }); + test('rejects an assertion for another audience', () => { + expect(verifyOnlineLicenseAssertion(onlineAssertion({ audience: 'another-service' }))).toBeNull(); + }); - test('falls through when offline key is malformed', () => { - mocks.env.SOURCEBOT_EE_LICENSE_KEY = 'sourcebot_ee_not-a-valid-payload'; - expect(getEntitlements(null)).toEqual([]); - }); + 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(); + }); - describe('online license staleness', () => { - const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; + test('rejects an invalid issuedAt timestamp', () => { + expect(verifyOnlineLicenseAssertion(onlineAssertion({ issuedAt: 'not-a-date' }))).toBeNull(); + }); - test('returns entitlements when lastSyncAt is recent', () => { + 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({ 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'], - lastSyncAt: new Date(Date.now() - 24 * 60 * 60 * 1000), // 1 day ago + 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']); }); - test('returns empty when lastSyncAt is past the stale threshold', () => { + test('does not fall back to mutable columns when the signature is invalid', () => { + mocks.verifySignature.mockReturnValue(false); const license = makeLicense({ status: 'active', - entitlements: ['sso'], - lastSyncAt: new Date(Date.now() - STALE_THRESHOLD_MS - 60 * 1000), // 7d + 1min + entitlements: ['audit'], + licenseAssertion: onlineAssertion(), }); + expect(getEntitlements(license)).toEqual([]); }); - test('returns empty when lastSyncAt is null', () => { + test('rejects an assertion issued for another installation', () => { const license = makeLicense({ status: 'active', - entitlements: ['sso'], - lastSyncAt: null, + 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('returns entitlements at the threshold boundary', () => { - // Exactly at the threshold should still be treated as valid (<=). + test('rejects active mutable columns when the signed status is canceled', () => { + const license = makeLicense({ + status: 'active', + entitlements: ['audit'], + 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'], - lastSyncAt: new Date(Date.now() - STALE_THRESHOLD_MS + 1000), + licenseAssertion, }); - expect(getEntitlements(license)).toEqual(['sso']); + + expect(verifyOnlineLicenseAssertion(licenseAssertion)?.license.status).toBe('future-status'); + 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); + expect(result).toContain('sso'); + expect(result).toContain('audit'); + expect(result).toContain('search-contexts'); + }); + + 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([]); + }); + + test('falls through when offline key is malformed', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = 'sourcebot_ee_not-a-valid-payload'; + expect(getEntitlements(null)).toEqual([]); + }); + 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([]); @@ -257,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']); @@ -270,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); @@ -279,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 85982c147..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,6 +31,11 @@ const ACTIVE_ONLINE_LICENSE_STATUSES: LicenseStatus[] = [ 'past_due', ]; +const isActiveOnlineLicenseStatus = (status: string): status is LicenseStatus => + ACTIVE_ONLINE_LICENSE_STATUSES.includes(status as LicenseStatus); + +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 // prior to rolling a new Sourcebot version. @@ -47,6 +56,62 @@ const ALL_ENTITLEMENTS = [ ] as const; export type Entitlement = (typeof ALL_ENTITLEMENTS)[number]; +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): OnlineLicenseAssertionClaims | 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 = 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'); + 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,16 +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; -const getValidOnlineLicense = (_license: License | null): License | 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 _license; +type ValidOnlineLicense = { + entitlements: Entitlement[]; + status: LicenseStatus; +}; + +const getValidOnlineLicense = (_license: License | null): ValidOnlineLicense | null => { + // Unsigned database columns never grant online entitlements. + if (!_license?.licenseAssertion) { + return null; + } + + if (_license.lastSyncErrorCode === 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE') { + return null; + } + + const assertion = verifyOnlineLicenseAssertion(_license.licenseAssertion); + if (assertion && isActiveOnlineLicenseStatus(assertion.license.status)) { + return { + entitlements: assertion.license.entitlements.filter(isKnownEntitlement), + status: assertion.license.status, + }; } return null; @@ -208,4 +284,4 @@ export const getSeatCap = (): number | undefined => { } return undefined; -} \ No newline at end of file +} 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 80fe65ce8..6c1d8d723 100644 --- a/packages/shared/src/index.server.ts +++ b/packages/shared/src/index.server.ts @@ -12,11 +12,13 @@ export { getOfflineLicenseMetadata, STALE_ONLINE_LICENSE_THRESHOLD_MS, STALE_ONLINE_LICENSE_WARNING_THRESHOLD_MS, + verifyOnlineLicenseAssertion, } from "./entitlements.js"; export type { Entitlement, OfflineLicenseMetadata, } 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 77% rename from packages/web/src/features/billing/types.ts rename to packages/shared/src/lighthouseTypes.ts index f97523fc3..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,37 +120,10 @@ 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(), + status: z.literal('ok'), + license: onlineLicenseSnapshotSchema.optional(), + licenseAssertion: z.string().optional(), }); export type ServicePingResponse = z.infer; 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/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 6a0290c90..1e637d81e 100644 --- a/packages/web/src/features/billing/servicePing.ts +++ b/packages/web/src/features/billing/servicePing.ts @@ -7,10 +7,11 @@ import { decryptActivationCode, env, SOURCEBOT_VERSION, - isValidOfflineLicenseActive + 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"; @@ -125,6 +126,22 @@ 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) { const { @@ -142,7 +159,7 @@ export const syncWithLighthouse = async (orgId: number) => { trialEnd, hasPaymentMethod, yearlyTermStatus, - } = response.license; + } = verifiedAssertion?.license ?? response.license; await __unsafePrisma.license.update({ where: { @@ -174,6 +191,7 @@ export const syncWithLighthouse = async (orgId: number) => { yearlyPeakSeats: yearlyTermStatus?.peakSeats ?? null, lastSyncAt: new Date(), lastSyncErrorCode: null, + ...(licenseAssertion !== undefined && { licenseAssertion }), }, }); @@ -182,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"