mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
fix: split config-eval from config-rendering for browser safety
Move Node.js-only functions (evalConfigFileContent, tryEvalConfigFileContent, detectImportPackageFromDir) to new config-eval.ts. This prevents the dashboard browser build from failing on fs/path/jiti imports. Dashboard now uses parseStaticConfigLiteral (regex+JSON.parse) instead of jiti eval for untrusted GitHub-fetched config content, avoiding RCE risk. Remove type casts in favor of isRecord type guard. Co-Authored-By: mantra <mantra@stack-auth.com>
This commit is contained in:
parent
576bdcfb8a
commit
f71cde84b8
@ -1,6 +1,7 @@
|
||||
import { globalPrismaClient } from "@/prisma-client";
|
||||
import { showOnboardingHexclaveConfigValue } from "@hexclave/shared/dist/config-authoring";
|
||||
import { detectImportPackageFromDir, evalConfigFileContent, renderConfigFileContent } from "@hexclave/shared/dist/config-rendering";
|
||||
import { renderConfigFileContent } from "@hexclave/shared/dist/config-rendering";
|
||||
import { detectImportPackageFromDir, evalConfigFileContent } from "@hexclave/shared/dist/config-eval";
|
||||
import { isValidConfig } from "@hexclave/shared/dist/config/format";
|
||||
import { LOCAL_EMULATOR_ADMIN_EMAIL, LOCAL_EMULATOR_ADMIN_PASSWORD } from "@hexclave/shared/dist/local-emulator";
|
||||
import { getEnvVariable } from "@hexclave/shared/dist/utils/env";
|
||||
|
||||
@ -12,8 +12,7 @@
|
||||
import type { PushedConfigSource } from "@hexclave/next";
|
||||
import type { EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/config/schema";
|
||||
import { isValidConfig, override } from "@hexclave/shared/dist/config/format";
|
||||
import { evalConfigFileContent, renderConfigFileContent } from "@hexclave/shared/dist/config-rendering";
|
||||
import { showOnboardingHexclaveConfigValue } from "@hexclave/shared/dist/hexclave-config-file";
|
||||
import { parseStaticConfigLiteral, renderConfigFileContent } from "@hexclave/shared/dist/config-rendering";
|
||||
|
||||
import {
|
||||
commitFile,
|
||||
@ -52,10 +51,10 @@ export function buildUpdatedConfigFileContent(
|
||||
currentFileContent: string,
|
||||
configUpdate: EnvironmentConfigOverrideOverride,
|
||||
): string {
|
||||
const parsed = evalConfigFileContent(currentFileContent, "stack.config.ts");
|
||||
if (parsed === showOnboardingHexclaveConfigValue) {
|
||||
const parsed = parseStaticConfigLiteral(currentFileContent);
|
||||
if (parsed == null) {
|
||||
throw new Error(
|
||||
"The config file currently exports the onboarding placeholder. Finish setting up Hexclave in your repo before pushing dashboard changes."
|
||||
"Could not parse the existing config file. The file must be a static JSON config object (as produced by the dashboard). Config files with dynamic expressions or imports are not supported for dashboard-driven updates."
|
||||
);
|
||||
}
|
||||
if (!isValidConfig(parsed)) {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { replaceConfigObject, updateConfigObject } from "@hexclave/shared-backend";
|
||||
import { detectImportPackageFromDir } from "@hexclave/shared/dist/config-rendering";
|
||||
import { detectImportPackageFromDir } from "@hexclave/shared/dist/config-eval";
|
||||
import { isValidConfig } from "@hexclave/shared/dist/config/format";
|
||||
import type { EnvironmentConfigOverrideOverride } from "@hexclave/shared/dist/config/schema";
|
||||
import { throwErr } from "@hexclave/shared/dist/utils/errors";
|
||||
|
||||
@ -13,7 +13,8 @@ import { createInitPrompt } from "../lib/init-prompt.js";
|
||||
import { createProjectInteractively } from "../lib/create-project.js";
|
||||
import { runClaudeAgent } from "../lib/claude-agent.js";
|
||||
import { resolveConfigFilePathOption } from "../lib/config-file-path.js";
|
||||
import { detectImportPackageFromDir, renderConfigFileContent } from "@hexclave/shared/dist/config-rendering";
|
||||
import { renderConfigFileContent } from "@hexclave/shared/dist/config-rendering";
|
||||
import { detectImportPackageFromDir } from "@hexclave/shared/dist/config-eval";
|
||||
import { throwErr } from "@hexclave/shared/dist/utils/errors";
|
||||
|
||||
const VALID_INIT_MODES = ["create", "create-cloud", "link-config", "link-cloud"] as const;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { showOnboardingHexclaveConfigValue } from "@hexclave/shared/dist/config-authoring";
|
||||
import { detectImportPackageFromDir, evalConfigFileContent, renderConfigFileContent } from "@hexclave/shared/dist/config-rendering";
|
||||
import { renderConfigFileContent } from "@hexclave/shared/dist/config-rendering";
|
||||
import { detectImportPackageFromDir, evalConfigFileContent } from "@hexclave/shared/dist/config-eval";
|
||||
import type { Config, ConfigValue, NormalizedConfig } from "@hexclave/shared/dist/config/format";
|
||||
import { isValidConfig, normalize, override } from "@hexclave/shared/dist/config/format";
|
||||
import { captureError } from "@hexclave/shared/dist/utils/errors";
|
||||
@ -130,7 +131,7 @@ export async function updateConfigObject(configFilePath: string, configUpdate: C
|
||||
|
||||
const content = readFileSync(configFilePath, "utf-8");
|
||||
|
||||
// Fast path: if the config is a plain static literal (no imports, no helpers),
|
||||
// Fast path: if the config can be evaluated by jiti (no unresolvable imports),
|
||||
// apply the update deterministically without invoking the AI agent.
|
||||
const staticConfig = tryParseConfigFileContent(content, configFilePath);
|
||||
if (staticConfig != null && isValidConfig(staticConfig)) {
|
||||
@ -318,6 +319,10 @@ function configFileExportsConfig(content: string, configFilePath: string): boole
|
||||
evalConfigFileContent(content, configFilePath);
|
||||
return true;
|
||||
} catch {
|
||||
// jiti may fail to resolve imports that are valid in the user's project but
|
||||
// absent from the current process (e.g. relative asset imports, workspace
|
||||
// packages). For the structural sanity check we only need to know a runtime
|
||||
// `config` binding still exists after the agent edited the file.
|
||||
return /\bexport\s+const\s+config\b/.test(content);
|
||||
}
|
||||
}
|
||||
|
||||
147
packages/shared/src/config-eval.ts
Normal file
147
packages/shared/src/config-eval.ts
Normal file
@ -0,0 +1,147 @@
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import { createJiti } from "jiti";
|
||||
import path from "path";
|
||||
|
||||
export { hexclaveConfigFileExportsConfig } from "./hexclave-config-file";
|
||||
|
||||
const jiti = createJiti(import.meta.url, { moduleCache: false });
|
||||
|
||||
/**
|
||||
* Packages that export the `HexclaveConfig` type, in priority order.
|
||||
* The first match found in a project's dependencies wins. Hexclave-branded
|
||||
* packages come first (canonical); the legacy `@stackframe/*` names remain
|
||||
* so projects pinned to the last legacy release still render a config file
|
||||
* that compiles against their installed SDK.
|
||||
*/
|
||||
const CONFIG_IMPORT_PACKAGES = [
|
||||
"@hexclave/next",
|
||||
"@hexclave/react",
|
||||
"@hexclave/tanstack-start",
|
||||
"@hexclave/js",
|
||||
"@hexclave/template",
|
||||
"@stackframe/stack",
|
||||
"@stackframe/react",
|
||||
"@stackframe/js",
|
||||
"@stackframe/template",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Walks up from `dir` to find the nearest `package.json` and returns the
|
||||
* best SDK package to use for the `HexclaveConfig` type import.
|
||||
*/
|
||||
export function detectImportPackageFromDir(dir: string): string | undefined {
|
||||
let current = dir;
|
||||
while (true) {
|
||||
const pkgPath = path.join(current, "package.json");
|
||||
if (existsSync(pkgPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
||||
const deps = [
|
||||
...Object.keys(pkg.dependencies ?? {}),
|
||||
...Object.keys(pkg.devDependencies ?? {}),
|
||||
];
|
||||
for (const known of CONFIG_IMPORT_PACKAGES) {
|
||||
if (deps.includes(known)) {
|
||||
return known;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
type ParsedConfigValue = Record<string, unknown> | string;
|
||||
|
||||
/**
|
||||
* Evaluates config file content using jiti and returns the exported `config`
|
||||
* value. Replaces the old Babel AST-based `parseHexclaveConfigFileContent`.
|
||||
*
|
||||
* WARNING: This executes arbitrary code via `jiti.evalModule` — only use on
|
||||
* content that is fully operator-controlled (local filesystem). Never call
|
||||
* this on untrusted input (e.g. content fetched from a remote repository).
|
||||
*/
|
||||
export function evalConfigFileContent(content: string, filePath: string): ParsedConfigValue {
|
||||
if (content.trim() === "") return {};
|
||||
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
|
||||
const mod: unknown = jiti.evalModule(content, { filename: resolvedPath });
|
||||
if (!isRecord(mod)) {
|
||||
throw new Error(`Invalid config in ${filePath}. The file must export a plain \`config\` object or "show-onboarding".`);
|
||||
}
|
||||
const config = mod.config;
|
||||
if (config === undefined) {
|
||||
throw new Error(`Invalid config in ${filePath}. The file must export a plain \`config\` object or "show-onboarding".`);
|
||||
}
|
||||
if (typeof config === "string") return config;
|
||||
if (isRecord(config)) return config;
|
||||
throw new Error(`Invalid config in ${filePath}. The file must export a plain \`config\` object or "show-onboarding".`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link evalConfigFileContent}, but returns `null` instead of throwing
|
||||
* when the content cannot be evaluated.
|
||||
*/
|
||||
export function tryEvalConfigFileContent(content: string, filePath: string): ParsedConfigValue | null {
|
||||
try {
|
||||
return evalConfigFileContent(content, filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- inline vitest tests ---
|
||||
|
||||
import.meta.vitest?.test("evalConfigFileContent parses static config exports", ({ expect }) => {
|
||||
expect(evalConfigFileContent(`
|
||||
import type { StackConfig } from "@hexclave/js";
|
||||
export const config: StackConfig = {
|
||||
auth: { allowSignUp: true },
|
||||
payments: { testMode: false },
|
||||
};
|
||||
`, "stack.config.ts")).toMatchInlineSnapshot(`
|
||||
{
|
||||
"auth": {
|
||||
"allowSignUp": true,
|
||||
},
|
||||
"payments": {
|
||||
"testMode": false,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("evalConfigFileContent parses show-onboarding", ({ expect }) => {
|
||||
expect(evalConfigFileContent('export const config = "show-onboarding";', "stack.config.ts")).toBe("show-onboarding");
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("evalConfigFileContent rejects content without config export", ({ expect }) => {
|
||||
expect(() => evalConfigFileContent("export const other = {};", "stack.config.ts")).toThrow(/must export/);
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("tryEvalConfigFileContent returns the config for valid exports", ({ expect }) => {
|
||||
expect(tryEvalConfigFileContent("export const config = { auth: { allowSignUp: true } };", "stack.config.ts")).toEqual({
|
||||
auth: { allowSignUp: true },
|
||||
});
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("tryEvalConfigFileContent returns null on unresolvable function call", ({ expect }) => {
|
||||
expect(tryEvalConfigFileContent("export const config = someUndefinedFunction();", "stack.config.ts")).toBeNull();
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("tryEvalConfigFileContent returns null on unresolvable import", ({ expect }) => {
|
||||
expect(tryEvalConfigFileContent('import x from "./nonexistent-file";\nexport const config = { a: x };', "stack.config.ts")).toBeNull();
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("tryEvalConfigFileContent returns null on syntax error", ({ expect }) => {
|
||||
expect(tryEvalConfigFileContent("export const config = {", "stack.config.ts")).toBeNull();
|
||||
});
|
||||
@ -1,14 +1,7 @@
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import { createJiti } from "jiti";
|
||||
import path from "path";
|
||||
import { isValidConfig, normalize } from "./config/format";
|
||||
import { hexclaveConfigFileExportsConfig } from "./hexclave-config-file";
|
||||
export { hexclaveConfigFileExportsConfig };
|
||||
|
||||
const DEFAULT_CONFIG_IMPORT_PACKAGE = "@hexclave/js";
|
||||
|
||||
const jiti = createJiti(import.meta.url, { moduleCache: false });
|
||||
|
||||
/**
|
||||
* Packages that export the `HexclaveConfig` type, in priority order.
|
||||
* The first match found in a project's dependencies wins. Hexclave-branded
|
||||
@ -42,33 +35,6 @@ export function detectConfigImportPackage(dependencies: string[]): string | unde
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks up from `dir` to find the nearest `package.json` and returns the
|
||||
* best SDK package to use for the `HexclaveConfig` type import.
|
||||
*/
|
||||
export function detectImportPackageFromDir(dir: string): string | undefined {
|
||||
let current = dir;
|
||||
while (true) {
|
||||
const pkgPath = path.join(current, "package.json");
|
||||
if (existsSync(pkgPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
||||
const deps = [
|
||||
...Object.keys(pkg.dependencies ?? {}),
|
||||
...Object.keys(pkg.devDependencies ?? {}),
|
||||
];
|
||||
return detectConfigImportPackage(deps);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a config object into the source text of a `stack.config.ts` file.
|
||||
*/
|
||||
@ -92,39 +58,38 @@ export function renderConfigFileContent(config: unknown, importPackage?: string)
|
||||
return `${importLine}\n\nexport const config: HexclaveConfig = ${JSON.stringify(normalizedConfig, null, 2)};\n`;
|
||||
}
|
||||
|
||||
type ParsedConfigValue = Record<string, unknown> | string;
|
||||
|
||||
/**
|
||||
* Evaluates config file content using jiti and returns the exported `config`
|
||||
* value. Replaces the old Babel AST-based `parseHexclaveConfigFileContent`.
|
||||
*/
|
||||
export function evalConfigFileContent(content: string, filePath: string): ParsedConfigValue {
|
||||
if (content.trim() === "") return {};
|
||||
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
|
||||
const mod = jiti.evalModule(content, { filename: resolvedPath }) as Record<string, unknown>;
|
||||
const config = mod.config;
|
||||
if (config === undefined) {
|
||||
throw new Error(`Invalid config in ${filePath}. The file must export a plain \`config\` object or "show-onboarding".`);
|
||||
}
|
||||
if (typeof config === "string") return config;
|
||||
if (config !== null && typeof config === "object" && !Array.isArray(config)) {
|
||||
return config as Record<string, unknown>;
|
||||
}
|
||||
throw new Error(`Invalid config in ${filePath}. The file must export a plain \`config\` object or "show-onboarding".`);
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link evalConfigFileContent}, but returns `null` instead of throwing
|
||||
* when the content cannot be evaluated.
|
||||
* Parses a config file that was produced by {@link renderConfigFileContent}.
|
||||
* Extracts the JSON object literal after the `export const config` assignment
|
||||
* and parses it with `JSON.parse`. This is intentionally limited to the exact
|
||||
* format we emit — it never executes code, so it is safe for untrusted input
|
||||
* such as content fetched from a remote repository.
|
||||
*
|
||||
* Returns `null` when the content does not match the expected static format.
|
||||
*/
|
||||
export function tryEvalConfigFileContent(content: string, filePath: string): ParsedConfigValue | null {
|
||||
export function parseStaticConfigLiteral(content: string): Record<string, unknown> | null {
|
||||
// Match `export const config ... = <json>;` — the type annotation is optional.
|
||||
const match = content.match(/export\s+const\s+config(?:\s*:\s*\w+)?\s*=\s*([\s\S]+);?\s*$/m);
|
||||
if (match == null) return null;
|
||||
|
||||
// Trim any trailing semicolons and whitespace from the captured JSON body.
|
||||
const jsonBody = match[1].replace(/;\s*$/, "").trim();
|
||||
|
||||
try {
|
||||
return evalConfigFileContent(content, filePath);
|
||||
const parsed: unknown = JSON.parse(jsonBody);
|
||||
if (isRecord(parsed)) return parsed;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- inline vitest tests ---
|
||||
|
||||
import.meta.vitest?.test("renderConfigFileContent normalizes config exports", ({ expect }) => {
|
||||
expect(renderConfigFileContent({
|
||||
"payments.items.todos.displayName": "Todo Slots",
|
||||
@ -141,50 +106,6 @@ import.meta.vitest?.test("renderConfigFileContent normalizes config exports", ({
|
||||
};`);
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("evalConfigFileContent parses static config exports", ({ expect }) => {
|
||||
expect(evalConfigFileContent(`
|
||||
import type { StackConfig } from "@hexclave/js";
|
||||
export const config: StackConfig = {
|
||||
auth: { allowSignUp: true },
|
||||
payments: { testMode: false },
|
||||
};
|
||||
`, "stack.config.ts")).toMatchInlineSnapshot(`
|
||||
{
|
||||
"auth": {
|
||||
"allowSignUp": true,
|
||||
},
|
||||
"payments": {
|
||||
"testMode": false,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("evalConfigFileContent parses show-onboarding", ({ expect }) => {
|
||||
expect(evalConfigFileContent('export const config = "show-onboarding";', "stack.config.ts")).toBe("show-onboarding");
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("evalConfigFileContent rejects content without config export", ({ expect }) => {
|
||||
expect(() => evalConfigFileContent("export const other = {};", "stack.config.ts")).toThrow(/must export/);
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("tryEvalConfigFileContent returns the config for valid exports", ({ expect }) => {
|
||||
expect(tryEvalConfigFileContent("export const config = { auth: { allowSignUp: true } };", "stack.config.ts")).toEqual({
|
||||
auth: { allowSignUp: true },
|
||||
});
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("tryEvalConfigFileContent returns null on failure", ({ expect }) => {
|
||||
expect(tryEvalConfigFileContent("export const config = {", "stack.config.ts")).toBeNull();
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("hexclaveConfigFileExportsConfig detects a config export", ({ expect }) => {
|
||||
expect(hexclaveConfigFileExportsConfig("export const config = { a: 1 };", "stack.config.ts")).toBe(true);
|
||||
expect(hexclaveConfigFileExportsConfig('import x from "./x.txt" with { type: "text" };\nexport const config = { a: x };', "stack.config.ts")).toBe(true);
|
||||
expect(hexclaveConfigFileExportsConfig("export const notConfig = { a: 1 };", "stack.config.ts")).toBe(false);
|
||||
expect(hexclaveConfigFileExportsConfig("export const config = {", "stack.config.ts")).toBe(false);
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("renderConfigFileContent rejects conflicting dotted keys", ({ expect }) => {
|
||||
expect(() => renderConfigFileContent({
|
||||
"a.b": 1,
|
||||
@ -209,8 +130,6 @@ import.meta.vitest?.test("renderConfigFileContent defaults to @hexclave/js", ({
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("renderConfigFileContent keeps legacy @stackframe packages on their root entrypoint", ({ expect }) => {
|
||||
// The lightweight `/config` subpath only exists on Hexclave-branded packages;
|
||||
// already-published @stackframe/* releases predate it.
|
||||
const content = renderConfigFileContent({}, "@stackframe/next");
|
||||
expect(content).toContain('import type { HexclaveConfig } from "@stackframe/next";');
|
||||
});
|
||||
@ -220,11 +139,24 @@ import.meta.vitest?.test("detectConfigImportPackage picks first matching package
|
||||
expect(detectConfigImportPackage(["@hexclave/react", "@hexclave/js"])).toBe("@hexclave/react");
|
||||
expect(detectConfigImportPackage(["@hexclave/js"])).toBe("@hexclave/js");
|
||||
expect(detectConfigImportPackage(["@hexclave/tanstack-start"])).toBe("@hexclave/tanstack-start");
|
||||
// Hexclave names take priority over legacy stackframe names when both appear.
|
||||
expect(detectConfigImportPackage(["@stackframe/stack", "@hexclave/next"])).toBe("@hexclave/next");
|
||||
// Legacy fallback still works for projects pinned to the last @stackframe/* release.
|
||||
expect(detectConfigImportPackage(["@stackframe/stack"])).toBe("@stackframe/stack");
|
||||
expect(detectConfigImportPackage(["@stackframe/template"])).toBe("@stackframe/template");
|
||||
expect(detectConfigImportPackage(["lodash", "express"])).toBeUndefined();
|
||||
expect(detectConfigImportPackage([])).toBeUndefined();
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("parseStaticConfigLiteral extracts JSON from rendered config", ({ expect }) => {
|
||||
const rendered = renderConfigFileContent({ auth: { allowSignUp: true } });
|
||||
expect(parseStaticConfigLiteral(rendered)).toEqual({ auth: { allowSignUp: true } });
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("parseStaticConfigLiteral returns null for non-static content", ({ expect }) => {
|
||||
expect(parseStaticConfigLiteral("export const config = someFunction();")).toBeNull();
|
||||
expect(parseStaticConfigLiteral("export const other = {};")).toBeNull();
|
||||
expect(parseStaticConfigLiteral("")).toBeNull();
|
||||
});
|
||||
|
||||
import.meta.vitest?.test("parseStaticConfigLiteral returns null for show-onboarding string", ({ expect }) => {
|
||||
expect(parseStaticConfigLiteral('export const config = "show-onboarding";')).toBeNull();
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user