mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(auth): honor REQUIRE_API_KEY feature flag (#3188)
Integrated into release/v3.8.11
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
---
|
||||
title: "Authorization Guide"
|
||||
version: 3.8.2
|
||||
lastUpdated: 2026-05-13
|
||||
lastUpdated: 2026-06-05
|
||||
---
|
||||
|
||||
# Authorization Guide
|
||||
|
||||
> **Source of truth:** `src/server/authz/`, `src/shared/constants/publicApiRoutes.ts`, `src/lib/api/requireManagementAuth.ts`, `src/shared/utils/apiAuth.ts`
|
||||
> **Last updated:** 2026-05-13 — v3.8.0
|
||||
> **Last updated:** 2026-06-05 — v3.8.2
|
||||
|
||||
OmniRoute has a route-aware authorization pipeline that gates every API request. Classification is **deterministic** and **fail-closed** — anything that cannot be classified ends up as `MANAGEMENT` and demands a session or management-grade token. This page explains the model for engineers maintaining routes or designing new endpoints.
|
||||
|
||||
@@ -43,16 +43,16 @@ Some management routes accept **either** mode: cookie OR `Bearer <key>` when the
|
||||
|
||||
`src/server/authz/types.ts` defines three classes; any route that cannot be classified deterministically falls back to `MANAGEMENT`.
|
||||
|
||||
| Class | Description | Auth required |
|
||||
| ------------ | ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
|
||||
| `PUBLIC` | Explicitly safe routes — login, logout, status, init, health, onboarding bootstrap. | None |
|
||||
| `CLIENT_API` | Model-serving endpoints — `/api/v1/*`, plus aliases `/v1/*`, `/chat/completions`, `/responses`, `/models`, `/codex/*`. | Bearer key (unless `REQUIRE_API_KEY != "true"`) |
|
||||
| `MANAGEMENT` | Dashboard pages, settings, providers, keys, admin and diagnostics endpoints. | Dashboard session OR Bearer with `manage` scope |
|
||||
| Class | Description | Auth required |
|
||||
| ------------ | ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| `PUBLIC` | Explicitly safe routes — login, logout, status, init, health, onboarding bootstrap. | None |
|
||||
| `CLIENT_API` | Model-serving endpoints — `/api/v1/*`, plus aliases `/v1/*`, `/chat/completions`, `/responses`, `/models`, `/codex/*`. | Bearer key when the effective `REQUIRE_API_KEY` feature flag is enabled |
|
||||
| `MANAGEMENT` | Dashboard pages, settings, providers, keys, admin and diagnostics endpoints. | Dashboard session OR Bearer with `manage` scope |
|
||||
|
||||
## Pipeline
|
||||
|
||||
```
|
||||
Incoming request → src/middleware.ts
|
||||
Incoming request → src/proxy.ts
|
||||
→ runAuthzPipeline() in src/server/authz/pipeline.ts
|
||||
1. Strip trusted internal headers (x-omniroute-auth-*, x-omniroute-route-class)
|
||||
2. Generate request id, classify route via classifyRoute()
|
||||
@@ -73,7 +73,7 @@ Trusted internal headers (defined in `src/server/authz/headers.ts`) are **stripp
|
||||
Each route class has a policy in `src/server/authz/policies/`:
|
||||
|
||||
- **`publicPolicy`** (`policies/public.ts`) — always returns `allow({ kind: "anonymous", id: "anonymous" })`.
|
||||
- **`clientApiPolicy`** (`policies/clientApi.ts`) — extracts Bearer, validates via `validateApiKey()`. Falls through to anonymous if `REQUIRE_API_KEY != "true"`. Allows dashboard-session GET on `/api/v1/models` (used by the dashboard model catalog).
|
||||
- **`clientApiPolicy`** (`policies/clientApi.ts`) — extracts Bearer, validates via `validateApiKey()`. Falls through to anonymous only when the effective `REQUIRE_API_KEY` feature flag is disabled. The effective flag is resolved through `isRequireApiKeyEnabled()` (`DB feature flag override > process.env.REQUIRE_API_KEY > default`) so Dashboard Feature Flags and environment variables govern `/api/v1/*` and aliases consistently; resolver failures fail closed. Allows dashboard-session requests on client API routes (including `/api/v1/models`, used by the dashboard model catalog).
|
||||
- **`managementPolicy`** (`policies/management.ts`) — accepts dashboard session, internal model-sync requests (matched against `/api/providers/[name]/(sync-models|models)`), or skips entirely if `isAuthRequired()` returns false. Returns 403 (`AUTH_001`) when a Bearer token is present but invalid, 401 otherwise. Also enforces the route-guard tiers (LOCAL_ONLY / ALWAYS_PROTECTED) before any auth branch — see [Route Guard Tiers](../security/ROUTE_GUARD_TIERS.md). LOCAL_ONLY paths in `LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES` (today: `/api/mcp/`) may be accessed from non-loopback when the Bearer key carries the `manage` scope; all other LOCAL_ONLY paths remain strict-loopback regardless of scope.
|
||||
|
||||
A successful policy returns `AuthSubject` with `kind ∈ { client_api_key, dashboard_session, management_key, anonymous }`. Downstream handlers can read it via `assertAuth(request, "CLIENT_API")` in `src/server/authz/assertAuth.ts` instead of re-running auth logic.
|
||||
@@ -173,6 +173,8 @@ Preset bundles (`MCP_SCOPE_PRESETS`): `readonly`, `full`, `monitor`, `agent`. Us
|
||||
- No password configured **and** no `INITIAL_PASSWORD` env var → bootstrap mode allows the onboarding wizard and loopback requests, but exposed network requests still need credentials.
|
||||
- Any DB error → fails closed (secure-by-default).
|
||||
|
||||
Client API key enforcement uses `isRequireApiKeyEnabled()` in `src/shared/utils/featureFlags.ts`, not a direct `process.env.REQUIRE_API_KEY` read. This matters for deployed instances: toggling `REQUIRE_API_KEY` in Dashboard → Feature Flags stores a DB override and immediately affects `/v1/*`, `/models`, `/responses`, `/chat/completions`, `/codex/*`, and other client-API auth checks that share this helper. If the feature flag store cannot be read, client API auth fails closed and requires a key.
|
||||
|
||||
## Breaking Change — v3.8.0
|
||||
|
||||
The `/api/v1/agents/tasks/*` and `/api/resilience/model-cooldowns` endpoints **now require management auth** (commit `588a0333`). Clients previously sending a normal API key without the `manage` scope receive `403`. Migration: either issue the key the `manage` scope in the API Manager dashboard, or use a logged-in dashboard session.
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
buildImproveChatBody,
|
||||
parseImprovedContent,
|
||||
} from "@/lib/playground/promptImprover";
|
||||
import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags";
|
||||
|
||||
const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
@@ -64,7 +65,7 @@ export async function POST(request: Request): Promise<Response> {
|
||||
|
||||
// 3. Optional auth (mirrors /v1/web/fetch pattern)
|
||||
const apiKeyRaw = extractApiKey(request);
|
||||
if (process.env.REQUIRE_API_KEY === "true" && !apiKeyRaw) {
|
||||
if (isRequireApiKeyEnabled() && !apiKeyRaw) {
|
||||
return errorResp(HTTP_STATUS.UNAUTHORIZED, "Authentication required");
|
||||
}
|
||||
if (apiKeyRaw && !(await isValidApiKey(apiKeyRaw))) {
|
||||
@@ -76,8 +77,7 @@ export async function POST(request: Request): Promise<Response> {
|
||||
|
||||
// 5. Call /v1/chat/completions on ourselves (D8)
|
||||
const port = process.env.PORT ?? "20128";
|
||||
const baseUrl =
|
||||
process.env.OMNIROUTE_BASE_URL ?? `http://127.0.0.1:${port}`;
|
||||
const baseUrl = process.env.OMNIROUTE_BASE_URL ?? `http://127.0.0.1:${port}`;
|
||||
const upstreamUrl = `${baseUrl}/v1/chat/completions`;
|
||||
|
||||
let upstreamResponse: Response;
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
deletePlaygroundPreset,
|
||||
} from "@/lib/db/playgroundPresets";
|
||||
import { PlaygroundPresetUpdateSchema } from "@/shared/schemas/playground";
|
||||
import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags";
|
||||
|
||||
const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Methods": "GET, PUT, DELETE, OPTIONS",
|
||||
@@ -26,18 +27,15 @@ const CORS_HEADERS = {
|
||||
};
|
||||
|
||||
function errorResp(status: number, message: string): Response {
|
||||
return new Response(
|
||||
JSON.stringify(buildErrorBody(status, sanitizeErrorMessage(message))),
|
||||
{
|
||||
status,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
}
|
||||
);
|
||||
return new Response(JSON.stringify(buildErrorBody(status, sanitizeErrorMessage(message))), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
});
|
||||
}
|
||||
|
||||
async function checkAuth(request: Request): Promise<Response | null> {
|
||||
const apiKeyRaw = extractApiKey(request);
|
||||
if (process.env.REQUIRE_API_KEY === "true" && !apiKeyRaw) {
|
||||
if (isRequireApiKeyEnabled() && !apiKeyRaw) {
|
||||
return errorResp(HTTP_STATUS.UNAUTHORIZED, "Authentication required");
|
||||
}
|
||||
if (apiKeyRaw && !(await isValidApiKey(apiKeyRaw))) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import { listPlaygroundPresets, createPlaygroundPreset } from "@/lib/db/playgroundPresets";
|
||||
import { PlaygroundPresetCreateSchema } from "@/shared/schemas/playground";
|
||||
import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags";
|
||||
|
||||
const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
@@ -19,18 +20,15 @@ const CORS_HEADERS = {
|
||||
};
|
||||
|
||||
function errorResp(status: number, message: string): Response {
|
||||
return new Response(
|
||||
JSON.stringify(buildErrorBody(status, sanitizeErrorMessage(message))),
|
||||
{
|
||||
status,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
}
|
||||
);
|
||||
return new Response(JSON.stringify(buildErrorBody(status, sanitizeErrorMessage(message))), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
});
|
||||
}
|
||||
|
||||
async function checkAuth(request: Request): Promise<Response | null> {
|
||||
const apiKeyRaw = extractApiKey(request);
|
||||
if (process.env.REQUIRE_API_KEY === "true" && !apiKeyRaw) {
|
||||
if (isRequireApiKeyEnabled() && !apiKeyRaw) {
|
||||
return errorResp(HTTP_STATUS.UNAUTHORIZED, "Authentication required");
|
||||
}
|
||||
if (apiKeyRaw && !(await isValidApiKey(apiKeyRaw))) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import { isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags";
|
||||
import { projectCombo, type PublicCombo } from "./projectCombo";
|
||||
|
||||
export async function OPTIONS() {
|
||||
@@ -33,7 +34,7 @@ export async function GET(request: Request) {
|
||||
const dashboardOk = !apiKeyOk ? await isDashboardSessionAuthenticated(request) : false;
|
||||
|
||||
if (!apiKeyOk && !dashboardOk) {
|
||||
if (process.env.REQUIRE_API_KEY === "true") {
|
||||
if (isRequireApiKeyEnabled()) {
|
||||
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Authentication required");
|
||||
}
|
||||
// REQUIRE_API_KEY=false → still allow anonymous read of public metadata.
|
||||
|
||||
@@ -6,6 +6,7 @@ import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import * as log from "@/sse/utils/logger";
|
||||
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
|
||||
import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags";
|
||||
import { v1EmbeddingsSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
@@ -91,7 +92,7 @@ export async function POST(request) {
|
||||
|
||||
// Auth check
|
||||
const apiKeyRaw = extractApiKey(request);
|
||||
if (process.env.REQUIRE_API_KEY === "true" && !apiKeyRaw) {
|
||||
if (isRequireApiKeyEnabled() && !apiKeyRaw) {
|
||||
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Authentication required");
|
||||
}
|
||||
if (apiKeyRaw && !(await isValidApiKey(apiKeyRaw))) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { handleWebFetch } from "@omniroute/open-sse/handlers/webFetch.ts";
|
||||
import * as log from "@/sse/utils/logger";
|
||||
import { extractApiKey, isValidApiKey, getProviderCredentials } from "@/sse/services/auth";
|
||||
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
|
||||
import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags";
|
||||
import { v1WebFetchSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
@@ -61,7 +62,7 @@ export async function POST(request: Request) {
|
||||
|
||||
// Optional auth check
|
||||
const apiKeyRaw = extractApiKey(request);
|
||||
if (process.env.REQUIRE_API_KEY === "true" && !apiKeyRaw) {
|
||||
if (isRequireApiKeyEnabled() && !apiKeyRaw) {
|
||||
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Authentication required");
|
||||
}
|
||||
if (apiKeyRaw && !(await isValidApiKey(apiKeyRaw))) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth.ts";
|
||||
import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags";
|
||||
import type { AuthOutcome, PolicyContext, RoutePolicy } from "../context";
|
||||
import { allow, reject } from "../context";
|
||||
|
||||
@@ -29,7 +30,7 @@ export const clientApiPolicy: RoutePolicy = {
|
||||
return allow({ kind: "dashboard_session", id: "dashboard" });
|
||||
}
|
||||
|
||||
if (process.env.REQUIRE_API_KEY !== "true") {
|
||||
if (!isRequireApiKeyEnabled()) {
|
||||
return allow({ kind: "anonymous", id: "local" });
|
||||
}
|
||||
|
||||
@@ -45,7 +46,7 @@ export const clientApiPolicy: RoutePolicy = {
|
||||
// "anonymous traffic is allowed", so an invalid key should degrade to
|
||||
// anonymous instead of rejecting. We log a warning so the bad key is
|
||||
// still observable in the request log.
|
||||
if (process.env.REQUIRE_API_KEY !== "true") {
|
||||
if (!isRequireApiKeyEnabled()) {
|
||||
console.warn(
|
||||
`[clientApiPolicy] invalid bearer presented to ${ctx.classification.normalizedPath} ` +
|
||||
`but REQUIRE_API_KEY=false — falling through to anonymous (key_id=${maskKeyId(bearer)})`
|
||||
|
||||
@@ -21,7 +21,7 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [
|
||||
category: "security",
|
||||
defaultValue: "false",
|
||||
type: "boolean",
|
||||
requiresRestart: true,
|
||||
requiresRestart: false,
|
||||
warningLevel: "caution",
|
||||
},
|
||||
{
|
||||
@@ -72,7 +72,8 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [
|
||||
{
|
||||
key: "PII_RESPONSE_SANITIZATION_MODE",
|
||||
label: "PII Response Sanitization Mode",
|
||||
description: "Mode for PII response sanitization: redact (replace PII), warn (log only), block (reject), off (disable)",
|
||||
description:
|
||||
"Mode for PII response sanitization: redact (replace PII), warn (log only), block (reject), off (disable)",
|
||||
descriptionI18nKey: "featureFlagPiiResponseSanitizationModeDescription",
|
||||
category: "security",
|
||||
defaultValue: "redact",
|
||||
|
||||
@@ -56,6 +56,18 @@ export function resolveAllFeatureFlags(): Array<{
|
||||
}
|
||||
|
||||
// Backward-compatible wrappers
|
||||
export function isRequireApiKeyEnabled(): boolean {
|
||||
try {
|
||||
return isFeatureFlagEnabled("REQUIRE_API_KEY");
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[featureFlags] Failed to resolve REQUIRE_API_KEY, defaulting to required:",
|
||||
error instanceof Error ? error.message : error
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function isCcCompatibleProviderEnabled(): boolean {
|
||||
return isFeatureFlagEnabled("ENABLE_CC_COMPATIBLE_PROVIDER");
|
||||
}
|
||||
|
||||
@@ -40,9 +40,14 @@ const POLICY_IMPORT_TARGET = "src/lib/db/apiKeys";
|
||||
|
||||
// Write the stub file ad-hoc (Node's loader needs a real file)
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omr-clientapi-policy-fallback-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const STUB_PATH = path.join(__dirname, "__stub_apiKeys.mjs");
|
||||
fs.writeFileSync(
|
||||
@@ -60,6 +65,9 @@ test.after(() => {
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = ORIGINAL_DATA_DIR;
|
||||
});
|
||||
|
||||
// ─── Load policy fresh (after the interceptor is in place) ────────────────
|
||||
|
||||
@@ -10,6 +10,7 @@ process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = "test-secret";
|
||||
|
||||
const apiKeysDb = await import("../../../src/lib/db/apiKeys.ts");
|
||||
const featureFlagsDb = await import("../../../src/lib/db/featureFlags.ts");
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
|
||||
const ORIGINAL_OMNIROUTE_API_KEY = process.env.OMNIROUTE_API_KEY;
|
||||
@@ -81,6 +82,32 @@ test("clientApiPolicy: missing bearer is rejected with 401", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("clientApiPolicy: REQUIRE_API_KEY DB feature flag override rejects anonymous", async () => {
|
||||
process.env.REQUIRE_API_KEY = "false";
|
||||
featureFlagsDb.setFeatureFlagOverride("REQUIRE_API_KEY", "true");
|
||||
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(ctx(new Headers()));
|
||||
assert.equal(out.allow, false);
|
||||
if (!out.allow) {
|
||||
assert.equal(out.status, 401);
|
||||
assert.equal(out.code, "AUTH_002");
|
||||
}
|
||||
});
|
||||
|
||||
test("clientApiPolicy: REQUIRE_API_KEY DB feature flag override can disable env requirement", async () => {
|
||||
process.env.REQUIRE_API_KEY = "true";
|
||||
featureFlagsDb.setFeatureFlagOverride("REQUIRE_API_KEY", "false");
|
||||
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(ctx(new Headers()));
|
||||
assert.equal(out.allow, true);
|
||||
if (out.allow) {
|
||||
assert.equal(out.subject.kind, "anonymous");
|
||||
assert.equal(out.subject.id, "local");
|
||||
}
|
||||
});
|
||||
|
||||
test("clientApiPolicy: dashboard session can read the model catalog without bearer", async () => {
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(
|
||||
|
||||
@@ -23,6 +23,7 @@ const {
|
||||
resolveFeatureFlag,
|
||||
isFeatureFlagEnabled,
|
||||
resolveAllFeatureFlags,
|
||||
isRequireApiKeyEnabled,
|
||||
isCcCompatibleProviderEnabled,
|
||||
} = await import("../../src/shared/utils/featureFlags.ts");
|
||||
|
||||
@@ -249,6 +250,29 @@ describe("resolveFeatureFlag", () => {
|
||||
});
|
||||
|
||||
describe("backward compatibility", () => {
|
||||
it("isRequireApiKeyEnabled uses the resolved REQUIRE_API_KEY flag", () => {
|
||||
setFeatureFlagOverride("REQUIRE_API_KEY", "true");
|
||||
assert.strictEqual(isRequireApiKeyEnabled(), true);
|
||||
});
|
||||
|
||||
it("isRequireApiKeyEnabled fails closed when the flag store cannot be read", () => {
|
||||
const originalError = console.error;
|
||||
console.error = () => {};
|
||||
try {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
const blockerPath = path.join(tmpDir, "storage.sqlite");
|
||||
fs.mkdirSync(blockerPath, { recursive: true });
|
||||
assert.strictEqual(isRequireApiKeyEnabled(), true);
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("isCcCompatibleProviderEnabled still works", () => {
|
||||
const result = isCcCompatibleProviderEnabled();
|
||||
assert.strictEqual(typeof result, "boolean");
|
||||
|
||||
Reference in New Issue
Block a user