frontend-app-api: extract prepared app predicates

Move predicate reference collection and predicate context loading out of prepareSpecializedApp so the session and finalization flow can focus on lifecycle state.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-16 00:23:54 +01:00
parent 01f5660fe7
commit ca24aa2da9
2 changed files with 199 additions and 157 deletions
@@ -0,0 +1,185 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
ApiHolder,
createApiRef,
featureFlagsApiRef,
} from '@backstage/frontend-plugin-api';
import { FilterPredicate } from '@backstage/filter-predicates';
import type {
EvaluatePermissionRequest,
EvaluatePermissionResponse,
} from '@backstage/plugin-permission-common';
export type ExtensionPredicateContext = {
featureFlags: string[];
permissions: string[];
};
export const EMPTY_PREDICATE_CONTEXT: ExtensionPredicateContext = {
featureFlags: [],
permissions: [],
};
// Minimal local permission API interface to avoid a dependency on @backstage/plugin-permission-react
type MinimalPermissionApi = {
authorize(
request: EvaluatePermissionRequest,
): Promise<EvaluatePermissionResponse>;
};
export const localPermissionApiRef = createApiRef<MinimalPermissionApi>({
id: 'plugin.permission.api',
});
export function createPredicateContextLoader(options: {
apis: ApiHolder;
predicateReferences: ExtensionPredicateContext;
}) {
function getActiveFeatureFlags() {
const featureFlagsApi = options.apis.get(featureFlagsApiRef);
if (!featureFlagsApi) {
return [];
}
return options.predicateReferences.featureFlags.filter(name =>
featureFlagsApi.isActive(name),
);
}
function getImmediate(): ExtensionPredicateContext | undefined {
if (options.predicateReferences.permissions.length > 0) {
const permissionApi = options.apis.get(localPermissionApiRef);
if (permissionApi) {
return undefined;
}
}
return {
featureFlags: getActiveFeatureFlags(),
permissions: [],
};
}
async function load() {
const immediatePredicateContext = getImmediate();
if (immediatePredicateContext) {
return immediatePredicateContext;
}
let allowedPermissions: string[] = [];
const permissionApi = options.apis.get(localPermissionApiRef);
if (permissionApi) {
const permissionNames = options.predicateReferences.permissions;
const responses = await Promise.all(
permissionNames.map(name =>
permissionApi.authorize({
permission: { name, type: 'basic', attributes: {} },
}),
),
);
allowedPermissions = permissionNames.filter(
(_, i) => responses[i].result === 'ALLOW',
);
}
return {
featureFlags: getActiveFeatureFlags(),
permissions: allowedPermissions,
};
}
return {
getImmediate,
load,
};
}
export function collectPredicateReferences(
nodes: Iterable<{ spec: { if?: FilterPredicate } }>,
): ExtensionPredicateContext {
const featureFlags = new Set<string>();
const permissions = new Set<string>();
for (const node of nodes) {
if (node.spec.if === undefined) {
continue;
}
for (const name of extractFeatureFlagNames(node.spec.if)) {
featureFlags.add(name);
}
for (const name of extractPermissionNames(node.spec.if)) {
permissions.add(name);
}
}
return {
featureFlags: Array.from(featureFlags),
permissions: Array.from(permissions),
};
}
/**
* Recursively walks a FilterPredicate and returns all string values referenced
* by `featureFlags: { $contains: '...' }` expressions. This lets us call
* `isActive()` only for the flags that are actually used in predicates rather
* than fetching the full registered-flag list.
*/
function extractFeatureFlagNames(predicate: FilterPredicate): string[] {
return extractPredicateKeyNames(predicate, 'featureFlags');
}
/**
* Recursively walks a FilterPredicate and returns all string values referenced
* by `permissions: { $contains: '...' }` expressions. This lets us issue a
* single batched authorize call for only the permissions actually referenced.
*/
function extractPermissionNames(predicate: FilterPredicate): string[] {
return extractPredicateKeyNames(predicate, 'permissions');
}
function extractPredicateKeyNames(
predicate: FilterPredicate,
key: string,
): string[] {
if (typeof predicate !== 'object' || predicate === null) {
return [];
}
const obj = predicate as Record<string, unknown>;
if (Array.isArray(obj.$all)) {
return (obj.$all as FilterPredicate[]).flatMap(p =>
extractPredicateKeyNames(p, key),
);
}
if (Array.isArray(obj.$any)) {
return (obj.$any as FilterPredicate[]).flatMap(p =>
extractPredicateKeyNames(p, key),
);
}
if (obj.$not !== undefined) {
return extractPredicateKeyNames(obj.$not as FilterPredicate, key);
}
const value = obj[key];
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
const contains = (value as Record<string, unknown>).$contains;
if (typeof contains === 'string') {
return [contains];
}
}
return [];
}
@@ -32,7 +32,6 @@ import {
RouteFunc,
RouteResolutionApi,
createApiFactory,
createApiRef,
routeResolutionApiRef,
AppNode,
AppNodeInstance,
@@ -44,10 +43,6 @@ import {
identityApiRef,
createExtensionDataRef,
} from '@backstage/frontend-plugin-api';
import type {
EvaluatePermissionRequest,
EvaluatePermissionResponse,
} from '@backstage/plugin-permission-common';
import { FilterPredicate } from '@backstage/filter-predicates';
import {
createExtensionDataContainer,
@@ -99,6 +94,12 @@ import {
createErrorCollector,
ErrorCollector,
} from './createErrorCollector';
import {
collectPredicateReferences,
createPredicateContextLoader,
EMPTY_PREDICATE_CONTEXT,
type ExtensionPredicateContext,
} from './predicates';
import {
FrontendApiRegistry,
FrontendApiResolver,
@@ -167,22 +168,12 @@ type FinalizationState = {
reject(error: unknown): void;
};
type ExtensionPredicateContext = {
featureFlags: string[];
permissions: string[];
};
type InternalSpecializedAppSessionState = {
apis: ApiHolder;
identityApi?: IdentityApi;
predicateContext: ExtensionPredicateContext;
};
const EMPTY_PREDICATE_CONTEXT: ExtensionPredicateContext = {
featureFlags: [],
permissions: [],
};
/**
* Opaque reusable session state for specialized apps.
*
@@ -437,17 +428,6 @@ export type PreparedSpecializedApp = {
}): FinalizedSpecializedApp;
};
// Minimal local permission API interface to avoid a dependency on @backstage/plugin-permission-react
type MinimalPermissionApi = {
authorize(
request: EvaluatePermissionRequest,
): Promise<EvaluatePermissionResponse>;
};
const localPermissionApiRef = createApiRef<MinimalPermissionApi>({
id: 'plugin.permission.api',
});
// Internal options type, not exported in the public API
export interface CreateSpecializedAppInternalOptions
extends PrepareSpecializedAppOptions {
@@ -523,7 +503,7 @@ export function prepareSpecializedApp(
tree,
collector,
});
const predicateReferences = collectPredicateReferences(tree);
const predicateReferences = collectPredicateReferences(tree.nodes.values());
const appApiRegistry = new FrontendApiRegistry();
const internalStaticFactories =
internalOptions?.__internal?.apiFactoryOverrides ?? [];
@@ -560,6 +540,10 @@ export function prepareSpecializedApp(
routeBindings,
staticFactories: phaseStaticFactories,
});
const predicateContextLoader = createPredicateContextLoader({
apis: phase.apis,
predicateReferences,
});
const state: {
signInRuntime?: SignInRuntime;
cachedSessionState?: SpecializedAppSessionState;
@@ -586,61 +570,6 @@ export function prepareSpecializedApp(
});
}
function getActiveFeatureFlags() {
const featureFlagsApi = phase.apis.get(featureFlagsApiRef);
if (!featureFlagsApi) {
return [];
}
return predicateReferences.featureFlags.filter(name =>
featureFlagsApi.isActive(name),
);
}
function getImmediatePredicateContext():
| ExtensionPredicateContext
| undefined {
if (predicateReferences.permissions.length > 0) {
const permissionApi = phase.apis.get(localPermissionApiRef);
if (permissionApi) {
return undefined;
}
}
return {
featureFlags: getActiveFeatureFlags(),
permissions: [],
};
}
async function createPredicateContext() {
const immediatePredicateContext = getImmediatePredicateContext();
if (immediatePredicateContext) {
return immediatePredicateContext;
}
let allowedPermissions: string[] = [];
const permissionApi = phase.apis.get(localPermissionApiRef);
if (permissionApi) {
const permNames = predicateReferences.permissions;
const responses = await Promise.all(
permNames.map(name =>
permissionApi.authorize({
permission: { name, type: 'basic', attributes: {} },
}),
),
);
allowedPermissions = permNames.filter(
(_, i) => responses[i].result === 'ALLOW',
);
}
return {
featureFlags: getActiveFeatureFlags(),
permissions: allowedPermissions,
};
}
function createSessionState(predicateContext: ExtensionPredicateContext) {
const identityApi =
state.signInRuntime?.readyIdentityApi ?? providedSessionData?.identityApi;
@@ -662,7 +591,7 @@ export function prepareSpecializedApp(
return undefined;
}
const predicateContext = getImmediatePredicateContext();
const predicateContext = predicateContextLoader.getImmediate();
if (!predicateContext) {
return undefined;
}
@@ -692,7 +621,8 @@ export function prepareSpecializedApp(
);
}
state.sessionStatePromise = createPredicateContext()
state.sessionStatePromise = predicateContextLoader
.load()
.then(predicateContext => {
if (state.cachedSessionState) {
return state.cachedSessionState;
@@ -1586,29 +1516,6 @@ function classifyBootstrapTree(options: {
};
}
function collectPredicateReferences(tree: AppTree): ExtensionPredicateContext {
const featureFlags = new Set<string>();
const permissions = new Set<string>();
for (const node of tree.nodes.values()) {
if (node.spec.if === undefined) {
continue;
}
for (const name of extractFeatureFlagNames(node.spec.if)) {
featureFlags.add(name);
}
for (const name of extractPermissionNames(node.spec.if)) {
permissions.add(name);
}
}
return {
featureFlags: Array.from(featureFlags),
permissions: Array.from(permissions),
};
}
function subtreeContainsPredicate(root: AppNode) {
const visited = new Set<AppNode>();
@@ -1686,53 +1593,3 @@ function mergeExtensionFactoryMiddleware(
};
});
}
/**
* Recursively walks a FilterPredicate and returns all string values referenced
* by `featureFlags: { $contains: '...' }` expressions. This lets us call
* `isActive()` only for the flags that are actually used in predicates rather
* than fetching the full registered-flag list.
*/
function extractFeatureFlagNames(predicate: FilterPredicate): string[] {
return extractPredicateKeyNames(predicate, 'featureFlags');
}
/**
* Recursively walks a FilterPredicate and returns all string values referenced
* by `permissions: { $contains: '...' }` expressions. This lets us issue a
* single batched authorize call for only the permissions actually referenced.
*/
function extractPermissionNames(predicate: FilterPredicate): string[] {
return extractPredicateKeyNames(predicate, 'permissions');
}
function extractPredicateKeyNames(
predicate: FilterPredicate,
key: string,
): string[] {
if (typeof predicate !== 'object' || predicate === null) {
return [];
}
const obj = predicate as Record<string, unknown>;
if (Array.isArray(obj.$all)) {
return (obj.$all as FilterPredicate[]).flatMap(p =>
extractPredicateKeyNames(p, key),
);
}
if (Array.isArray(obj.$any)) {
return (obj.$any as FilterPredicate[]).flatMap(p =>
extractPredicateKeyNames(p, key),
);
}
if (obj.$not !== undefined) {
return extractPredicateKeyNames(obj.$not as FilterPredicate, key);
}
const value = obj[key];
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
const contains = (value as Record<string, unknown>).$contains;
if (typeof contains === 'string') {
return [contains];
}
}
return [];
}