Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "License" ADD COLUMN "lastSyncErrorCode" TEXT;
1 change: 1 addition & 0 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ model License {
trialEnd DateTime?
hasPaymentMethod Boolean?
lastSyncAt DateTime?
lastSyncErrorCode String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Expand Down
38 changes: 38 additions & 0 deletions packages/shared/src/entitlements.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const makeLicense = (overrides: Partial<License> = {}): License => ({
trialEnd: null,
hasPaymentMethod: null,
lastSyncAt: new Date(),
lastSyncErrorCode: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
Expand Down Expand Up @@ -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', () => {
Expand Down
3 changes: 2 additions & 1 deletion packages/shared/src/entitlements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const makeLicense = (overrides: Partial<License> = {}): License => ({
trialEnd: null,
hasPaymentMethod: null,
lastSyncAt: NOW,
lastSyncErrorCode: null,
createdAt: NOW,
updatedAt: NOW,
...overrides,
Expand Down Expand Up @@ -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({
Expand Down
18 changes: 18 additions & 0 deletions packages/web/src/app/(app)/components/banners/bannerResolver.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) => <LicenseReboundElsewhereBanner {...props} />,
});
}

if (
!ctx.offlineLicense
&& ctx.license?.status === 'trialing'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ type BannerSlotProps = Omit<BannerContext, 'dismissals' | 'today' | 'now'>;

const KNOWN_BANNER_IDS: BannerId[] = [
'licenseExpired',
'licenseReboundElsewhere',
'invoicePastDue',
'permissionSync',
'licenseExpiryHeadsUp',
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = (
<a href={ENTERPRISE_OFFERING_DOCS_LINK} target="_blank" rel="noopener noreferrer">
What&apos;s affected?
</a>
);

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 (
<BannerShell
id={id}
dismissible={dismissible}
icon={<AlertTriangle className="h-4 w-4 mt-0.5 text-destructive" />}
title="License activated on another instance"
description={description}
action={isOwner ? (
<Button asChild size="sm" variant="outline">
<Link href="/settings/license">Manage license</Link>
</Button>
) : undefined}
/>
);
}
2 changes: 2 additions & 0 deletions packages/web/src/app/(app)/components/banners/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -13,6 +14,7 @@ export const BannerPriority = {

export type BannerId =
| 'licenseExpired'
| 'licenseReboundElsewhere'
| 'invoicePastDue'
| 'permissionSync'
| 'licenseExpiryHeadsUp'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions packages/web/src/app/(app)/settings/license/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -84,7 +84,7 @@ export default authenticatedPage<LicensePageProps>(async ({ prisma, org }, props
{offlineLicense && (
<OfflineLicenseCard license={offlineLicense} isExpired={isOfflineLicenseExpired} />
)}
{license && <CurrentPlanCard license={license} />}
{license && <OnlineLicenseCard license={license} />}
{license && <RecentInvoicesCard invoices={invoices} />}
{!offlineLicense && !license && <ActivationCodeCard isTrialEligible={isTrialEligible} />}
</div>
Expand Down
18 changes: 15 additions & 3 deletions packages/web/src/ee/features/lighthouse/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 },
});
Expand Down
13 changes: 13 additions & 0 deletions packages/web/src/ee/features/lighthouse/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { fetchWithRetry, isServiceError } from "@/lib/utils";
import { env } from "@sourcebot/shared";
import {
ActivateRequest,
ActivateResponse,
activateResponseSchema,
CheckoutRequest,
CheckoutResponse,
checkoutResponseSchema,
Expand All @@ -20,6 +23,16 @@ import { StatusCodes } from "http-status-codes";
import { z } from "zod";

export const client = {
activate: async (body: ActivateRequest): Promise<ActivateResponse | ServiceError> => {
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<ServicePingResponse | ServiceError> => {
const response = await fetchWithRetry(`${env.SOURCEBOT_LIGHTHOUSE_URL}/ping`, {
method: 'POST',
Expand Down
9 changes: 9 additions & 0 deletions packages/web/src/ee/features/lighthouse/servicePing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -78,6 +86,7 @@ export const syncWithLighthouse = async (orgId: number) => {
trialEnd: trialEnd ? new Date(trialEnd) : null,
hasPaymentMethod,
lastSyncAt: new Date(),
lastSyncErrorCode: null,
},
});

Expand Down
12 changes: 12 additions & 0 deletions packages/web/src/ee/features/lighthouse/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ export const servicePingRequestSchema = z.object({
});
export type ServicePingRequest = z.infer<typeof servicePingRequestSchema>;

export const activateRequestSchema = z.object({
activationCode: z.string(),
installId: z.string(),
});
export type ActivateRequest = z.infer<typeof activateRequestSchema>;

export const activateResponseSchema = z.object({
status: z.literal('ok'),
reactivationsRemaining: z.number().int(),
});
export type ActivateResponse = z.infer<typeof activateResponseSchema>;

export const servicePingResponseSchema = z.object({
license: z.object({
entitlements: z.string().array(),
Expand Down
Loading