From 49c5a00233878ddef1087f5b261f2120900844f1 Mon Sep 17 00:00:00 2001 From: wangsijie Date: Mon, 14 Sep 2026 10:05:00 +0000 Subject: [PATCH] feat(schemas,console): add self-hosted license payload guard and OSS default quota Add the shared license contract so Core and Console read one shape: the `self-hosted-pro` and `self-hosted-enterprise` reserved plan IDs, the signed license payload guard with its entitlements, the `license` system key holding an installed key, and the `ossDefaultQuota` fallback for an instance without one. Extend the Console SKU name map for the two new reserved plan IDs to keep its exhaustiveness check satisfied. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UNfQWWRxGBra4UB5RHAzUy --- .../console/src/components/SkuName/index.tsx | 2 + packages/schemas/src/consts/index.ts | 1 + packages/schemas/src/consts/license.test.ts | 126 ++++++++++++++ packages/schemas/src/consts/license.ts | 154 ++++++++++++++++++ packages/schemas/src/consts/subscriptions.ts | 12 ++ packages/schemas/src/types/system.test.ts | 22 +++ packages/schemas/src/types/system.ts | 40 ++++- 7 files changed, 354 insertions(+), 3 deletions(-) create mode 100644 packages/schemas/src/consts/license.test.ts create mode 100644 packages/schemas/src/consts/license.ts create mode 100644 packages/schemas/src/types/system.test.ts diff --git a/packages/console/src/components/SkuName/index.tsx b/packages/console/src/components/SkuName/index.tsx index 15dedbb68538..b2ed2cec76ba 100644 --- a/packages/console/src/components/SkuName/index.tsx +++ b/packages/console/src/components/SkuName/index.tsx @@ -12,6 +12,8 @@ const registeredPlanNamePhraseMap: Record< [ReservedPlanId.Pro202509]: 'pro_plan', [ReservedPlanId.Development]: 'dev_plan', [ReservedPlanId.Admin]: 'admin_plan', + [ReservedPlanId.SelfHostedPro]: 'pro_plan', + [ReservedPlanId.SelfHostedEnterprise]: 'enterprise', } satisfies Record>; const getRegisteredSkuNamePhrase = ( diff --git a/packages/schemas/src/consts/index.ts b/packages/schemas/src/consts/index.ts index 09533ff6f5cc..160ae551e778 100644 --- a/packages/schemas/src/consts/index.ts +++ b/packages/schemas/src/consts/index.ts @@ -11,3 +11,4 @@ export * from './verification-code.js'; export * from './product-event.js'; export * from './application.js'; export * from './social-state.js'; +export * from './license.js'; diff --git a/packages/schemas/src/consts/license.test.ts b/packages/schemas/src/consts/license.test.ts new file mode 100644 index 000000000000..24f0b7dae59c --- /dev/null +++ b/packages/schemas/src/consts/license.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest'; + +import { + LicenseEnv, + licensePayloadGuard, + ossDefaultQuota, + resolveLicenseQuota, +} from './license.js'; +import { ReservedPlanId } from './subscriptions.js'; + +const validPayload = Object.freeze({ + plan: ReservedPlanId.SelfHostedPro, + env: LicenseEnv.Production, + customerId: 'cus_1234567890', + licenseId: 'lic_1234567890', + iat: 1_760_000_000, + exp: 1_791_536_000, + quota: { + hideLogtoBranding: true, + bringYourUi: true, + idpInitiatedSso: true, + consoleCollaboration: true, + mandatoryMfa: true, + hostedEmail: false, + samlApplicationsLimit: null, + }, +}); + +describe('licensePayloadGuard', () => { + it('parses a production Pro license payload', () => { + expect(licensePayloadGuard.parse(validPayload)).toEqual(validPayload); + }); + + it('parses a non-production Enterprise license payload', () => { + const payload = { + ...validPayload, + plan: ReservedPlanId.SelfHostedEnterprise, + env: LicenseEnv.NonProduction, + }; + + expect(licensePayloadGuard.parse(payload)).toEqual(payload); + }); + + it('parses a payload carrying only the entitlements it changes', () => { + const payload = { ...validPayload, quota: { hideLogtoBranding: true } }; + + expect(licensePayloadGuard.parse(payload)).toEqual(payload); + }); + + it.each([ReservedPlanId.Free, ReservedPlanId.Pro202509, ReservedPlanId.Development])( + 'rejects the Cloud-only plan %s', + (plan) => { + expect(licensePayloadGuard.safeParse({ ...validPayload, plan }).success).toBe(false); + } + ); + + it('rejects an unknown environment', () => { + expect(licensePayloadGuard.safeParse({ ...validPayload, env: 'staging' }).success).toBe(false); + }); + + it.each([-1, 1.5, '3'])('rejects the SAML application limit %j', (samlApplicationsLimit) => { + expect( + licensePayloadGuard.safeParse({ + ...validPayload, + quota: { ...validPayload.quota, samlApplicationsLimit }, + }).success + ).toBe(false); + }); + + it('strips unknown claims instead of rejecting them', () => { + expect(licensePayloadGuard.parse({ ...validPayload, iss: 'https://logto.io' })).toEqual( + validPayload + ); + }); +}); + +describe('ossDefaultQuota', () => { + it('locks every feature and caps SAML applications at 3', () => { + expect(ossDefaultQuota).toStrictEqual({ + hideLogtoBranding: false, + bringYourUi: false, + idpInitiatedSso: false, + consoleCollaboration: false, + mandatoryMfa: false, + hostedEmail: false, + samlApplicationsLimit: 3, + }); + }); +}); + +describe('resolveLicenseQuota', () => { + it('falls back to the OSS defaults without a license', () => { + expect(resolveLicenseQuota()).toStrictEqual(ossDefaultQuota); + }); + + it('applies every entitlement a license grants', () => { + expect(resolveLicenseQuota(validPayload.quota)).toStrictEqual({ + hideLogtoBranding: true, + bringYourUi: true, + idpInitiatedSso: true, + consoleCollaboration: true, + mandatoryMfa: true, + hostedEmail: false, + samlApplicationsLimit: null, + }); + }); + + it('keeps the OSS default for an entitlement the license does not carry', () => { + expect(resolveLicenseQuota({ hideLogtoBranding: true })).toStrictEqual({ + ...ossDefaultQuota, + hideLogtoBranding: true, + }); + }); + + it('keeps an unlimited SAML application quota instead of falling back to the cap', () => { + expect(resolveLicenseQuota({ samlApplicationsLimit: null }).samlApplicationsLimit).toBeNull(); + }); + + it('applies a SAML application quota of 0', () => { + expect(resolveLicenseQuota({ samlApplicationsLimit: 0 }).samlApplicationsLimit).toBe(0); + }); + + it('applies an explicitly disabled entitlement', () => { + expect(resolveLicenseQuota({ hostedEmail: false }).hostedEmail).toBe(false); + }); +}); diff --git a/packages/schemas/src/consts/license.ts b/packages/schemas/src/consts/license.ts new file mode 100644 index 000000000000..08e998115149 --- /dev/null +++ b/packages/schemas/src/consts/license.ts @@ -0,0 +1,154 @@ +import { z } from 'zod'; + +import { type ToZodObject } from '../utils/zod.js'; + +import { ReservedPlanId } from './subscriptions.js'; + +/** + * The deployment a license key is valid for. + * + * Logto signs one key per environment for the same license, identical except for this claim, so a + * customer can run their staging or CI deployment without sharing the production key. Which key is + * installed where is the customer's declaration; Logto does not verify it. + */ +export enum LicenseEnv { + Production = 'production', + NonProduction = 'non-production', +} + +/** The plans a license key can grant. A Cloud-only plan is never a valid license `plan`. */ +export const selfHostedPlanIds = Object.freeze([ + ReservedPlanId.SelfHostedPro, + ReservedPlanId.SelfHostedEnterprise, +] as const); + +/** The plan a license key grants. See {@link selfHostedPlanIds}. */ +export type SelfHostedPlanId = (typeof selfHostedPlanIds)[number]; + +/** + * The entitlements of a self-hosted instance: the features a license unlocks, plus the numeric + * quotas it raises. + * + * The names follow the license payload rather than the Cloud `SubscriptionQuota` vocabulary + * (`bringYourUi`, not `bringYourUiEnabled`): these values are resolved locally from an installed + * key and never travel through the Cloud subscription API or its usage reporting. + * `samlApplicationsLimit` is the one name shared with Cloud, and it keeps the same meaning there — + * `null` is unlimited, a number is the hard cap. + */ +export type LicenseQuota = { + /** Whether the sign-in experience may hide the Logto branding. */ + hideLogtoBranding: boolean; + /** Whether custom sign-in experience assets and a custom CSP may be configured. */ + bringYourUi: boolean; + /** Whether IdP-initiated SSO may be configured on an enterprise SSO connector. */ + idpInitiatedSso: boolean; + /** Whether the instance's Console may be administered by more than one member. */ + consoleCollaboration: boolean; + /** Whether the sign-in experience MFA policy may be set to mandatory. */ + mandatoryMfa: boolean; + /** + * Whether Logto's hosted email service may be used. + * + * Always `false` for {@link ReservedPlanId.SelfHostedPro}. The Enterprise offering is deferred, + * so the flag is carried from the first signed key onwards and simply never set, which keeps the + * signed shape stable when Enterprise ships. + */ + hostedEmail: boolean; + /** How many SAML applications the instance may create. `null` means unlimited. */ + samlApplicationsLimit: number | null; +}; + +export const licenseQuotaGuard = z.object({ + hideLogtoBranding: z.boolean(), + bringYourUi: z.boolean(), + idpInitiatedSso: z.boolean(), + consoleCollaboration: z.boolean(), + mandatoryMfa: z.boolean(), + hostedEmail: z.boolean(), + samlApplicationsLimit: z.number().int().nonnegative().nullable(), +}) satisfies ToZodObject; + +/** + * The entitlements a license key grants, as they appear in its payload. + * + * Every field is optional: a key only carries what it changes, and an absent field falls back to + * {@link ossDefaultQuota}. Keeping it partial means a key signed before a new entitlement existed + * still verifies on a newer Logto, and the missing entitlement stays locked rather than breaking + * the whole license. Use {@link resolveLicenseQuota} to apply the overrides. + */ +export const licenseQuotaOverridesGuard = licenseQuotaGuard.partial(); + +export type LicenseQuotaOverrides = z.infer; + +/** + * The payload of a license key, i.e. the claims of the Ed25519-signed JWT an operator installs on a + * self-hosted instance. + * + * The signature is what makes the payload trustworthy, so none of it is editable after issuing. + * Unknown claims are stripped rather than rejected, so a newer license service can add one without + * invalidating the key on an older Logto. + */ +export type LicensePayload = { + /** The plan the license grants. */ + plan: SelfHostedPlanId; + /** The deployment the key is valid for. */ + env: LicenseEnv; + /** The Logto customer the license was issued to, for support and renewal lookups. */ + customerId: string; + /** The license's own identifier, shared by its production and non-production keys. */ + licenseId: string; + /** When the key was signed, in seconds since the Unix epoch. */ + iat: number; + /** When the key expires, in seconds since the Unix epoch. */ + exp: number; + /** The entitlements the license grants on top of {@link ossDefaultQuota}. */ + quota: LicenseQuotaOverrides; +}; + +export const licensePayloadGuard = z.object({ + plan: z.enum(selfHostedPlanIds), + env: z.nativeEnum(LicenseEnv), + customerId: z.string(), + licenseId: z.string(), + iat: z.number().int().nonnegative(), + exp: z.number().int().nonnegative(), + quota: licenseQuotaOverridesGuard, +}) satisfies ToZodObject; + +/** + * The entitlements of a self-hosted instance with no license key installed, i.e. today's OSS. + * + * Every feature is locked and SAML applications keep the long-standing cap of 3. Core falls back to + * this when no valid license is installed, and Console displays it, so there is exactly one + * definition of "self-hosted, no license". + */ +export const ossDefaultQuota = Object.freeze({ + hideLogtoBranding: false, + bringYourUi: false, + idpInitiatedSso: false, + consoleCollaboration: false, + mandatoryMfa: false, + hostedEmail: false, + samlApplicationsLimit: 3, +} satisfies LicenseQuota); + +/** + * Resolve the effective entitlements of a self-hosted instance by applying a license key's + * overrides on top of {@link ossDefaultQuota}. Without a key, the OSS defaults are the answer. + * + * Core and Console both call this so they can never disagree on what a license grants. The fields + * are applied one by one on purpose: `samlApplicationsLimit` is nullable and `null` means + * unlimited, so a `??` merge would silently turn an unlimited license back into the OSS cap. + */ +export const resolveLicenseQuota = (overrides: LicenseQuotaOverrides = {}): LicenseQuota => ({ + hideLogtoBranding: overrides.hideLogtoBranding ?? ossDefaultQuota.hideLogtoBranding, + bringYourUi: overrides.bringYourUi ?? ossDefaultQuota.bringYourUi, + idpInitiatedSso: overrides.idpInitiatedSso ?? ossDefaultQuota.idpInitiatedSso, + consoleCollaboration: overrides.consoleCollaboration ?? ossDefaultQuota.consoleCollaboration, + mandatoryMfa: overrides.mandatoryMfa ?? ossDefaultQuota.mandatoryMfa, + hostedEmail: overrides.hostedEmail ?? ossDefaultQuota.hostedEmail, + samlApplicationsLimit: + overrides.samlApplicationsLimit === undefined + ? ossDefaultQuota.samlApplicationsLimit + : overrides.samlApplicationsLimit, +}); diff --git a/packages/schemas/src/consts/subscriptions.ts b/packages/schemas/src/consts/subscriptions.ts index 882b8e7042ec..d97fa7b6b2ec 100644 --- a/packages/schemas/src/consts/subscriptions.ts +++ b/packages/schemas/src/consts/subscriptions.ts @@ -28,6 +28,18 @@ export enum ReservedPlanId { * Latest Pro plan ID applied from 2025-09. */ Pro202509 = 'pro-202509', + /** + * Self-hosted Pro plan, unlocked by installing a license key on a self-hosted instance. + * + * Unlike the plans above, this one is never returned by the Cloud subscription API: it is carried + * in the signed license payload and resolved locally. See `licensePayloadGuard`. + */ + SelfHostedPro = 'self-hosted-pro', + /** + * Self-hosted Enterprise plan, unlocked by installing a license key on a self-hosted instance. + * See {@link SelfHostedPro}. + */ + SelfHostedEnterprise = 'self-hosted-enterprise', } /** diff --git a/packages/schemas/src/types/system.test.ts b/packages/schemas/src/types/system.test.ts new file mode 100644 index 000000000000..d0af660e57b3 --- /dev/null +++ b/packages/schemas/src/types/system.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { LicenseKey, systemGuards, systemKeys } from './system.js'; + +describe('license system entries', () => { + it('registers the license key, so the CLI accepts it', () => { + expect(systemKeys).toContain(LicenseKey.License); + }); + + it('parses an installed license row', () => { + const value = { jwt: 'header.payload.signature', installedAt: '2026-01-01T00:00:00.000Z' }; + + expect(systemGuards[LicenseKey.License].parse(value)).toEqual(value); + }); + + it('rejects an installed license row without a key', () => { + expect( + systemGuards[LicenseKey.License].safeParse({ installedAt: '2026-01-01T00:00:00.000Z' }) + .success + ).toBe(false); + }); +}); diff --git a/packages/schemas/src/types/system.ts b/packages/schemas/src/types/system.ts index b6387f2705e4..a7cf6b8f655f 100644 --- a/packages/schemas/src/types/system.ts +++ b/packages/schemas/src/types/system.ts @@ -224,26 +224,58 @@ export const cloudflareGuard: Readonly<{ [CloudflareKey.CustomJwtWorkerConfig]: customJwtWorkerConfigGuard, }); +// Self-hosted license +/** + * The license key installed on a self-hosted instance, stored verbatim so it can be re-verified on + * every read and re-signed on refresh. The `systems` table is global, so one deployment installs + * one license no matter how many instances share the database. + */ +export const installedLicenseGuard = z.object({ + /** The raw Ed25519-signed license key JWT, exactly as it was installed. */ + jwt: z.string(), + /** When the key was installed, as an ISO 8601 timestamp. */ + installedAt: z.string(), +}); + +export type InstalledLicense = z.infer; + +export enum LicenseKey { + License = 'license', +} + +export type LicenseType = { + [LicenseKey.License]: InstalledLicense; +}; + +export const licenseGuard: Readonly<{ + [key in LicenseKey]: ZodType; +}> = Object.freeze({ + [LicenseKey.License]: installedLicenseGuard, +}); + // Summary export type SystemKey = | AlterationStateKey | StorageProviderKey | DemoSocialKey | CloudflareKey - | EmailServiceProviderKey; + | EmailServiceProviderKey + | LicenseKey; export type SystemType = | AlterationStateType | StorageProviderType | DemoSocialType | CloudflareType - | EmailServiceProviderType; + | EmailServiceProviderType + | LicenseType; export type SystemGuard = typeof alterationStateGuard & typeof storageProviderGuard & typeof demoSocialGuard & typeof cloudflareGuard & - typeof emailServiceProviderGuard; + typeof emailServiceProviderGuard & + typeof licenseGuard; export const systemKeys: readonly SystemKey[] = Object.freeze([ ...Object.values(AlterationStateKey), @@ -251,6 +283,7 @@ export const systemKeys: readonly SystemKey[] = Object.freeze([ ...Object.values(DemoSocialKey), ...Object.values(CloudflareKey), ...Object.values(EmailServiceProviderKey), + ...Object.values(LicenseKey), ]); export const systemGuards: SystemGuard = Object.freeze({ @@ -259,4 +292,5 @@ export const systemGuards: SystemGuard = Object.freeze({ ...demoSocialGuard, ...cloudflareGuard, ...emailServiceProviderGuard, + ...licenseGuard, });