Skip to content

Commit 5bb49da

Browse files
committed
fix(webapp): bust the SSO entitlement cache on plan changes
The entitlement is derived from the org's plan, so a downgrade off an SSO-bearing plan could keep the surface open for the stale TTL. Only the billing-limit path cleared it; the three setPlan branches cleared the sibling entitlement cache and left this one behind. Share one helper across those branches so a plan change clears both. Also guard the SWR read itself, matching getBillingLimit: a cache-infra failure now resolves to "unknown" — upsell on the settings page, retry in the directory-sync worker — instead of rejecting into both.
1 parent 6818be9 commit 5bb49da

1 file changed

Lines changed: 34 additions & 17 deletions

File tree

apps/webapp/app/services/platform.v3.server.ts

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,16 @@ export function bustBillingLimitCaches(organizationId: string) {
218218
invalidateBillingLimitCaches(organizationId);
219219
}
220220

221+
/**
222+
* Clears the caches whose value is derived from the org's plan. Call after a
223+
* plan change — a downgrade can revoke SSO, and serving the previous decision
224+
* for the stale TTL would keep a surface open that the new plan doesn't allow.
225+
*/
226+
function invalidatePlanDerivedCaches(organizationId: string) {
227+
platformCache.entitlement.remove(organizationId).catch(() => {});
228+
platformCache.ssoEntitlement.remove(organizationId).catch(() => {});
229+
}
230+
221231
// Clear the cached promo-credits read so a just-granted code shows on the usage
222232
// page immediately rather than after the stale TTL.
223233
export function bustPromoCreditsCache(organizationId: string) {
@@ -543,7 +553,7 @@ export async function setPlan(
543553
case "free_connected": {
544554
// Selecting Free provisions the plan directly, so any free result is a success.
545555
opts?.invalidateBillingCache?.(organization.id);
546-
platformCache.entitlement.remove(organization.id).catch(() => {});
556+
invalidatePlanDerivedCaches(organization.id);
547557
const response = redirect(newProjectPath(organization, "You're on the Free plan."));
548558
await opts?.onFreePlanProvisioned?.(response);
549559
return response;
@@ -554,13 +564,13 @@ export async function setPlan(
554564
case "updated_subscription": {
555565
// Invalidate billing cache since subscription changed
556566
opts?.invalidateBillingCache?.(organization.id);
557-
platformCache.entitlement.remove(organization.id).catch(() => {});
567+
invalidatePlanDerivedCaches(organization.id);
558568
return redirectWithSuccessMessage(callerPath, request, "Subscription updated successfully.");
559569
}
560570
case "canceled_subscription": {
561571
// Invalidate billing cache since subscription was canceled
562572
opts?.invalidateBillingCache?.(organization.id);
563-
platformCache.entitlement.remove(organization.id).catch(() => {});
573+
invalidatePlanDerivedCaches(organization.id);
564574
return redirectWithSuccessMessage(callerPath, request, "Subscription canceled.");
565575
}
566576
}
@@ -778,28 +788,35 @@ export type SsoEntitlement = "entitled" | "not_entitled" | "unknown";
778788
* Loader errors are swallowed inside the loader for the same reason as
779789
* `getEntitlement`: @unkey/cache passes the loader promise to waitUntil()
780790
* with no .catch(), and returning undefined stops a transient billing
781-
* failure from being cached as an access decision.
791+
* failure from being cached as an access decision. The SWR read is guarded
792+
* too, so a cache-infra failure resolves to `unknown` rather than rejecting
793+
* into the settings loader and the directory-sync worker.
782794
*/
783795
export async function getSsoEntitlement(organizationId: string): Promise<SsoEntitlement> {
784796
if (!client) return "entitled";
785797

786-
const result = await platformCache.ssoEntitlement.swr(organizationId, async () => {
787-
try {
788-
const response = await client.currentPlan(organizationId);
789-
if (!response.success) {
790-
recordPlatformFailure("getSsoEntitlement", "no_success");
798+
try {
799+
const result = await platformCache.ssoEntitlement.swr(organizationId, async () => {
800+
try {
801+
const response = await client.currentPlan(organizationId);
802+
if (!response.success) {
803+
recordPlatformFailure("getSsoEntitlement", "no_success");
804+
return undefined;
805+
}
806+
return response.v3Subscription?.plan?.limits?.hasSso === true;
807+
} catch (_e) {
808+
recordPlatformFailure("getSsoEntitlement", "caught");
791809
return undefined;
792810
}
793-
return response.v3Subscription?.plan?.limits?.hasSso === true;
794-
} catch (_e) {
795-
recordPlatformFailure("getSsoEntitlement", "caught");
796-
return undefined;
797-
}
798-
});
811+
});
799812

800-
if (result.err || result.val === undefined) return "unknown";
813+
if (result.err || result.val === undefined) return "unknown";
801814

802-
return result.val ? "entitled" : "not_entitled";
815+
return result.val ? "entitled" : "not_entitled";
816+
} catch (_e) {
817+
recordPlatformFailure("getSsoEntitlement", "caught");
818+
return "unknown";
819+
}
803820
}
804821

805822
export type PromoCreditsData = {

0 commit comments

Comments
 (0)