From 1b368590d348dc44269329e53149f81a3e874457 Mon Sep 17 00:00:00 2001 From: adrians5j Date: Tue, 4 Aug 2026 00:13:24 +0200 Subject: [PATCH 1/3] feat(licensing): merge live WCP license into FeatureFlags, refreshed pre-register MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 (tracer) of the licensing/feature-flags redesign. Makes register-time feature-flag gating valid by loading the WCP license BEFORE the per-request register phase, and folding it into FeatureFlags so api-* read only flags. - loadWcpLicense: add single-flight (coalesce concurrent WCP fetches) + a sync getCachedWcpLicense() read. - WcpLicenseRefreshDecorator (api-event-handler-core): a ChildContainerFactory pre-register decorator that awaits loadWcpLicense() before the child's register phase. Wired via the new `app` hook on createLambdaHandler/createServerHandler, registered by both createWebinyApiHandler (aws + server). - FeatureFlagsWithLicenseDecorator (api-core): FeatureFlags.get() now returns the EFFECTIVE flags (userFlag && license.canUseX()) — the runtime counterpart of @webiny/project's build-time GetFeatureFlagsWithLicense. - api-file-manager AssetDeliveryFeature: private-files gate now reads FeatureFlags.get().isPrivateFilesEnabled() (register-time, license-merged) instead of WcpContext.canUsePrivateFiles(). WcpLicenseInitializer / WcpContext stay for the not-yet-migrated canUse* sites; RequestInitializer removal + the featureFlags gql query are follow slices. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Rg3MRCToopzWSTPWqU9Lga --- .../api-core/src/features/wcp/WcpFeature.ts | 10 ++- .../FeatureFlagsWithLicenseDecorator.ts | 83 +++++++++++++++++++ .../src/features/wcp/loadWcpLicense.ts | 75 ++++++++++++----- .../src/createWebinyApiHandler.ts | 10 ++- .../src/WcpLicenseRefreshDecorator.ts | 27 ++++++ packages/api-event-handler-core/src/index.ts | 1 + .../src/createWebinyApiHandler.ts | 10 ++- .../src/features/assetDelivery/feature.ts | 9 +- .../src/createLambdaHandler.ts | 9 +- .../src/createServerHandler.ts | 8 +- 10 files changed, 211 insertions(+), 31 deletions(-) create mode 100644 packages/api-core/src/features/wcp/decorators/FeatureFlagsWithLicenseDecorator.ts create mode 100644 packages/api-event-handler-core/src/WcpLicenseRefreshDecorator.ts 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-aws/src/createWebinyApiHandler.ts b/packages/api-event-handler-aws/src/createWebinyApiHandler.ts index b9a5c2efbdc..563e0f2a3f5 100644 --- a/packages/api-event-handler-aws/src/createWebinyApiHandler.ts +++ b/packages/api-event-handler-aws/src/createWebinyApiHandler.ts @@ -20,7 +20,10 @@ import { import { BackgroundTasksAwsFeature } from "@webiny/background-tasks-aws"; import { registerExtensions } from "@webiny/handler"; import { DynamoDBCoreFeature } from "@webiny/db-dynamodb"; -import { registerApiRequestStack } from "@webiny/api-event-handler-core"; +import { + registerApiRequestStack, + WcpLicenseRefreshDecorator +} from "@webiny/api-event-handler-core"; import { WebsocketsAwsFeature } from "@webiny/api-websockets-aws"; import { SchedulerAwsFeature } from "@webiny/api-scheduler-aws"; import { FileManagerS3Feature } from "@webiny/api-file-manager-s3"; @@ -67,6 +70,11 @@ export function createWebinyApiHandler(config: CreateWebinyApiHandlerConfig) { const documentClient = config.documentClient ?? getDocumentClient(); return createLambdaHandler({ + // Refresh the WCP license BEFORE each request's register phase, so register()-time feature-flag + // checks see the live license (ChildContainerFactory pre-register decorator). + app: container => { + container.registerDecorator(WcpLicenseRefreshDecorator); + }, root: async container => { // ── Transport ────────────────────────────────────────────── // ApiGatewayFeature registers the HTTP transport (event type + router + HttpFeature). diff --git a/packages/api-event-handler-core/src/WcpLicenseRefreshDecorator.ts b/packages/api-event-handler-core/src/WcpLicenseRefreshDecorator.ts new file mode 100644 index 00000000000..fe1d8a24a3f --- /dev/null +++ b/packages/api-event-handler-core/src/WcpLicenseRefreshDecorator.ts @@ -0,0 +1,27 @@ +import { ChildContainerFactory } from "@webiny/event-handler-core"; +import type { Container } from "@webiny/di"; +import { loadWcpLicense } from "@webiny/api-core/features/wcp/loadWcpLicense.js"; + +/** + * Refreshes the WCP license BEFORE the per-request child container is set up (its register phase). + * Register()-time feature-flag checks read the license synchronously via the process cache, so it + * must be loaded first — this decorator on `ChildContainerFactory` is the pre-register hook that + * guarantees that ordering. + * + * The license is project-level / tenant-agnostic, so loading it this early (before auth/tenant) is + * fine. `loadWcpLicense` is process-cached (~5-min TTL) and single-flighted, so this is a cheap + * no-op on warm requests and never fires two concurrent WCP fetches. + */ +class WcpLicenseRefreshDecoratorImpl implements ChildContainerFactory.Interface { + constructor(private decoratee: ChildContainerFactory.Interface) {} + + async create(root: Container, rawArgs: any[]): Promise { + await loadWcpLicense(); + return this.decoratee.create(root, rawArgs); + } +} + +export const WcpLicenseRefreshDecorator = ChildContainerFactory.createDecorator({ + decorator: WcpLicenseRefreshDecoratorImpl, + dependencies: [] +}); diff --git a/packages/api-event-handler-core/src/index.ts b/packages/api-event-handler-core/src/index.ts index d103ccc3ac0..79e9f5f4de4 100644 --- a/packages/api-event-handler-core/src/index.ts +++ b/packages/api-event-handler-core/src/index.ts @@ -1,2 +1,3 @@ export { registerApiRequestStack } from "./registerApiRequestStack.js"; export type { RegisterApiRequestStackConfig } from "./registerApiRequestStack.js"; +export { WcpLicenseRefreshDecorator } from "./WcpLicenseRefreshDecorator.js"; diff --git a/packages/api-event-handler-server/src/createWebinyApiHandler.ts b/packages/api-event-handler-server/src/createWebinyApiHandler.ts index 982fe3e5d82..dc4d4ca211b 100644 --- a/packages/api-event-handler-server/src/createWebinyApiHandler.ts +++ b/packages/api-event-handler-server/src/createWebinyApiHandler.ts @@ -18,7 +18,10 @@ import type { Container } from "@webiny/di"; import { createServerHandler, NodeHttpFeature } from "@webiny/event-handler-server"; import { registerExtensions } from "@webiny/handler"; -import { registerApiRequestStack } from "@webiny/api-event-handler-core"; +import { + registerApiRequestStack, + WcpLicenseRefreshDecorator +} from "@webiny/api-event-handler-core"; import { ServerConnectionManager, NodeWsAdapter, @@ -48,6 +51,11 @@ export interface CreateWebinyApiHandlerConfig { export function createWebinyApiHandler(config: CreateWebinyApiHandlerConfig) { return createServerHandler({ + // Refresh the WCP license BEFORE each request's register phase, so register()-time feature-flag + // checks see the live license (ChildContainerFactory pre-register decorator). + app: container => { + container.registerDecorator(WcpLicenseRefreshDecorator); + }, root: async rootContainer => { // ── Transport (Node HTTP) ────────────────────────────────── // NodeHttpFeature registers the event type + HttpFeature (router) + the routing terminal. 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) => { From 708d801a28cf46ff488df1a3d91bcade64efa5e3 Mon Sep 17 00:00:00 2001 From: adrians5j Date: Tue, 4 Aug 2026 12:36:34 +0200 Subject: [PATCH 2/3] refactor(api-event-handler-core): share app-container setup via registerApiHandlerApp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-register license-refresh 'app' hook was duplicated in both createWebinyApiHandler (aws + server). Extract to registerApiHandlerApp in api-event-handler-core (sibling of registerApiRequestStack) — both handlers now pass 'app: registerApiHandlerApp'. WcpLicenseRefreshDecorator becomes internal. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Rg3MRCToopzWSTPWqU9Lga --- .../src/createWebinyApiHandler.ts | 12 +++--------- packages/api-event-handler-core/src/index.ts | 2 +- .../src/registerApiHandlerApp.ts | 16 ++++++++++++++++ .../src/createWebinyApiHandler.ts | 12 +++--------- 4 files changed, 23 insertions(+), 19 deletions(-) create mode 100644 packages/api-event-handler-core/src/registerApiHandlerApp.ts diff --git a/packages/api-event-handler-aws/src/createWebinyApiHandler.ts b/packages/api-event-handler-aws/src/createWebinyApiHandler.ts index 563e0f2a3f5..0519ac6eb9d 100644 --- a/packages/api-event-handler-aws/src/createWebinyApiHandler.ts +++ b/packages/api-event-handler-aws/src/createWebinyApiHandler.ts @@ -20,10 +20,7 @@ import { import { BackgroundTasksAwsFeature } from "@webiny/background-tasks-aws"; import { registerExtensions } from "@webiny/handler"; import { DynamoDBCoreFeature } from "@webiny/db-dynamodb"; -import { - registerApiRequestStack, - WcpLicenseRefreshDecorator -} from "@webiny/api-event-handler-core"; +import { registerApiRequestStack, registerApiHandlerApp } from "@webiny/api-event-handler-core"; import { WebsocketsAwsFeature } from "@webiny/api-websockets-aws"; import { SchedulerAwsFeature } from "@webiny/api-scheduler-aws"; import { FileManagerS3Feature } from "@webiny/api-file-manager-s3"; @@ -70,11 +67,8 @@ export function createWebinyApiHandler(config: CreateWebinyApiHandlerConfig) { const documentClient = config.documentClient ?? getDocumentClient(); return createLambdaHandler({ - // Refresh the WCP license BEFORE each request's register phase, so register()-time feature-flag - // checks see the live license (ChildContainerFactory pre-register decorator). - app: container => { - container.registerDecorator(WcpLicenseRefreshDecorator); - }, + // Shared app-container setup (pre-register WCP license refresh, etc.). + app: registerApiHandlerApp, root: async container => { // ── Transport ────────────────────────────────────────────── // ApiGatewayFeature registers the HTTP transport (event type + router + HttpFeature). diff --git a/packages/api-event-handler-core/src/index.ts b/packages/api-event-handler-core/src/index.ts index 79e9f5f4de4..d9dbd9e8fa5 100644 --- a/packages/api-event-handler-core/src/index.ts +++ b/packages/api-event-handler-core/src/index.ts @@ -1,3 +1,3 @@ export { registerApiRequestStack } from "./registerApiRequestStack.js"; export type { RegisterApiRequestStackConfig } from "./registerApiRequestStack.js"; -export { WcpLicenseRefreshDecorator } from "./WcpLicenseRefreshDecorator.js"; +export { registerApiHandlerApp } from "./registerApiHandlerApp.js"; diff --git a/packages/api-event-handler-core/src/registerApiHandlerApp.ts b/packages/api-event-handler-core/src/registerApiHandlerApp.ts new file mode 100644 index 00000000000..fdb5859a893 --- /dev/null +++ b/packages/api-event-handler-core/src/registerApiHandlerApp.ts @@ -0,0 +1,16 @@ +import type { Container } from "@webiny/di"; +import { WcpLicenseRefreshDecorator } from "./WcpLicenseRefreshDecorator.js"; + +/** + * App-container setup shared by every Webiny API handler (aws + server). Runs against the handler + * APP container (the `app` hook of `HandlerApp.init`) — the place for lifecycle decorators that must + * be in effect before the first request. The transport-specific `root` / `request` wiring stays in + * each handler; this is the transport-agnostic app-level setup, the sibling of + * {@link registerApiRequestStack} (which sets up the per-request child container). + * + * Currently: the pre-register WCP license refresh (a `ChildContainerFactory` decorator), so + * register()-time feature-flag checks see the live license. + */ +export function registerApiHandlerApp(container: Container): void { + container.registerDecorator(WcpLicenseRefreshDecorator); +} diff --git a/packages/api-event-handler-server/src/createWebinyApiHandler.ts b/packages/api-event-handler-server/src/createWebinyApiHandler.ts index dc4d4ca211b..320d529e1ec 100644 --- a/packages/api-event-handler-server/src/createWebinyApiHandler.ts +++ b/packages/api-event-handler-server/src/createWebinyApiHandler.ts @@ -18,10 +18,7 @@ import type { Container } from "@webiny/di"; import { createServerHandler, NodeHttpFeature } from "@webiny/event-handler-server"; import { registerExtensions } from "@webiny/handler"; -import { - registerApiRequestStack, - WcpLicenseRefreshDecorator -} from "@webiny/api-event-handler-core"; +import { registerApiRequestStack, registerApiHandlerApp } from "@webiny/api-event-handler-core"; import { ServerConnectionManager, NodeWsAdapter, @@ -51,11 +48,8 @@ export interface CreateWebinyApiHandlerConfig { export function createWebinyApiHandler(config: CreateWebinyApiHandlerConfig) { return createServerHandler({ - // Refresh the WCP license BEFORE each request's register phase, so register()-time feature-flag - // checks see the live license (ChildContainerFactory pre-register decorator). - app: container => { - container.registerDecorator(WcpLicenseRefreshDecorator); - }, + // Shared app-container setup (pre-register WCP license refresh, etc.). + app: registerApiHandlerApp, root: async rootContainer => { // ── Transport (Node HTTP) ────────────────────────────────── // NodeHttpFeature registers the event type + HttpFeature (router) + the routing terminal. From 1d6f2a0b490ec82fb5732c81ae812fd042bf81a6 Mon Sep 17 00:00:00 2001 From: adrians5j Date: Tue, 4 Aug 2026 12:41:34 +0200 Subject: [PATCH 3/3] refactor: refresh WCP license inside registerApiRequestStack (no app-hook) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the ChildContainerFactory app-decorator + registerApiHandlerApp helper — they forced both createWebinyApiHandler (aws + server) to wire `app:`. Instead `registerApiRequestStack` (the shared request stack both hosting types already call) does `await loadWcpLicense()` before any feature registers, so the license is fresh for register()-time flag checks with zero per-handler wiring. The `app` prop on createLambdaHandler/createServerHandler/HandlerApp stays for future transport-specific decoration. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Rg3MRCToopzWSTPWqU9Lga --- .../src/createWebinyApiHandler.ts | 4 +-- .../src/WcpLicenseRefreshDecorator.ts | 27 ------------------- packages/api-event-handler-core/src/index.ts | 1 - .../src/registerApiHandlerApp.ts | 16 ----------- .../src/registerApiRequestStack.ts | 7 +++++ .../src/createWebinyApiHandler.ts | 4 +-- 6 files changed, 9 insertions(+), 50 deletions(-) delete mode 100644 packages/api-event-handler-core/src/WcpLicenseRefreshDecorator.ts delete mode 100644 packages/api-event-handler-core/src/registerApiHandlerApp.ts diff --git a/packages/api-event-handler-aws/src/createWebinyApiHandler.ts b/packages/api-event-handler-aws/src/createWebinyApiHandler.ts index 0519ac6eb9d..b9a5c2efbdc 100644 --- a/packages/api-event-handler-aws/src/createWebinyApiHandler.ts +++ b/packages/api-event-handler-aws/src/createWebinyApiHandler.ts @@ -20,7 +20,7 @@ import { import { BackgroundTasksAwsFeature } from "@webiny/background-tasks-aws"; import { registerExtensions } from "@webiny/handler"; import { DynamoDBCoreFeature } from "@webiny/db-dynamodb"; -import { registerApiRequestStack, registerApiHandlerApp } from "@webiny/api-event-handler-core"; +import { registerApiRequestStack } from "@webiny/api-event-handler-core"; import { WebsocketsAwsFeature } from "@webiny/api-websockets-aws"; import { SchedulerAwsFeature } from "@webiny/api-scheduler-aws"; import { FileManagerS3Feature } from "@webiny/api-file-manager-s3"; @@ -67,8 +67,6 @@ export function createWebinyApiHandler(config: CreateWebinyApiHandlerConfig) { const documentClient = config.documentClient ?? getDocumentClient(); return createLambdaHandler({ - // Shared app-container setup (pre-register WCP license refresh, etc.). - app: registerApiHandlerApp, root: async container => { // ── Transport ────────────────────────────────────────────── // ApiGatewayFeature registers the HTTP transport (event type + router + HttpFeature). diff --git a/packages/api-event-handler-core/src/WcpLicenseRefreshDecorator.ts b/packages/api-event-handler-core/src/WcpLicenseRefreshDecorator.ts deleted file mode 100644 index fe1d8a24a3f..00000000000 --- a/packages/api-event-handler-core/src/WcpLicenseRefreshDecorator.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { ChildContainerFactory } from "@webiny/event-handler-core"; -import type { Container } from "@webiny/di"; -import { loadWcpLicense } from "@webiny/api-core/features/wcp/loadWcpLicense.js"; - -/** - * Refreshes the WCP license BEFORE the per-request child container is set up (its register phase). - * Register()-time feature-flag checks read the license synchronously via the process cache, so it - * must be loaded first — this decorator on `ChildContainerFactory` is the pre-register hook that - * guarantees that ordering. - * - * The license is project-level / tenant-agnostic, so loading it this early (before auth/tenant) is - * fine. `loadWcpLicense` is process-cached (~5-min TTL) and single-flighted, so this is a cheap - * no-op on warm requests and never fires two concurrent WCP fetches. - */ -class WcpLicenseRefreshDecoratorImpl implements ChildContainerFactory.Interface { - constructor(private decoratee: ChildContainerFactory.Interface) {} - - async create(root: Container, rawArgs: any[]): Promise { - await loadWcpLicense(); - return this.decoratee.create(root, rawArgs); - } -} - -export const WcpLicenseRefreshDecorator = ChildContainerFactory.createDecorator({ - decorator: WcpLicenseRefreshDecoratorImpl, - dependencies: [] -}); diff --git a/packages/api-event-handler-core/src/index.ts b/packages/api-event-handler-core/src/index.ts index d9dbd9e8fa5..d103ccc3ac0 100644 --- a/packages/api-event-handler-core/src/index.ts +++ b/packages/api-event-handler-core/src/index.ts @@ -1,3 +1,2 @@ export { registerApiRequestStack } from "./registerApiRequestStack.js"; export type { RegisterApiRequestStackConfig } from "./registerApiRequestStack.js"; -export { registerApiHandlerApp } from "./registerApiHandlerApp.js"; diff --git a/packages/api-event-handler-core/src/registerApiHandlerApp.ts b/packages/api-event-handler-core/src/registerApiHandlerApp.ts deleted file mode 100644 index fdb5859a893..00000000000 --- a/packages/api-event-handler-core/src/registerApiHandlerApp.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Container } from "@webiny/di"; -import { WcpLicenseRefreshDecorator } from "./WcpLicenseRefreshDecorator.js"; - -/** - * App-container setup shared by every Webiny API handler (aws + server). Runs against the handler - * APP container (the `app` hook of `HandlerApp.init`) — the place for lifecycle decorators that must - * be in effect before the first request. The transport-specific `root` / `request` wiring stays in - * each handler; this is the transport-agnostic app-level setup, the sibling of - * {@link registerApiRequestStack} (which sets up the per-request child container). - * - * Currently: the pre-register WCP license refresh (a `ChildContainerFactory` decorator), so - * register()-time feature-flag checks see the live license. - */ -export function registerApiHandlerApp(container: Container): void { - container.registerDecorator(WcpLicenseRefreshDecorator); -} 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-event-handler-server/src/createWebinyApiHandler.ts b/packages/api-event-handler-server/src/createWebinyApiHandler.ts index 320d529e1ec..982fe3e5d82 100644 --- a/packages/api-event-handler-server/src/createWebinyApiHandler.ts +++ b/packages/api-event-handler-server/src/createWebinyApiHandler.ts @@ -18,7 +18,7 @@ import type { Container } from "@webiny/di"; import { createServerHandler, NodeHttpFeature } from "@webiny/event-handler-server"; import { registerExtensions } from "@webiny/handler"; -import { registerApiRequestStack, registerApiHandlerApp } from "@webiny/api-event-handler-core"; +import { registerApiRequestStack } from "@webiny/api-event-handler-core"; import { ServerConnectionManager, NodeWsAdapter, @@ -48,8 +48,6 @@ export interface CreateWebinyApiHandlerConfig { export function createWebinyApiHandler(config: CreateWebinyApiHandlerConfig) { return createServerHandler({ - // Shared app-container setup (pre-register WCP license refresh, etc.). - app: registerApiHandlerApp, root: async rootContainer => { // ── Transport (Node HTTP) ────────────────────────────────── // NodeHttpFeature registers the event type + HttpFeature (router) + the routing terminal.