Skip to content

Commit a7ec2ef

Browse files
handle failure case where lighthouse cannot be reached
1 parent 9c2b28b commit a7ec2ef

10 files changed

Lines changed: 424 additions & 14 deletions

File tree

packages/shared/src/entitlements.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ const makeLicense = (overrides: Partial<License> = {}): License => ({
6363
nextRenewalAt: null,
6464
nextRenewalAmount: null,
6565
cancelAt: null,
66-
lastSyncAt: null,
66+
lastSyncAt: new Date(),
6767
createdAt: new Date(),
6868
updatedAt: new Date(),
6969
...overrides,
@@ -172,6 +172,47 @@ describe('getEntitlements', () => {
172172
mocks.env.SOURCEBOT_EE_LICENSE_KEY = 'sourcebot_ee_not-a-valid-payload';
173173
expect(getEntitlements(null)).toEqual([]);
174174
});
175+
176+
describe('online license staleness', () => {
177+
const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
178+
179+
test('returns entitlements when lastSyncAt is recent', () => {
180+
const license = makeLicense({
181+
status: 'active',
182+
entitlements: ['sso'],
183+
lastSyncAt: new Date(Date.now() - 24 * 60 * 60 * 1000), // 1 day ago
184+
});
185+
expect(getEntitlements(license)).toEqual(['sso']);
186+
});
187+
188+
test('returns empty when lastSyncAt is past the stale threshold', () => {
189+
const license = makeLicense({
190+
status: 'active',
191+
entitlements: ['sso'],
192+
lastSyncAt: new Date(Date.now() - STALE_THRESHOLD_MS - 60 * 1000), // 7d + 1min
193+
});
194+
expect(getEntitlements(license)).toEqual([]);
195+
});
196+
197+
test('returns empty when lastSyncAt is null', () => {
198+
const license = makeLicense({
199+
status: 'active',
200+
entitlements: ['sso'],
201+
lastSyncAt: null,
202+
});
203+
expect(getEntitlements(license)).toEqual([]);
204+
});
205+
206+
test('returns entitlements at the threshold boundary', () => {
207+
// Exactly at the threshold should still be treated as valid (<=).
208+
const license = makeLicense({
209+
status: 'active',
210+
entitlements: ['sso'],
211+
lastSyncAt: new Date(Date.now() - STALE_THRESHOLD_MS + 1000),
212+
});
213+
expect(getEntitlements(license)).toEqual(['sso']);
214+
});
215+
});
175216
});
176217

177218
describe('hasEntitlement', () => {

packages/shared/src/entitlements.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,26 @@ const getValidOfflineLicense = (): getValidOfflineLicense | null => {
8888
return payload;
8989
}
9090

91+
// If the license hasn't successfully synced with Lighthouse for this long,
92+
// the locally-cached state is no longer trusted. This guards against an
93+
// operator blocking egress to prevent the license row from hearing about
94+
// a canceled or past-due subscription. 7 days absorbs week-long transient
95+
// outages (weekends, firewall rollouts) without punishing legitimate
96+
// customers.
97+
export const STALE_ONLINE_LICENSE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
98+
99+
// Surface a UI warning (banner + "refreshed" timestamp color) when the
100+
// license hasn't synced for this long. Must be < the enforcement threshold
101+
// so the warning has a chance to fire before entitlements are stripped.
102+
export const STALE_ONLINE_LICENSE_WARNING_THRESHOLD_MS = 48 * 60 * 60 * 1000;
103+
91104
const getValidOnlineLicense = (_license: License | null): License | null => {
92105
if (
93106
_license &&
94107
_license.status &&
95-
ACTIVE_ONLINE_LICENSE_STATUSES.includes(_license.status as LicenseStatus)
108+
ACTIVE_ONLINE_LICENSE_STATUSES.includes(_license.status as LicenseStatus) &&
109+
_license.lastSyncAt &&
110+
(Date.now() - _license.lastSyncAt.getTime()) <= STALE_ONLINE_LICENSE_THRESHOLD_MS
96111
) {
97112
return _license;
98113
}

packages/shared/src/index.server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ export {
77
isAnonymousAccessAvailable as _isAnonymousAccessAvailable,
88
getSeatCap,
99
getOfflineLicenseMetadata,
10+
STALE_ONLINE_LICENSE_THRESHOLD_MS,
11+
STALE_ONLINE_LICENSE_WARNING_THRESHOLD_MS,
1012
} from "./entitlements.js";
1113
export type {
1214
Entitlement,

packages/web/src/app/(app)/components/banners/bannerResolver.test.ts

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,19 @@ import type { OfflineLicenseMetadata } from '@sourcebot/shared';
55
// Stub the rendered banner components — these tests assert on descriptor
66
// metadata only (id, priority, etc), so avoiding their React/Next.js import
77
// chains keeps the suite focused on resolver logic.
8+
// Stub @sourcebot/shared: importing its real index initializes env-backed
9+
// server code that can't run in the test environment. The resolver only
10+
// needs the threshold constant; type imports are erased at runtime.
11+
vi.mock('@sourcebot/shared', () => ({
12+
STALE_ONLINE_LICENSE_THRESHOLD_MS: 7 * 24 * 60 * 60 * 1000,
13+
STALE_ONLINE_LICENSE_WARNING_THRESHOLD_MS: 48 * 60 * 60 * 1000,
14+
}));
15+
816
vi.mock('./permissionSyncBanner', () => ({ PermissionSyncBanner: () => null }));
917
vi.mock('./licenseExpiredBanner', () => ({ LicenseExpiredBanner: () => null }));
1018
vi.mock('./licenseExpiryHeadsUpBanner', () => ({ LicenseExpiryHeadsUpBanner: () => null }));
1119
vi.mock('./invoicePastDueBanner', () => ({ InvoicePastDueBanner: () => null }));
20+
vi.mock('./servicePingFailedBanner', () => ({ ServicePingFailedBanner: () => null }));
1221

1322
import { resolveActiveBanner, type BannerContext } from './bannerResolver';
1423

@@ -34,7 +43,7 @@ const makeLicense = (overrides: Partial<License> = {}): License => ({
3443
nextRenewalAt: null,
3544
nextRenewalAmount: null,
3645
cancelAt: null,
37-
lastSyncAt: null,
46+
lastSyncAt: NOW,
3847
createdAt: NOW,
3948
updatedAt: NOW,
4049
...overrides,
@@ -305,6 +314,136 @@ describe('resolveActiveBanner', () => {
305314
});
306315
});
307316

317+
describe('service ping staleness', () => {
318+
const WARNING_MS = 48 * 60 * 60 * 1000;
319+
const ENFORCEMENT_MS = 7 * 24 * 60 * 60 * 1000;
320+
const msBefore = (ms: number) => new Date(NOW.getTime() - ms);
321+
322+
test('fresh lastSyncAt → no banner', () => {
323+
const result = resolveActiveBanner(makeContext({
324+
license: makeLicense({ status: 'active', lastSyncAt: msBefore(1000) }),
325+
}));
326+
expect(result).toBeNull();
327+
});
328+
329+
test('stale between 48h and 7d → warning (dismissible, owner)', () => {
330+
const result = resolveActiveBanner(makeContext({
331+
license: makeLicense({
332+
status: 'active',
333+
lastSyncAt: msBefore(WARNING_MS + 60_000),
334+
}),
335+
}));
336+
expect(result?.id).toBe('servicePingFailed');
337+
expect(result?.dismissible).toBe(true);
338+
expect(result?.audience).toBe('owner');
339+
});
340+
341+
test('stale beyond 7d → enforced (non-dismissible, everyone)', () => {
342+
const result = resolveActiveBanner(makeContext({
343+
license: makeLicense({
344+
status: 'active',
345+
lastSyncAt: msBefore(ENFORCEMENT_MS + 60_000),
346+
}),
347+
}));
348+
expect(result?.id).toBe('servicePingFailed');
349+
expect(result?.dismissible).toBe(false);
350+
expect(result?.audience).toBe('everyone');
351+
});
352+
353+
test('null lastSyncAt on existing license → enforced', () => {
354+
const result = resolveActiveBanner(makeContext({
355+
license: makeLicense({ status: 'active', lastSyncAt: null }),
356+
}));
357+
expect(result?.id).toBe('servicePingFailed');
358+
expect(result?.audience).toBe('everyone');
359+
});
360+
361+
test('offline license suppresses staleness banner', () => {
362+
const result = resolveActiveBanner(makeContext({
363+
offlineLicense: makeOfflineLicense(),
364+
license: makeLicense({ status: 'active', lastSyncAt: null }),
365+
}));
366+
expect(result).toBeNull();
367+
});
368+
369+
test('warning banner hidden from non-owners', () => {
370+
const result = resolveActiveBanner(makeContext({
371+
role: OrgRole.MEMBER,
372+
license: makeLicense({
373+
status: 'active',
374+
lastSyncAt: msBefore(WARNING_MS + 60_000),
375+
}),
376+
}));
377+
expect(result).toBeNull();
378+
});
379+
380+
test('enforced banner shown to non-owners', () => {
381+
const result = resolveActiveBanner(makeContext({
382+
role: OrgRole.MEMBER,
383+
license: makeLicense({
384+
status: 'active',
385+
lastSyncAt: msBefore(ENFORCEMENT_MS + 60_000),
386+
}),
387+
}));
388+
expect(result?.id).toBe('servicePingFailed');
389+
});
390+
391+
test('warning: dismissed today → filtered out', () => {
392+
const result = resolveActiveBanner(makeContext({
393+
license: makeLicense({
394+
status: 'active',
395+
lastSyncAt: msBefore(WARNING_MS + 60_000),
396+
}),
397+
dismissals: { servicePingFailed: TODAY },
398+
}));
399+
expect(result).toBeNull();
400+
});
401+
402+
test('enforced: dismissal cookie is ignored', () => {
403+
const result = resolveActiveBanner(makeContext({
404+
license: makeLicense({
405+
status: 'active',
406+
lastSyncAt: msBefore(ENFORCEMENT_MS + 60_000),
407+
}),
408+
dismissals: { servicePingFailed: TODAY },
409+
}));
410+
expect(result?.id).toBe('servicePingFailed');
411+
});
412+
413+
test('enforced outranks invoice past due', () => {
414+
const result = resolveActiveBanner(makeContext({
415+
license: makeLicense({
416+
status: 'past_due',
417+
lastSyncAt: msBefore(ENFORCEMENT_MS + 60_000),
418+
}),
419+
}));
420+
expect(result?.id).toBe('servicePingFailed');
421+
expect(result?.audience).toBe('everyone');
422+
});
423+
424+
test('license expired outranks enforced ping staleness', () => {
425+
const result = resolveActiveBanner(makeContext({
426+
license: makeLicense({
427+
status: 'canceled',
428+
lastSyncAt: msBefore(ENFORCEMENT_MS + 60_000),
429+
}),
430+
}));
431+
expect(result?.id).toBe('licenseExpired');
432+
});
433+
434+
test('warning ranks below permission sync', () => {
435+
const result = resolveActiveBanner(makeContext({
436+
license: makeLicense({
437+
status: 'active',
438+
lastSyncAt: msBefore(WARNING_MS + 60_000),
439+
}),
440+
hasPermissionSyncEntitlement: true,
441+
hasPendingFirstSync: true,
442+
}));
443+
expect(result?.id).toBe('permissionSync');
444+
});
445+
});
446+
308447
describe('permission sync', () => {
309448
test('entitlement + pending → permissionSync', () => {
310449
const result = resolveActiveBanner(makeContext({

packages/web/src/app/(app)/components/banners/bannerResolver.tsx

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import { OrgRole, type License } from "@sourcebot/db";
2-
import type { LicenseStatus, OfflineLicenseMetadata } from "@sourcebot/shared";
2+
import {
3+
STALE_ONLINE_LICENSE_THRESHOLD_MS,
4+
STALE_ONLINE_LICENSE_WARNING_THRESHOLD_MS,
5+
type LicenseStatus,
6+
type OfflineLicenseMetadata,
7+
} from "@sourcebot/shared";
38
import { BannerPriority, type BannerDescriptor, type BannerId } from "./types";
49
import { PermissionSyncBanner } from "./permissionSyncBanner";
510
import { LicenseExpiredBanner } from "./licenseExpiredBanner";
611
import { LicenseExpiryHeadsUpBanner } from "./licenseExpiryHeadsUpBanner";
712
import { InvoicePastDueBanner } from "./invoicePastDueBanner";
13+
import { ServicePingFailedBanner } from "./servicePingFailedBanner";
814

915
const EXPIRY_HEADS_UP_WINDOW_MS = 14 * 24 * 60 * 60 * 1000;
1016

@@ -69,6 +75,39 @@ function buildCandidates(ctx: BannerContext): BannerDescriptor[] {
6975
});
7076
}
7177

78+
const pingStaleness = getPingStaleness(ctx);
79+
if (pingStaleness === 'warning') {
80+
const lastSyncAtIso = ctx.license?.lastSyncAt?.toISOString() ?? null;
81+
banners.push({
82+
id: 'servicePingFailed',
83+
priority: BannerPriority.SERVICE_PING_FAILED,
84+
dismissible: true,
85+
audience: 'owner',
86+
render: (props) => (
87+
<ServicePingFailedBanner
88+
{...props}
89+
variant="warning"
90+
lastSyncAt={lastSyncAtIso}
91+
/>
92+
),
93+
});
94+
} else if (pingStaleness === 'enforced') {
95+
const lastSyncAtIso = ctx.license?.lastSyncAt?.toISOString() ?? null;
96+
banners.push({
97+
id: 'servicePingFailed',
98+
priority: BannerPriority.SERVICE_PING_ENFORCED,
99+
dismissible: false,
100+
audience: 'everyone',
101+
render: (props) => (
102+
<ServicePingFailedBanner
103+
{...props}
104+
variant="enforced"
105+
lastSyncAt={lastSyncAtIso}
106+
/>
107+
),
108+
});
109+
}
110+
72111
if (ctx.hasPermissionSyncEntitlement && ctx.hasPendingFirstSync) {
73112
banners.push({
74113
id: 'permissionSync',
@@ -129,3 +168,24 @@ function getLicenseExpiryState(ctx: BannerContext): LicenseExpiryState {
129168
}
130169
return null;
131170
}
171+
172+
// 'enforced' once entitlements.ts would strip entitlements; 'warning' in the
173+
// lead-up. Offline licenses don't ping, so they're never stale here.
174+
function getPingStaleness(ctx: BannerContext): 'warning' | 'enforced' | null {
175+
if (ctx.offlineLicense || !ctx.license) {
176+
return null;
177+
}
178+
// Matches the fail-safe in entitlements.ts: a license row without a
179+
// lastSyncAt means the first verification never succeeded.
180+
if (!ctx.license.lastSyncAt) {
181+
return 'enforced';
182+
}
183+
const stalenessMs = ctx.now.getTime() - ctx.license.lastSyncAt.getTime();
184+
if (stalenessMs > STALE_ONLINE_LICENSE_THRESHOLD_MS) {
185+
return 'enforced';
186+
}
187+
if (stalenessMs > STALE_ONLINE_LICENSE_WARNING_THRESHOLD_MS) {
188+
return 'warning';
189+
}
190+
return null;
191+
}

packages/web/src/app/(app)/components/banners/licenseExpiredBanner.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ interface LicenseExpiredBannerProps extends BannerProps {
99
source: 'offline' | 'online';
1010
}
1111

12-
// @todo: This should instead be a docs page that explains the enterprise offering.
12+
// @nocheckin: This should instead be a docs page that explains the enterprise offering.
1313
const ENTERPRISE_OFFERING_DOCS_LINK = "https://sourcebot.dev/pricing";
1414

1515
export function LicenseExpiredBanner({ id, dismissible, role, source }: LicenseExpiredBannerProps) {
@@ -37,7 +37,7 @@ export function LicenseExpiredBanner({ id, dismissible, role, source }: LicenseE
3737
id={id}
3838
dismissible={dismissible}
3939
icon={<AlertTriangle className="h-4 w-4 mt-0.5 text-destructive" />}
40-
title="License Expired"
40+
title="License expired"
4141
description={description}
4242
action={isOwner ? (
4343
<Button asChild size="sm" variant="outline">

0 commit comments

Comments
 (0)