From d6f922407ffc447f90ceed0794abaa59524b3500 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sat, 25 Jul 2026 14:13:38 +0100 Subject: [PATCH 1/5] feat(webapp): gate SSO on the hasSso entitlement instead of the Enterprise plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SSO settings surface compared the org's plan code against the literal string "enterprise", so granting SSO meant moving the org onto the enterprise plan and everything else that implies. Read the hasSso plan entitlement instead, which billing resolves as per-org override ?? plan default. Enterprise keeps SSO by default and any org can now be granted it without a plan change. Directory Sync effects are gated too, so revoking the entitlement stops SCIM provisioning rather than only freezing the settings page. An unreadable entitlement throws so the worker retries — effects are idempotent, and skipping would silently drop a directory change. Entitlement reads go through a new SWR cache namespace, replacing an uncached billing round-trip on every settings load. Requires @trigger.dev/platform 1.3.0: the older Limits schema strips the unknown hasSso key during parsing, so the field is invisible until the dependency is bumped. --- .server-changes/sso-entitlement.md | 6 +++ .../route.tsx | 17 +++---- .../services/directorySyncEffects.server.ts | 30 +++++++++++++ .../webapp/app/services/platform.v3.server.ts | 45 +++++++++++++++++++ apps/webapp/package.json | 2 +- 5 files changed, 89 insertions(+), 11 deletions(-) create mode 100644 .server-changes/sso-entitlement.md diff --git a/.server-changes/sso-entitlement.md b/.server-changes/sso-entitlement.md new file mode 100644 index 00000000000..2d9eabf58dc --- /dev/null +++ b/.server-changes/sso-entitlement.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +SSO and Directory Sync can now be enabled for any organization, rather than only those on an Enterprise plan. diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx index e58afdb65df..720074ba536 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx @@ -39,7 +39,7 @@ import { prisma } from "~/db.server"; import { useOrganization } from "~/hooks/useOrganizations"; import { rbac } from "~/services/rbac.server"; import { ssoController } from "~/services/sso.server"; -import { getCurrentPlan } from "~/services/platform.v3.server"; +import { getSsoEntitlement } from "~/services/platform.v3.server"; import type { DirectorySyncEffect, DirectorySyncStatus, Role } from "@trigger.dev/plugins"; import { applyDirectorySyncEffects } from "~/services/directorySyncEffects.server"; import { flag } from "~/v3/featureFlags.server"; @@ -64,15 +64,15 @@ async function resolveOrg(slug: string) { function planAllowsSso(plan: unknown): boolean { if (!plan || typeof plan !== "object") return false; - const subscription = (plan as { v3Subscription?: { plan?: { code?: string } } }).v3Subscription; - return subscription?.plan?.code === "enterprise"; + const subscription = (plan as { v3Subscription?: { plan?: { limits?: { hasSso?: boolean } } } }) + .v3Subscription; + return subscription?.plan?.limits?.hasSso === true; } // Client-side upsell is cosmetic; gate real IdP mutations server-side. async function requireSsoEntitlement(orgId: string): Promise { - const plan = await getCurrentPlan(orgId); - if (!planAllowsSso(plan)) { - throw new Response("SSO requires an Enterprise plan", { status: 403 }); + if ((await getSsoEntitlement(orgId)) !== "entitled") { + throw new Response("This organization is not entitled to SSO", { status: 403 }); } } @@ -142,10 +142,7 @@ export const loader = dashboardLoader( throw new Response("Not Found", { status: 404 }); } - // Not Enterprise: render the upsell for every role, skip role check + - // queries, return empty data. - const plan = await getCurrentPlan(orgId); - if (!planAllowsSso(plan)) { + if ((await getSsoEntitlement(orgId)) !== "entitled") { return typedjson({ status: EMPTY_SSO_STATUS, orgTitle: context.orgTitle, diff --git a/apps/webapp/app/services/directorySyncEffects.server.ts b/apps/webapp/app/services/directorySyncEffects.server.ts index 24e1dade5a2..555bb6f98ab 100644 --- a/apps/webapp/app/services/directorySyncEffects.server.ts +++ b/apps/webapp/app/services/directorySyncEffects.server.ts @@ -8,6 +8,7 @@ import { removeOrgMemberForDirectory, } from "~/models/orgMember.server"; import { createPlatformNotification } from "~/services/platformNotifications.server"; +import { getSsoEntitlement, type SsoEntitlement } from "~/services/platform.v3.server"; const LAST_OWNER_NOTIFICATION_TITLE = "Directory Sync: last Owner protected"; @@ -145,8 +146,37 @@ async function applyEffect(effect: DirectorySyncEffect): Promise { } } +/** + * Applies membership effects, skipping any org that isn't entitled to SSO. + * + * An unreadable entitlement throws rather than skipping: effects are + * idempotent and the worker retries, so retrying is lossless where dropping + * would silently lose a directory change. + */ export async function applyDirectorySyncEffects(effects: DirectorySyncEffect[]): Promise { + const entitlements = new Map(); + for (const effect of effects) { + let entitlement = entitlements.get(effect.organizationId); + if (entitlement === undefined) { + entitlement = await getSsoEntitlement(effect.organizationId); + entitlements.set(effect.organizationId, entitlement); + } + + if (entitlement === "unknown") { + throw retryableEffectError( + `directory sync: could not read the SSO entitlement for organization ${effect.organizationId}` + ); + } + + if (entitlement === "not_entitled") { + logger.warn("Directory Sync: skipping effect for org without the SSO entitlement", { + organizationId: effect.organizationId, + kind: effect.kind, + }); + continue; + } + await applyEffect(effect); } } diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index bded0b92065..ae162964cec 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -196,6 +196,11 @@ function initializePlatformCache() { fresh: 60_000, stale: 120_000, }), + ssoEntitlement: new Namespace(ctx, { + stores: [memory, redisCacheStore], + fresh: 60_000, + stale: 120_000, + }), }); return cache; @@ -206,6 +211,7 @@ const platformCache = singleton("platformCache", initializePlatformCache); function invalidateBillingLimitCaches(organizationId: string) { platformCache.billingLimit.remove(organizationId).catch(() => {}); platformCache.entitlement.remove(organizationId).catch(() => {}); + platformCache.ssoEntitlement.remove(organizationId).catch(() => {}); } export function bustBillingLimitCaches(organizationId: string) { @@ -757,6 +763,45 @@ export async function getEntitlement( return result.val; } +export type SsoEntitlement = "entitled" | "not_entitled" | "unknown"; + +/** + * Whether an org may configure and use SSO / Directory Sync. + * + * `unknown` means billing was configured but unreadable — callers decide: + * read paths show the upsell, mutations refuse, and the directory-sync + * worker throws so the effect is retried rather than silently dropped. + * + * Self-hosted deployments have no billing service, so the plugin's presence + * (plus the kill switch) is the only gate and this returns `entitled`. + * + * Loader errors are swallowed inside the loader for the same reason as + * `getEntitlement`: @unkey/cache passes the loader promise to waitUntil() + * with no .catch(), and returning undefined stops a transient billing + * failure from being cached as an access decision. + */ +export async function getSsoEntitlement(organizationId: string): Promise { + if (!client) return "entitled"; + + const result = await platformCache.ssoEntitlement.swr(organizationId, async () => { + try { + const response = await client.currentPlan(organizationId); + if (!response.success) { + recordPlatformFailure("getSsoEntitlement", "no_success"); + return undefined; + } + return response.v3Subscription?.plan?.limits?.hasSso === true; + } catch (_e) { + recordPlatformFailure("getSsoEntitlement", "caught"); + return undefined; + } + }); + + if (result.err || result.val === undefined) return "unknown"; + + return result.val ? "entitled" : "not_entitled"; +} + export type PromoCreditsData = { grantedCents: number; remainingCents: number; diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 678eeb76033..0eb149f77ed 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -117,7 +117,7 @@ "@trigger.dev/core": "workspace:*", "@trigger.dev/database": "workspace:*", "@trigger.dev/otlp-importer": "workspace:*", - "@trigger.dev/platform": "1.2.0", + "@trigger.dev/platform": "1.3.0", "@trigger.dev/plugins": "workspace:*", "@trigger.dev/rbac": "workspace:*", "@trigger.dev/redis-worker": "workspace:*", From a4a15d94d2b65f33dbd75e15552ed6748f87b43d Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 27 Jul 2026 12:09:45 +0100 Subject: [PATCH 2/5] test(webapp): cover the SSO entitlement gate on directory sync Locks in the three branches of the new gate: effects apply for an entitled org, are skipped for one without the entitlement (provision and deprovision alike, so revocation can't remove members), and an unreadable entitlement throws a warn-level retryable error rather than silently dropping a directory change. Also covers per-org memoisation across a batch and that one unentitled org doesn't block another. Picks up @trigger.dev/platform 1.3.0, which is what makes limits.hasSso visible to the webapp at all. --- .../test/directorySyncEffects.server.test.ts | 130 ++++++++++++++++++ pnpm-lock.yaml | 10 +- 2 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 apps/webapp/test/directorySyncEffects.server.test.ts diff --git a/apps/webapp/test/directorySyncEffects.server.test.ts b/apps/webapp/test/directorySyncEffects.server.test.ts new file mode 100644 index 00000000000..e8f9bb933cc --- /dev/null +++ b/apps/webapp/test/directorySyncEffects.server.test.ts @@ -0,0 +1,130 @@ +import type { DirectorySyncEffect } from "@trigger.dev/plugins"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} })); +vi.mock("~/services/platformNotifications.server", () => ({ + createPlatformNotification: vi.fn(), +})); + +const getSsoEntitlement = vi.fn(); +vi.mock("~/services/platform.v3.server", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { ...actual, getSsoEntitlement: (orgId: string) => getSsoEntitlement(orgId) }; +}); + +const setUserRole = vi.fn(); +vi.mock("~/services/rbac.server", () => ({ + rbac: { setUserRole: (a: unknown) => setUserRole(a) }, +})); + +const ensureOrgMember = vi.fn(); +const ensureUserForDirectory = vi.fn(); +const removeOrgMemberForDirectory = vi.fn(); +vi.mock("~/models/orgMember.server", () => ({ + ensureOrgMember: (a: unknown) => ensureOrgMember(a), + ensureUserForDirectory: (a: unknown) => ensureUserForDirectory(a), + removeOrgMemberForDirectory: (a: unknown) => removeOrgMemberForDirectory(a), +})); + +import { applyDirectorySyncEffects } from "~/services/directorySyncEffects.server"; + +const ENTITLED_ORG = "org_entitled"; +const UNENTITLED_ORG = "org_unentitled"; + +function provision(organizationId: string, email = "someone@acme.com"): DirectorySyncEffect { + return { + kind: "provision", + userId: "user_1", + email, + firstName: null, + lastName: null, + organizationId, + roleId: null, + }; +} + +function deprovision(organizationId: string): DirectorySyncEffect { + return { kind: "deprovision", userId: "user_1", organizationId }; +} + +describe("applyDirectorySyncEffects — SSO entitlement gate", () => { + beforeEach(() => { + vi.clearAllMocks(); + ensureOrgMember.mockResolvedValue(undefined); + removeOrgMemberForDirectory.mockResolvedValue({ removed: true }); + setUserRole.mockResolvedValue({ ok: true }); + }); + + it("applies effects for an entitled org", async () => { + getSsoEntitlement.mockResolvedValue("entitled"); + + await applyDirectorySyncEffects([provision(ENTITLED_ORG)]); + + expect(ensureOrgMember).toHaveBeenCalledTimes(1); + expect(ensureOrgMember).toHaveBeenCalledWith( + expect.objectContaining({ organizationId: ENTITLED_ORG, source: "directory_sync" }) + ); + }); + + it("skips provisioning for an org without the entitlement", async () => { + getSsoEntitlement.mockResolvedValue("not_entitled"); + + await applyDirectorySyncEffects([provision(UNENTITLED_ORG)]); + + expect(ensureOrgMember).not.toHaveBeenCalled(); + expect(ensureUserForDirectory).not.toHaveBeenCalled(); + }); + + it("skips deprovisioning too, so revocation cannot remove members", async () => { + getSsoEntitlement.mockResolvedValue("not_entitled"); + + await applyDirectorySyncEffects([deprovision(UNENTITLED_ORG)]); + + expect(removeOrgMemberForDirectory).not.toHaveBeenCalled(); + }); + + it("throws on an unreadable entitlement so the worker retries", async () => { + getSsoEntitlement.mockResolvedValue("unknown"); + + await expect(applyDirectorySyncEffects([provision(ENTITLED_ORG)])).rejects.toThrow( + /could not read the SSO entitlement/ + ); + + expect(ensureOrgMember).not.toHaveBeenCalled(); + }); + + it("marks the retry as a warning rather than a pageable error", async () => { + getSsoEntitlement.mockResolvedValue("unknown"); + + await applyDirectorySyncEffects([provision(ENTITLED_ORG)]).then( + () => expect.unreachable("should have thrown"), + (error) => expect(error).toMatchObject({ logLevel: "warn" }) + ); + }); + + it("resolves the entitlement once per org across a batch", async () => { + getSsoEntitlement.mockResolvedValue("entitled"); + + await applyDirectorySyncEffects([ + provision(ENTITLED_ORG, "a@acme.com"), + provision(ENTITLED_ORG, "b@acme.com"), + provision(ENTITLED_ORG, "c@acme.com"), + ]); + + expect(getSsoEntitlement).toHaveBeenCalledTimes(1); + expect(ensureOrgMember).toHaveBeenCalledTimes(3); + }); + + it("gates per org, so one unentitled org does not block another", async () => { + getSsoEntitlement.mockImplementation(async (orgId: string) => + orgId === ENTITLED_ORG ? "entitled" : "not_entitled" + ); + + await applyDirectorySyncEffects([provision(UNENTITLED_ORG), provision(ENTITLED_ORG)]); + + expect(ensureOrgMember).toHaveBeenCalledTimes(1); + expect(ensureOrgMember).toHaveBeenCalledWith( + expect.objectContaining({ organizationId: ENTITLED_ORG }) + ); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b09dc718a6f..12e28056ddf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -478,8 +478,8 @@ importers: specifier: workspace:* version: link:../../internal-packages/otlp-importer '@trigger.dev/platform': - specifier: 1.2.0 - version: 1.2.0(zod@3.25.76) + specifier: 1.3.0 + version: 1.3.0(zod@3.25.76) '@trigger.dev/plugins': specifier: workspace:* version: link:../../packages/plugins @@ -7681,8 +7681,8 @@ packages: react: 18.3.1 react-dom: 18.3.1 - '@trigger.dev/platform@1.2.0': - resolution: {integrity: sha512-Wa/XlMlmo1vhol5DEBYW1gMW4wgUUqr/ClDQb4IgwrRdPeLLmTpIVsgMHmYJXkERqR04xVaX7wBibWl4sW+1Hg==} + '@trigger.dev/platform@1.3.0': + resolution: {integrity: sha512-DIJ5Sy4X5XKSHQdYA3GImkb0jdE9I/HTRRRB3c9CFuXjGzhK3rJkEzjIEbaNRjk6yAev67EVTWY8P+D+khorUw==} peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -22334,7 +22334,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@trigger.dev/platform@1.2.0(zod@3.25.76)': + '@trigger.dev/platform@1.3.0(zod@3.25.76)': dependencies: zod: 3.25.76 From f616908ab644ea26ded15b3c609ff3d0e7a58884 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 27 Jul 2026 12:15:57 +0100 Subject: [PATCH 3/5] fix(webapp): drive the SSO upsell from the loader's entitlement decision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client recomputed the gate from useCurrentPlan(), which is undefined wherever billing isn't configured. The server grants those deployments access, so it returned the real config surface while the page rendered the upgrade prompt over the top of it — self-hosters who installed the plugin could never reach the settings. Pass isEntitled down from the loader so there is one decision, made server-side. Removes the last plan-shaped read in the file. --- .../route.tsx | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx index 720074ba536..46abc2d4f2b 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx @@ -47,7 +47,6 @@ import { FEATURE_FLAG } from "~/v3/featureFlags"; import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { cn } from "~/utils/cn"; import { throwPermissionDenied } from "~/utils/permissionDenied"; -import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; export const meta: MetaFunction = () => [{ title: "SSO & Directory Sync | Trigger.dev" }]; @@ -62,13 +61,6 @@ async function resolveOrg(slug: string) { }); } -function planAllowsSso(plan: unknown): boolean { - if (!plan || typeof plan !== "object") return false; - const subscription = (plan as { v3Subscription?: { plan?: { limits?: { hasSso?: boolean } } } }) - .v3Subscription; - return subscription?.plan?.limits?.hasSso === true; -} - // Client-side upsell is cosmetic; gate real IdP mutations server-side. async function requireSsoEntitlement(orgId: string): Promise { if ((await getSsoEntitlement(orgId)) !== "entitled") { @@ -149,6 +141,7 @@ export const loader = dashboardLoader( jitRoles: [] as Role[], directorySync: EMPTY_DIRECTORY_SYNC_STATUS, hasSso: false, + isEntitled: false, }); } @@ -179,6 +172,7 @@ export const loader = dashboardLoader( jitRoles, directorySync, hasSso, + isEntitled: true, }); } ); @@ -371,11 +365,10 @@ function useOverrideDraft(serverValue: T): { } export default function Page() { - const { status, orgTitle, jitRoles, directorySync, hasSso } = useTypedLoaderData(); + const { status, orgTitle, jitRoles, directorySync, hasSso, isEntitled } = + useTypedLoaderData(); const organization = useOrganization(); - const _plan = useCurrentPlan(); - const isEntitled = planAllowsSso(_plan); const activeConnections = status.connections.filter((c) => c.state === "active"); const hasActive = activeConnections.length > 0; From 6818be9529b7b1f3611a2079d24d20fc1f67b1fd Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 27 Jul 2026 12:16:38 +0100 Subject: [PATCH 4/5] docs(webapp): clarify the SSO release note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous wording read as though SSO had become self-serve for everyone. It still has to be switched on per organization — what changed is that the plan no longer decides. --- .server-changes/sso-entitlement.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.server-changes/sso-entitlement.md b/.server-changes/sso-entitlement.md index 2d9eabf58dc..4a039b6855e 100644 --- a/.server-changes/sso-entitlement.md +++ b/.server-changes/sso-entitlement.md @@ -3,4 +3,4 @@ area: webapp type: improvement --- -SSO and Directory Sync can now be enabled for any organization, rather than only those on an Enterprise plan. +SSO and Directory Sync are no longer restricted to Enterprise plans — get in touch and we can turn them on for your organization whatever plan you're on. From 5bb49da44fa78872b112e6cb3fb8363987301120 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 27 Jul 2026 12:22:59 +0100 Subject: [PATCH 5/5] fix(webapp): bust the SSO entitlement cache on plan changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../webapp/app/services/platform.v3.server.ts | 51 ++++++++++++------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index ae162964cec..f8b524e3cdd 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -218,6 +218,16 @@ export function bustBillingLimitCaches(organizationId: string) { invalidateBillingLimitCaches(organizationId); } +/** + * Clears the caches whose value is derived from the org's plan. Call after a + * plan change — a downgrade can revoke SSO, and serving the previous decision + * for the stale TTL would keep a surface open that the new plan doesn't allow. + */ +function invalidatePlanDerivedCaches(organizationId: string) { + platformCache.entitlement.remove(organizationId).catch(() => {}); + platformCache.ssoEntitlement.remove(organizationId).catch(() => {}); +} + // Clear the cached promo-credits read so a just-granted code shows on the usage // page immediately rather than after the stale TTL. export function bustPromoCreditsCache(organizationId: string) { @@ -543,7 +553,7 @@ export async function setPlan( case "free_connected": { // Selecting Free provisions the plan directly, so any free result is a success. opts?.invalidateBillingCache?.(organization.id); - platformCache.entitlement.remove(organization.id).catch(() => {}); + invalidatePlanDerivedCaches(organization.id); const response = redirect(newProjectPath(organization, "You're on the Free plan.")); await opts?.onFreePlanProvisioned?.(response); return response; @@ -554,13 +564,13 @@ export async function setPlan( case "updated_subscription": { // Invalidate billing cache since subscription changed opts?.invalidateBillingCache?.(organization.id); - platformCache.entitlement.remove(organization.id).catch(() => {}); + invalidatePlanDerivedCaches(organization.id); return redirectWithSuccessMessage(callerPath, request, "Subscription updated successfully."); } case "canceled_subscription": { // Invalidate billing cache since subscription was canceled opts?.invalidateBillingCache?.(organization.id); - platformCache.entitlement.remove(organization.id).catch(() => {}); + invalidatePlanDerivedCaches(organization.id); return redirectWithSuccessMessage(callerPath, request, "Subscription canceled."); } } @@ -778,28 +788,35 @@ export type SsoEntitlement = "entitled" | "not_entitled" | "unknown"; * Loader errors are swallowed inside the loader for the same reason as * `getEntitlement`: @unkey/cache passes the loader promise to waitUntil() * with no .catch(), and returning undefined stops a transient billing - * failure from being cached as an access decision. + * failure from being cached as an access decision. The SWR read is guarded + * too, so a cache-infra failure resolves to `unknown` rather than rejecting + * into the settings loader and the directory-sync worker. */ export async function getSsoEntitlement(organizationId: string): Promise { if (!client) return "entitled"; - const result = await platformCache.ssoEntitlement.swr(organizationId, async () => { - try { - const response = await client.currentPlan(organizationId); - if (!response.success) { - recordPlatformFailure("getSsoEntitlement", "no_success"); + try { + const result = await platformCache.ssoEntitlement.swr(organizationId, async () => { + try { + const response = await client.currentPlan(organizationId); + if (!response.success) { + recordPlatformFailure("getSsoEntitlement", "no_success"); + return undefined; + } + return response.v3Subscription?.plan?.limits?.hasSso === true; + } catch (_e) { + recordPlatformFailure("getSsoEntitlement", "caught"); return undefined; } - return response.v3Subscription?.plan?.limits?.hasSso === true; - } catch (_e) { - recordPlatformFailure("getSsoEntitlement", "caught"); - return undefined; - } - }); + }); - if (result.err || result.val === undefined) return "unknown"; + if (result.err || result.val === undefined) return "unknown"; - return result.val ? "entitled" : "not_entitled"; + return result.val ? "entitled" : "not_entitled"; + } catch (_e) { + recordPlatformFailure("getSsoEntitlement", "caught"); + return "unknown"; + } } export type PromoCreditsData = {