From 4a5e123bad001e68d10e2ad6cc32457632052c86 Mon Sep 17 00:00:00 2001 From: Xiangzhe <32761048+xz-dev@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:53:27 +0800 Subject: [PATCH] fix(auth): honor REQUIRE_API_KEY feature flag (#3188) Integrated into release/v3.8.11 --- docs/architecture/AUTHZ_GUIDE.md | 20 +++++++------- .../api/playground/improve-prompt/route.ts | 6 ++--- src/app/api/playground/presets/[id]/route.ts | 14 +++++----- src/app/api/playground/presets/route.ts | 14 +++++----- src/app/api/v1/combos/route.ts | 3 ++- src/app/api/v1/embeddings/route.ts | 3 ++- src/app/api/v1/web/fetch/route.ts | 3 ++- src/server/authz/policies/clientApi.ts | 5 ++-- .../constants/featureFlagDefinitions.ts | 5 ++-- src/shared/utils/featureFlags.ts | 12 +++++++++ .../authz/client-api-policy-fallback.test.ts | 8 ++++++ tests/unit/authz/client-api-policy.test.ts | 27 +++++++++++++++++++ tests/unit/feature-flags-settings.test.ts | 24 +++++++++++++++++ 13 files changed, 109 insertions(+), 35 deletions(-) diff --git a/docs/architecture/AUTHZ_GUIDE.md b/docs/architecture/AUTHZ_GUIDE.md index 66dd6fbdfe..5dc16a07db 100644 --- a/docs/architecture/AUTHZ_GUIDE.md +++ b/docs/architecture/AUTHZ_GUIDE.md @@ -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 ` 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. diff --git a/src/app/api/playground/improve-prompt/route.ts b/src/app/api/playground/improve-prompt/route.ts index a0efd85d67..daa1c9dc4c 100644 --- a/src/app/api/playground/improve-prompt/route.ts +++ b/src/app/api/playground/improve-prompt/route.ts @@ -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 { // 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 { // 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; diff --git a/src/app/api/playground/presets/[id]/route.ts b/src/app/api/playground/presets/[id]/route.ts index ceabc40429..a0682cffbd 100644 --- a/src/app/api/playground/presets/[id]/route.ts +++ b/src/app/api/playground/presets/[id]/route.ts @@ -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 { 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))) { diff --git a/src/app/api/playground/presets/route.ts b/src/app/api/playground/presets/route.ts index 37b2b2b17d..d290524cc2 100644 --- a/src/app/api/playground/presets/route.ts +++ b/src/app/api/playground/presets/route.ts @@ -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 { 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))) { diff --git a/src/app/api/v1/combos/route.ts b/src/app/api/v1/combos/route.ts index 882da46539..b7a1413f88 100644 --- a/src/app/api/v1/combos/route.ts +++ b/src/app/api/v1/combos/route.ts @@ -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. diff --git a/src/app/api/v1/embeddings/route.ts b/src/app/api/v1/embeddings/route.ts index 0cf3bbd16d..130c5d2a22 100644 --- a/src/app/api/v1/embeddings/route.ts +++ b/src/app/api/v1/embeddings/route.ts @@ -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))) { diff --git a/src/app/api/v1/web/fetch/route.ts b/src/app/api/v1/web/fetch/route.ts index e78eb8399f..74111e22e5 100644 --- a/src/app/api/v1/web/fetch/route.ts +++ b/src/app/api/v1/web/fetch/route.ts @@ -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))) { diff --git a/src/server/authz/policies/clientApi.ts b/src/server/authz/policies/clientApi.ts index da893d932d..8b90e5b07a 100644 --- a/src/server/authz/policies/clientApi.ts +++ b/src/server/authz/policies/clientApi.ts @@ -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)})` diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index d725af161c..f7d273c42e 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -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", diff --git a/src/shared/utils/featureFlags.ts b/src/shared/utils/featureFlags.ts index 2b04a77eb1..e3c5829255 100644 --- a/src/shared/utils/featureFlags.ts +++ b/src/shared/utils/featureFlags.ts @@ -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"); } diff --git a/tests/unit/authz/client-api-policy-fallback.test.ts b/tests/unit/authz/client-api-policy-fallback.test.ts index 1c90e1f639..9971bf9f33 100644 --- a/tests/unit/authz/client-api-policy-fallback.test.ts +++ b/tests/unit/authz/client-api-policy-fallback.test.ts @@ -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) ──────────────── diff --git a/tests/unit/authz/client-api-policy.test.ts b/tests/unit/authz/client-api-policy.test.ts index bdac6ffad7..fb8c5e41d1 100644 --- a/tests/unit/authz/client-api-policy.test.ts +++ b/tests/unit/authz/client-api-policy.test.ts @@ -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( diff --git a/tests/unit/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index 20b075b2a7..8511980d95 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -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");