Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions packages/api-core/src/features/wcp/WcpFeature.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -11,7 +12,12 @@ export const WcpFeature = createFeature<ILicense | undefined>({
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.
}
});
Original file line number Diff line number Diff line change
@@ -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<T extends boolean | undefined>(
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: []
});
75 changes: 53 additions & 22 deletions packages/api-core/src/features/wcp/loadWcpLicense.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ILicense> | 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<ILicense> {
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -82,6 +83,12 @@ export async function registerApiRequestStack(
container: Container,
config: RegisterApiRequestStackConfig
): Promise<void> {
// 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 });
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
}
Expand Down
9 changes: 8 additions & 1 deletion packages/event-handler-aws/src/createLambdaHandler.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
}

/**
Expand All @@ -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<any> => app.handle(event, context);
Expand Down
8 changes: 7 additions & 1 deletion packages/event-handler-server/src/createServerHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,20 @@ export interface CreateServerHandlerOptions {
* upgrade handler) using the already-initialized root container.
*/
onServer?: (server: http.Server, rootContainer: Container) => void | Promise<void>;
/**
* 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(
options: CreateServerHandlerOptions
): Promise<http.Server> {
const app = HandlerApp.init({
root: options.root,
request: options.request
request: options.request,
app: options.app
});

const server = http.createServer(async (req, res) => {
Expand Down
Loading