diff --git a/packages/db/prisma/migrations/20260513212628_add_license_last_sync_error_code/migration.sql b/packages/db/prisma/migrations/20260513212628_add_license_last_sync_error_code/migration.sql new file mode 100644 index 000000000..771904032 --- /dev/null +++ b/packages/db/prisma/migrations/20260513212628_add_license_last_sync_error_code/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "License" ADD COLUMN "lastSyncErrorCode" TEXT; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 6f25a519d..7e1af6be7 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -318,6 +318,7 @@ model License { trialEnd DateTime? hasPaymentMethod Boolean? lastSyncAt DateTime? + lastSyncErrorCode 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 b6ff79239..906ef516e 100644 --- a/packages/shared/src/entitlements.test.ts +++ b/packages/shared/src/entitlements.test.ts @@ -66,6 +66,7 @@ const makeLicense = (overrides: Partial = {}): License => ({ trialEnd: null, hasPaymentMethod: null, lastSyncAt: new Date(), + lastSyncErrorCode: null, createdAt: new Date(), updatedAt: new Date(), ...overrides, @@ -215,6 +216,43 @@ describe('getEntitlements', () => { 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(), + lastSyncErrorCode: 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE', + }); + expect(getEntitlements(license)).toEqual([]); + }); + + test('returns entitlements when lastSyncErrorCode is some other error code', () => { + // Only ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE invalidates the + // local license. Other sync errors are persisted for visibility but + // don't strip entitlements (avoids paging operators on transient + // upstream issues). + const license = makeLicense({ + status: 'active', + entitlements: ['sso'], + lastSyncAt: new Date(), + lastSyncErrorCode: 'UNKNOWN_STRIPE_PRODUCT', + }); + expect(getEntitlements(license)).toEqual(['sso']); + }); + + test('offline license overrides the rebound-elsewhere gate', () => { + // Offline licenses don't rely on /ping, so a stale online error + // should not affect them. + mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey(); + const license = makeLicense({ + status: 'active', + lastSyncErrorCode: 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE', + }); + expect(getEntitlements(license).length).toBeGreaterThan(0); + }); + }); }); describe('hasEntitlement', () => { diff --git a/packages/shared/src/entitlements.ts b/packages/shared/src/entitlements.ts index f20270513..8ba587b43 100644 --- a/packages/shared/src/entitlements.ts +++ b/packages/shared/src/entitlements.ts @@ -107,7 +107,8 @@ const getValidOnlineLicense = (_license: License | null): License | null => { _license.status && ACTIVE_ONLINE_LICENSE_STATUSES.includes(_license.status as LicenseStatus) && _license.lastSyncAt && - (Date.now() - _license.lastSyncAt.getTime()) <= STALE_ONLINE_LICENSE_THRESHOLD_MS + (Date.now() - _license.lastSyncAt.getTime()) <= STALE_ONLINE_LICENSE_THRESHOLD_MS && + _license.lastSyncErrorCode !== 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE' ) { return _license; } 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 b8b44d2c7..0a5329fa4 100644 --- a/packages/web/src/app/(app)/components/banners/bannerResolver.test.ts +++ b/packages/web/src/app/(app)/components/banners/bannerResolver.test.ts @@ -47,6 +47,7 @@ const makeLicense = (overrides: Partial = {}): License => ({ trialEnd: null, hasPaymentMethod: null, lastSyncAt: NOW, + lastSyncErrorCode: null, createdAt: NOW, updatedAt: NOW, ...overrides, @@ -447,6 +448,81 @@ describe('resolveActiveBanner', () => { }); }); + describe('license rebound elsewhere', () => { + test('lastSyncErrorCode is rebound code → banner (non-dismissible, everyone)', () => { + const result = resolveActiveBanner(makeContext({ + license: makeLicense({ + status: 'active', + lastSyncErrorCode: 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE', + }), + })); + expect(result?.id).toBe('licenseReboundElsewhere'); + expect(result?.dismissible).toBe(false); + expect(result?.audience).toBe('everyone'); + }); + + test('null lastSyncErrorCode → no rebound banner', () => { + const result = resolveActiveBanner(makeContext({ + license: makeLicense({ status: 'active', lastSyncErrorCode: null }), + })); + expect(result?.id).not.toBe('licenseReboundElsewhere'); + }); + + test('other lastSyncErrorCode does not fire rebound banner', () => { + const result = resolveActiveBanner(makeContext({ + license: makeLicense({ + status: 'active', + lastSyncErrorCode: 'UNKNOWN_STRIPE_PRODUCT', + }), + })); + expect(result?.id).not.toBe('licenseReboundElsewhere'); + }); + + test('offline license suppresses rebound banner', () => { + const result = resolveActiveBanner(makeContext({ + offlineLicense: makeOfflineLicense(), + license: makeLicense({ + status: 'active', + lastSyncErrorCode: 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE', + }), + })); + expect(result).toBeNull(); + }); + + test('rebound banner shown to non-owners', () => { + // This is a hard lockout — everyone sees it. + const result = resolveActiveBanner(makeContext({ + role: OrgRole.MEMBER, + license: makeLicense({ + status: 'active', + lastSyncErrorCode: 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE', + }), + })); + expect(result?.id).toBe('licenseReboundElsewhere'); + }); + + test('rebound outranks enforced ping staleness', () => { + const result = resolveActiveBanner(makeContext({ + license: makeLicense({ + status: 'active', + lastSyncAt: new Date(NOW.getTime() - 14 * 24 * 60 * 60 * 1000), + lastSyncErrorCode: 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE', + }), + })); + expect(result?.id).toBe('licenseReboundElsewhere'); + }); + + test('license expired outranks rebound', () => { + const result = resolveActiveBanner(makeContext({ + license: makeLicense({ + status: 'canceled', + lastSyncErrorCode: 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE', + }), + })); + expect(result?.id).toBe('licenseExpired'); + }); + }); + describe('trial', () => { test('status trialing + future trialEnd → trial banner', () => { const result = resolveActiveBanner(makeContext({ diff --git a/packages/web/src/app/(app)/components/banners/bannerResolver.tsx b/packages/web/src/app/(app)/components/banners/bannerResolver.tsx index b494162f4..266e7b193 100644 --- a/packages/web/src/app/(app)/components/banners/bannerResolver.tsx +++ b/packages/web/src/app/(app)/components/banners/bannerResolver.tsx @@ -9,10 +9,15 @@ import { BannerPriority, type BannerDescriptor, type BannerId } from "./types"; import { PermissionSyncBanner } from "./permissionSyncBanner"; import { LicenseExpiredBanner } from "./licenseExpiredBanner"; import { LicenseExpiryHeadsUpBanner } from "./licenseExpiryHeadsUpBanner"; +import { LicenseReboundElsewhereBanner } from "./licenseReboundElsewhereBanner"; import { InvoicePastDueBanner } from "./invoicePastDueBanner"; import { ServicePingFailedBanner } from "./servicePingFailedBanner"; import { TrialBanner } from "./trialBanner"; +// Mirrors the value in `lighthouse: lambda/serviceError.ts` and the gating +// constant in `packages/shared/src/entitlements.ts`. +const LICENSE_REBOUND_ELSEWHERE_ERROR_CODE = 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE'; + const EXPIRY_HEADS_UP_WINDOW_MS = 14 * 24 * 60 * 60 * 1000; export interface BannerContext { @@ -66,6 +71,19 @@ function buildCandidates(ctx: BannerContext): BannerDescriptor[] { }); } + if ( + !ctx.offlineLicense + && ctx.license?.lastSyncErrorCode === LICENSE_REBOUND_ELSEWHERE_ERROR_CODE + ) { + banners.push({ + id: 'licenseReboundElsewhere', + priority: BannerPriority.LICENSE_REBOUND_ELSEWHERE, + dismissible: false, + audience: 'everyone', + render: (props) => , + }); + } + if ( !ctx.offlineLicense && ctx.license?.status === 'trialing' diff --git a/packages/web/src/app/(app)/components/banners/bannerSlot.tsx b/packages/web/src/app/(app)/components/banners/bannerSlot.tsx index 6cb9839ae..2920e2ca2 100644 --- a/packages/web/src/app/(app)/components/banners/bannerSlot.tsx +++ b/packages/web/src/app/(app)/components/banners/bannerSlot.tsx @@ -6,6 +6,7 @@ type BannerSlotProps = Omit; const KNOWN_BANNER_IDS: BannerId[] = [ 'licenseExpired', + 'licenseReboundElsewhere', 'invoicePastDue', 'permissionSync', 'licenseExpiryHeadsUp', diff --git a/packages/web/src/app/(app)/components/banners/licenseReboundElsewhereBanner.tsx b/packages/web/src/app/(app)/components/banners/licenseReboundElsewhereBanner.tsx new file mode 100644 index 000000000..c3465f649 --- /dev/null +++ b/packages/web/src/app/(app)/components/banners/licenseReboundElsewhereBanner.tsx @@ -0,0 +1,38 @@ +import Link from "next/link"; +import { AlertTriangle } from "lucide-react"; +import { OrgRole } from "@sourcebot/db"; +import { Button } from "@/components/ui/button"; +import { BannerShell } from "./bannerShell"; +import type { BannerProps } from "./types"; + +// @nocheckin: This should instead be a docs page that explains the enterprise offering. +const ENTERPRISE_OFFERING_DOCS_LINK = "https://sourcebot.dev/pricing"; + +export function LicenseReboundElsewhereBanner({ id, dismissible, role }: BannerProps) { + const isOwner = role === OrgRole.OWNER; + + const whatsAffectedLink = ( + + What's affected? + + ); + + const description = isOwner + ? <>This license is currently activated on a different Sourcebot install, and enterprise features have been disabled here. To use it on this install, deactivate and reactivate the license. {whatsAffectedLink} + : <>This license is currently activated on a different Sourcebot install, and enterprise features have been disabled. Contact your organization administrator to restore access. {whatsAffectedLink}; + + return ( + } + title="License activated on another instance" + description={description} + action={isOwner ? ( + + ) : undefined} + /> + ); +} diff --git a/packages/web/src/app/(app)/components/banners/types.ts b/packages/web/src/app/(app)/components/banners/types.ts index 94d0d400c..844c20478 100644 --- a/packages/web/src/app/(app)/components/banners/types.ts +++ b/packages/web/src/app/(app)/components/banners/types.ts @@ -3,6 +3,7 @@ import type { OrgRole } from "@sourcebot/db"; export const BannerPriority = { LICENSE_EXPIRED: 100, + LICENSE_REBOUND_ELSEWHERE: 97, SERVICE_PING_ENFORCED: 95, INVOICE_PAST_DUE: 90, PERMISSION_SYNC: 50, @@ -13,6 +14,7 @@ export const BannerPriority = { export type BannerId = | 'licenseExpired' + | 'licenseReboundElsewhere' | 'invoicePastDue' | 'permissionSync' | 'licenseExpiryHeadsUp' diff --git a/packages/web/src/app/(app)/settings/license/currentPlanCard.tsx b/packages/web/src/app/(app)/settings/license/onlineLicenseCard.tsx similarity index 98% rename from packages/web/src/app/(app)/settings/license/currentPlanCard.tsx rename to packages/web/src/app/(app)/settings/license/onlineLicenseCard.tsx index 51763c28f..398109622 100644 --- a/packages/web/src/app/(app)/settings/license/currentPlanCard.tsx +++ b/packages/web/src/app/(app)/settings/license/onlineLicenseCard.tsx @@ -7,11 +7,11 @@ import { cn, formatCurrency } from "@/lib/utils"; import { SettingsCard } from "../components/settingsCard"; import { PlanActionsMenu } from "./planActionsMenu"; -interface CurrentPlanCardProps { +interface OnlineLicenseCardProps { license: License; } -export function CurrentPlanCard({ license }: CurrentPlanCardProps) { +export function OnlineLicenseCard({ license }: OnlineLicenseCardProps) { const { planName, unitAmount, diff --git a/packages/web/src/app/(app)/settings/license/page.tsx b/packages/web/src/app/(app)/settings/license/page.tsx index e5d9c6e87..701c8e3b3 100644 --- a/packages/web/src/app/(app)/settings/license/page.tsx +++ b/packages/web/src/app/(app)/settings/license/page.tsx @@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button"; import { ExternalLink } from "lucide-react"; import { redirect } from "next/navigation"; import { ActivationCodeCard } from "./activationCodeCard"; -import { CurrentPlanCard } from "./currentPlanCard"; +import { OnlineLicenseCard } from "./onlineLicenseCard"; import { OfflineLicenseCard } from "./offlineLicenseCard"; import { RecentInvoicesCard } from "./recentInvoicesCard"; import { getAllInvoices } from "@/ee/features/lighthouse/actions"; @@ -84,7 +84,7 @@ export default authenticatedPage(async ({ prisma, org }, props {offlineLicense && ( )} - {license && } + {license && } {license && } {!offlineLicense && !license && } diff --git a/packages/web/src/ee/features/lighthouse/actions.ts b/packages/web/src/ee/features/lighthouse/actions.ts index fe89ebe10..00f07e633 100644 --- a/packages/web/src/ee/features/lighthouse/actions.ts +++ b/packages/web/src/ee/features/lighthouse/actions.ts @@ -4,7 +4,7 @@ import { sew } from "@/middleware/sew"; import { withAuth } from "@/middleware/withAuth"; import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole"; import { OrgRole } from "@sourcebot/db"; -import { ServiceError } from "@/lib/serviceError"; +import { ServiceError, ServiceErrorException } from "@/lib/serviceError"; import { StatusCodes } from "http-status-codes"; import { ErrorCode } from "@/lib/errorCodes"; import { encryptActivationCode, decryptActivationCode, env } from "@sourcebot/shared"; @@ -36,11 +36,23 @@ export const activateLicense = async (activationCode: string): Promise<{ success }, }); - // Immediately ping Lighthouse to validate and sync license data try { + // Bind the activation code to this install. This is the only + // call that mutates the binding on the Lighthouse side; the + // subsequent ping is pure read. + const activateResult = await client.activate({ + activationCode, + installId: env.SOURCEBOT_INSTALL_ID, + }); + + if (isServiceError(activateResult)) { + throw new ServiceErrorException(activateResult); + } + + // Immediately sync license data from Lighthouse. await syncWithLighthouse(org.id); } catch (e) { - // If the ping fails, remove the license record + // If activation or initial sync fails, remove the license record await prisma.license.delete({ where: { orgId: org.id }, }); diff --git a/packages/web/src/ee/features/lighthouse/client.ts b/packages/web/src/ee/features/lighthouse/client.ts index fac9b6933..82bfaee46 100644 --- a/packages/web/src/ee/features/lighthouse/client.ts +++ b/packages/web/src/ee/features/lighthouse/client.ts @@ -1,6 +1,9 @@ import { fetchWithRetry, isServiceError } from "@/lib/utils"; import { env } from "@sourcebot/shared"; import { + ActivateRequest, + ActivateResponse, + activateResponseSchema, CheckoutRequest, CheckoutResponse, checkoutResponseSchema, @@ -20,6 +23,16 @@ import { StatusCodes } from "http-status-codes"; import { z } from "zod"; export const client = { + activate: async (body: ActivateRequest): Promise => { + const response = await fetchWithRetry(`${env.SOURCEBOT_LIGHTHOUSE_URL}/activate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + return parseResponseBody(response, activateResponseSchema); + }, + ping: async (body: ServicePingRequest): Promise => { const response = await fetchWithRetry(`${env.SOURCEBOT_LIGHTHOUSE_URL}/ping`, { method: 'POST', diff --git a/packages/web/src/ee/features/lighthouse/servicePing.ts b/packages/web/src/ee/features/lighthouse/servicePing.ts index 14c2d1e6c..5d77cb068 100644 --- a/packages/web/src/ee/features/lighthouse/servicePing.ts +++ b/packages/web/src/ee/features/lighthouse/servicePing.ts @@ -36,6 +36,14 @@ export const syncWithLighthouse = async (orgId: number) => { const response = await client.ping(payload); if (isServiceError(response)) { logger.error(`Service ping failed:\n ${JSON.stringify(response, null, 2)}`) + + if (license) { + await __unsafePrisma.license.update({ + where: { orgId }, + data: { lastSyncErrorCode: response.errorCode }, + }); + } + throw new ServiceErrorException(response); } @@ -78,6 +86,7 @@ export const syncWithLighthouse = async (orgId: number) => { trialEnd: trialEnd ? new Date(trialEnd) : null, hasPaymentMethod, lastSyncAt: new Date(), + lastSyncErrorCode: null, }, }); diff --git a/packages/web/src/ee/features/lighthouse/types.ts b/packages/web/src/ee/features/lighthouse/types.ts index 1ca436acf..e9c5c2954 100644 --- a/packages/web/src/ee/features/lighthouse/types.ts +++ b/packages/web/src/ee/features/lighthouse/types.ts @@ -8,6 +8,18 @@ export const servicePingRequestSchema = z.object({ }); export type ServicePingRequest = z.infer; +export const activateRequestSchema = z.object({ + activationCode: z.string(), + installId: z.string(), +}); +export type ActivateRequest = z.infer; + +export const activateResponseSchema = z.object({ + status: z.literal('ok'), + reactivationsRemaining: z.number().int(), +}); +export type ActivateResponse = z.infer; + export const servicePingResponseSchema = z.object({ license: z.object({ entitlements: z.string().array(),