diff --git a/packages/api-core/src/features/wcp/WcpFeature.ts b/packages/api-core/src/features/wcp/WcpFeature.ts index 0728f6a530c..74514c6f67f 100644 --- a/packages/api-core/src/features/wcp/WcpFeature.ts +++ b/packages/api-core/src/features/wcp/WcpFeature.ts @@ -1,6 +1,7 @@ import { createFeature } from "@webiny/feature/api"; import { WcpContextFeature } from "./WcpContext/feature.js"; import { WcpContextWithFeatureFlagsDecorator } from "./WcpContext/decorators/WcpContextWithFeatureFlagsDecorator.js"; +import { FeatureFlagsWithLicenseDecorator } from "./decorators/FeatureFlagsWithLicenseDecorator.js"; import { WcpLicenseProvider, WcpLicenseProviderImpl } from "./WcpLicenseProvider.js"; import type { ILicense } from "@webiny/wcp/types.js"; @@ -11,7 +12,12 @@ export const WcpFeature = createFeature({ container.registerInstance(WcpLicenseProvider, provider); WcpContextFeature.register(container, provider); container.registerDecorator(WcpContextWithFeatureFlagsDecorator); - // Per-request license refresh (RequestInitializer) is registered by api-event-handler-core's - // registerApiRequestStack, keeping the domain layer free of the transport lifecycle contract. + + // Fold the live license into FeatureFlags so `FeatureFlags.get()` returns the effective flags + // (userFlag && license.canUseX()). This is what api-* read — they never touch WcpContext. + container.registerDecorator(FeatureFlagsWithLicenseDecorator); + // Per-request license refresh runs PRE-register (WcpLicenseRefreshDecorator on + // ChildContainerFactory, wired by api-event-handler-*), so the license is loaded before any + // register()-time flag check reads it. } }); diff --git a/packages/api-core/src/features/wcp/decorators/FeatureFlagsWithLicenseDecorator.ts b/packages/api-core/src/features/wcp/decorators/FeatureFlagsWithLicenseDecorator.ts new file mode 100644 index 00000000000..4aa3b038288 --- /dev/null +++ b/packages/api-core/src/features/wcp/decorators/FeatureFlagsWithLicenseDecorator.ts @@ -0,0 +1,83 @@ +import { FeatureFlags as FeatureFlagsClass } from "@webiny/feature-flags"; +import type { IFeatureFlagsDto, IAaclFeatureFlags } from "@webiny/feature-flags"; +import type { ILicense } from "@webiny/wcp/types.js"; +import { FeatureFlags } from "../../featureFlags/abstractions.js"; +import { getCachedWcpLicense } from "../loadWcpLicense.js"; + +/* Returns the user's value when the license permits it, otherwise false. Preserves an explicit user + * `false` (opt-out) while blocking features the license doesn't cover. */ +function applyLicenseFlag( + userValue: T, + licenseAllows: boolean +): T | false { + return licenseAllows ? userValue : false; +} + +/** + * Folds the live WCP license into the (user-defined) feature flags so `FeatureFlags.get()` returns + * the EFFECTIVE flags: `enabled = userFlag && license.canUseX()`. The license is read synchronously + * from the process cache, which the pre-register license refresh has already populated for this + * request — so this stays valid even when consumers read flags at register() time. + * + * Runtime counterpart of `@webiny/project`'s `GetFeatureFlagsWithLicense` (which applies the license + * at build time for the SDK); this one applies the live per-request license for the API. + */ +class FeatureFlagsWithLicenseDecoratorImpl implements FeatureFlags.Interface { + constructor(private decoratee: FeatureFlags.Interface) {} + + get(): FeatureFlagsClass { + const userFlags = this.decoratee.get(); + const license = getCachedWcpLicense(); + // toDto() returns a structuredClone, so mutating it here is safe. + return FeatureFlagsClass.fromDto(this.applyLicense(userFlags.toDto(), license)); + } + + /* For each licensable flag: effective = user_value && license_allows. fileManager (base) is + * always allowed; only threatDetection is license-restricted. */ + private applyLicense(dto: IFeatureFlagsDto, license: ILicense): IFeatureFlagsDto { + dto.multiTenancy = applyLicenseFlag( + dto.multiTenancy, + license.canUseFeature("multiTenancy") + ); + + dto.advancedPublishingWorkflow = applyLicenseFlag( + dto.advancedPublishingWorkflow, + license.canUseWorkflows() + ); + + if (dto.advancedAccessControlLayer !== false) { + if (!license.canUseAacl()) { + dto.advancedAccessControlLayer = false; + } else if (typeof dto.advancedAccessControlLayer === "object") { + const aacl = dto.advancedAccessControlLayer as IAaclFeatureFlags; + aacl.teams = applyLicenseFlag(aacl.teams, license.canUseTeams()); + aacl.privateFiles = applyLicenseFlag( + aacl.privateFiles, + license.canUsePrivateFiles() + ); + aacl.folderLevelPermissions = applyLicenseFlag( + aacl.folderLevelPermissions, + license.canUseFolderLevelPermissions() + ); + } + } + + dto.auditLogs = applyLicenseFlag(dto.auditLogs, license.canUseAuditLogs()); + dto.recordLocking = applyLicenseFlag(dto.recordLocking, license.canUseRecordLocking()); + + if (!dto.fileManager) { + dto.fileManager = {}; + } + dto.fileManager.threatDetection = applyLicenseFlag( + dto.fileManager.threatDetection, + license.canUseFileManagerThreatDetection() + ); + + return dto; + } +} + +export const FeatureFlagsWithLicenseDecorator = FeatureFlags.createDecorator({ + decorator: FeatureFlagsWithLicenseDecoratorImpl, + dependencies: [] +}); diff --git a/packages/api-core/src/features/wcp/loadWcpLicense.ts b/packages/api-core/src/features/wcp/loadWcpLicense.ts index fa679fd3fbb..c7e554e6274 100644 --- a/packages/api-core/src/features/wcp/loadWcpLicense.ts +++ b/packages/api-core/src/features/wcp/loadWcpLicense.ts @@ -48,33 +48,64 @@ const cachedLicense: CachedWcpProjectLicense = { license: new NullLicense() }; +// In-flight fetch, memoized so concurrent cache-miss callers coalesce onto a single WCP request +// (self-hosted runs many requests in one process; we never want two license fetches at once). +let inflight: Promise | null = null; + +/** + * Synchronously read the currently cached WCP license. `loadWcpLicense()` must have been awaited + * at least once this request (e.g. by the pre-register license refresh) for this to reflect the + * live license rather than the initial `NullLicense`. + */ +export function getCachedWcpLicense(): ILicense { + return cachedLicense.license; +} + export async function loadWcpLicense( testProjectLicense?: DecryptedWcpProjectLicense ): Promise { if (testProjectLicense) { cachedLicense.license = License.fromLicenseDto(testProjectLicense); - } else if (wcpProjectEnvironment) { - const currentCacheKey = getWcpProjectLicenseCacheKey(); - if (cachedLicense.cacheKey !== currentCacheKey) { - cachedLicense.cacheKey = currentCacheKey; - // Pull the project license from the WCP API. - const decryptedLicenseDto = await getWcpProjectLicense({ - orgId: wcpProjectEnvironment.org.id, - projectId: wcpProjectEnvironment.project.id, - projectEnvironmentApiKey: wcpProjectEnvironment.apiKey - }); - - if (decryptedLicenseDto) { - cachedLicense.project = { - orgId: decryptedLicenseDto.orgId, - projectId: decryptedLicenseDto.projectId, - package: decryptedLicenseDto.package - }; - } - - cachedLicense.license = License.fromLicenseDto(decryptedLicenseDto); - } + return cachedLicense.license; } - return cachedLicense.license; + if (!wcpProjectEnvironment) { + return cachedLicense.license; + } + + const currentCacheKey = getWcpProjectLicenseCacheKey(); + if (cachedLicense.cacheKey === currentCacheKey) { + return cachedLicense.license; + } + + // Cache miss (first load or the ~5-min key rotated). Coalesce concurrent callers onto one fetch. + if (inflight) { + return inflight; + } + + inflight = (async () => { + // Pull the project license from the WCP API. + const decryptedLicenseDto = await getWcpProjectLicense({ + orgId: wcpProjectEnvironment.org.id, + projectId: wcpProjectEnvironment.project.id, + projectEnvironmentApiKey: wcpProjectEnvironment.apiKey + }); + + if (decryptedLicenseDto) { + cachedLicense.project = { + orgId: decryptedLicenseDto.orgId, + projectId: decryptedLicenseDto.projectId, + package: decryptedLicenseDto.package + }; + } + + cachedLicense.license = License.fromLicenseDto(decryptedLicenseDto); + // Mark fresh only after a successful fetch — a throw leaves the key unset so the next call retries. + cachedLicense.cacheKey = currentCacheKey; + return cachedLicense.license; + })().finally(() => { + inflight = null; + }); + + return inflight; } diff --git a/packages/api-event-handler-core/src/registerApiRequestStack.ts b/packages/api-event-handler-core/src/registerApiRequestStack.ts index 7650bd88ee5..4465f927f30 100644 --- a/packages/api-event-handler-core/src/registerApiRequestStack.ts +++ b/packages/api-event-handler-core/src/registerApiRequestStack.ts @@ -3,6 +3,7 @@ import { registerExtensions } from "@webiny/handler"; import { GraphQLEngineFeature } from "@webiny/api-graphql"; import { ApiCoreFeature } from "@webiny/api-core"; import { WcpLicenseInitializer } from "./WcpLicenseInitializer.js"; +import { loadWcpLicense } from "@webiny/api-core/features/wcp/loadWcpLicense.js"; import { HeadlessCmsFeature } from "@webiny/api-headless-cms"; import { AcoHcmsFeature } from "@webiny/api-headless-cms-aco"; import { HcmsTasksFeature } from "@webiny/api-headless-cms-tasks"; @@ -82,6 +83,12 @@ export async function registerApiRequestStack( container: Container, config: RegisterApiRequestStackConfig ): Promise { + // Refresh the WCP license BEFORE any feature registers, so register()-time feature-flag checks + // (e.g. the private-files gate) read the live license via the process cache. Runs here — the + // shared request stack both hosting types call — so no handler needs to wire it. Process-cached + // (~5-min TTL) + single-flighted, so it's a cheap no-op on warm requests. + await loadWcpLicense(); + // ── Core API (per-request: EventPublisher + tenant/identity/request contexts must bind to the // request child container so per-request event handlers are resolvable) ───────── ApiCoreFeature.register(container, { wcpLicense: undefined }); diff --git a/packages/api-file-manager/src/features/assetDelivery/feature.ts b/packages/api-file-manager/src/features/assetDelivery/feature.ts index 663259e9aad..a6a4bdccdb4 100644 --- a/packages/api-file-manager/src/features/assetDelivery/feature.ts +++ b/packages/api-file-manager/src/features/assetDelivery/feature.ts @@ -1,5 +1,5 @@ import { createFeature } from "@webiny/feature/api"; -import { WcpContext } from "@webiny/api-core/features/wcp/WcpContext/index.js"; +import { FeatureFlags } from "@webiny/api-core/features/featureFlags/abstractions.js"; import { FilesAssetRequestResolverImpl } from "./FilesAssetRequestResolver.js"; import { NullAssetResolverImpl } from "./NullAssetResolver.js"; import { NullAssetOutputStrategyImpl } from "./NullAssetOutputStrategy.js"; @@ -27,8 +27,11 @@ export const AssetDeliveryFeature = createFeature({ container.registerDecorator(PrivateFileAssetRequestResolverDecorator); - const wcp = container.resolve(WcpContext); - if (wcp.canUsePrivateFiles()) { + // Register-time gate on the effective feature flags (userFlag && live WCP license). Valid at + // register() time because the license is refreshed PRE-register (WcpLicenseRefreshDecorator), + // and re-evaluated per request since the child re-registers — a license change takes effect + // on the next request. + if (container.resolve(FeatureFlags).get().isPrivateFilesEnabled()) { container.register(PrivateAuthenticatedAuthorizerImpl); container.registerDecorator(PrivateFilesAssetProcessorDecorator); } diff --git a/packages/event-handler-aws/src/createLambdaHandler.ts b/packages/event-handler-aws/src/createLambdaHandler.ts index 0e05707ef60..3e46eccfeff 100644 --- a/packages/event-handler-aws/src/createLambdaHandler.ts +++ b/packages/event-handler-aws/src/createLambdaHandler.ts @@ -1,3 +1,4 @@ +import type { Container } from "@webiny/di"; import { HandlerApp } from "@webiny/event-handler-core"; import type { HandlerSetup } from "@webiny/event-handler-core"; import type { Context } from "@webiny/aws-sdk/types/index.js"; @@ -6,6 +7,11 @@ import { awsLambdaTransport } from "./AwsLambdaTransport.js"; export interface CreateLambdaHandlerOptions { root: HandlerSetup; request?: HandlerSetup; + /** + * Decorate the handler app before first use (e.g. register a `ChildContainerFactory` decorator). + * Forwarded to `HandlerApp.init`. + */ + app?: (container: Container) => void; } /** @@ -17,7 +23,8 @@ export function createLambdaHandler(options: CreateLambdaHandlerOptions) { const app = HandlerApp.init({ root: options.root, request: options.request, - transport: awsLambdaTransport + transport: awsLambdaTransport, + app: options.app }); return (event: any, context?: Context): Promise => app.handle(event, context); diff --git a/packages/event-handler-server/src/createServerHandler.ts b/packages/event-handler-server/src/createServerHandler.ts index 5ac4d59f28e..4694a10378b 100644 --- a/packages/event-handler-server/src/createServerHandler.ts +++ b/packages/event-handler-server/src/createServerHandler.ts @@ -12,6 +12,11 @@ export interface CreateServerHandlerOptions { * upgrade handler) using the already-initialized root container. */ onServer?: (server: http.Server, rootContainer: Container) => void | Promise; + /** + * Decorate the handler app before first use (e.g. register a `ChildContainerFactory` decorator). + * Forwarded to `HandlerApp.init`. + */ + app?: (container: Container) => void; } export async function createServerHandler( @@ -19,7 +24,8 @@ export async function createServerHandler( ): Promise { const app = HandlerApp.init({ root: options.root, - request: options.request + request: options.request, + app: options.app }); const server = http.createServer(async (req, res) => {