From 633b49814b790d947c416b5297800db1ebfab1ea Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 12:22:01 +0000 Subject: [PATCH] feat(discovery): trust only handlerReady/available services (ADR-0076 D12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Framework discovery now reports honest capability markers (__serviceInfo: status + handlerReady, framework#2462). Consumers that gated UI on services..enabled alone would light up for dev stubs whose routes 404/501. - data-objectstack: add DiscoveryServiceInfo type + isServiceUsable() predicate — enabled && (handlerReady ?? status === 'available'). Degraded services with a live handler stay usable; stubs do not. - react useDiscovery: isAuthEnabled / isAiEnabled now use the predicate (auth stays fail-open when the discovery entry is absent). - app-shell ConditionalAuthWrapper: same predicate for the auth gate. - console useApiDiscovery: replace ad-hoc enabled/handler check with the shared predicate so degraded to unusable services stop rendering endpoint groups. - tests: table-driven unit coverage for isServiceUsable. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01A1BtxNeUaGmvUYr8FwtUNu --- .../pages/developer/hooks/useApiDiscovery.ts | 12 +++-- .../src/chrome/ConditionalAuthWrapper.tsx | 10 +++- packages/data-objectstack/src/index.ts | 40 ++++++++++++++ .../src/serviceUsable.test.ts | 52 +++++++++++++++++++ packages/react/src/hooks/useDiscovery.ts | 47 +++++++---------- 5 files changed, 128 insertions(+), 33 deletions(-) create mode 100644 packages/data-objectstack/src/serviceUsable.test.ts diff --git a/apps/console/src/pages/developer/hooks/useApiDiscovery.ts b/apps/console/src/pages/developer/hooks/useApiDiscovery.ts index 4a892ca05..86bae4143 100644 --- a/apps/console/src/pages/developer/hooks/useApiDiscovery.ts +++ b/apps/console/src/pages/developer/hooks/useApiDiscovery.ts @@ -8,6 +8,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useAdapter } from '@object-ui/app-shell'; +import { isServiceUsable } from '@object-ui/data-objectstack'; export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE' | 'PUT'; @@ -206,13 +207,16 @@ export function useApiDiscovery() { const serviceInfo = discoveredServices[serviceName] as | { enabled: boolean; status?: string; handlerReady?: boolean; route?: string } | undefined; - const isEnabled = serviceInfo?.enabled ?? false; - const hasHandler = serviceInfo?.handlerReady - ?? (serviceInfo?.status === 'available' || serviceInfo?.status === 'degraded'); + // ADR-0076 D12 (framework#2462): trust only what genuinely serves — + // handlerReady is authoritative when present; otherwise only + // status 'available'. (A bare 'degraded' without handlerReady no + // longer counts: the backend marks serving fallbacks with + // handlerReady:true explicitly.) + const usable = isServiceUsable(serviceInfo); const routePrefix = serviceInfo?.route ?? discoveredRoutes[serviceName] ?? catalog.defaultRoute; - if (isEnabled && hasHandler) { + if (usable) { serviceEndpoints.push(...buildServiceEndpoints(serviceName, routePrefix)); } } diff --git a/packages/app-shell/src/chrome/ConditionalAuthWrapper.tsx b/packages/app-shell/src/chrome/ConditionalAuthWrapper.tsx index 5e18a3de3..0aebe0652 100644 --- a/packages/app-shell/src/chrome/ConditionalAuthWrapper.tsx +++ b/packages/app-shell/src/chrome/ConditionalAuthWrapper.tsx @@ -7,7 +7,7 @@ */ import { useState, useEffect, useCallback, ReactNode } from 'react'; -import { getSharedDiscovery } from '@object-ui/data-objectstack'; +import { getSharedDiscovery, isServiceUsable } from '@object-ui/data-objectstack'; import { AuthProvider } from '@object-ui/auth'; import type { PreviewModeOptions } from '@object-ui/auth'; import { LoadingScreen } from './LoadingScreen'; @@ -101,7 +101,13 @@ export function ConditionalAuthWrapper({ children, authUrl }: ConditionalAuthWra }); setAuthEnabled(false); } else { - const isAuthEnabled = discovery?.services?.auth?.enabled ?? true; + // ADR-0076 D12 (framework#2462): gate the login requirement on a + // GENUINELY usable auth service — a stubbed/dev-fake auth entry + // (status 'stub', handlerReady false) must not force a login flow + // that cannot succeed. Missing discovery/auth entry stays fail-closed + // (assume auth exists). + const authSvc = discovery?.services?.auth; + const isAuthEnabled = authSvc ? isServiceUsable(authSvc) : true; setAuthEnabled(isAuthEnabled); } setIsLoading(false); diff --git a/packages/data-objectstack/src/index.ts b/packages/data-objectstack/src/index.ts index 09ee71850..bd9c48d65 100644 --- a/packages/data-objectstack/src/index.ts +++ b/packages/data-objectstack/src/index.ts @@ -173,6 +173,46 @@ export function clearSharedDiscoveryCache(): void { discoveryCache.clear(); } +/** + * Per-service entry in the backend discovery document's `services` map. + * Mirrors the framework's `ServiceInfoSchema` (spec/api/discovery.zod.ts). + * + * Since ADR-0076 D12 (framework#2462) the backend reports honest statuses: + * stub / dev-fake services carry `status: 'stub'`, `handlerReady: false`; + * working-but-partial fallbacks carry `status: 'degraded'` (usually with + * `handlerReady: true` — they genuinely serve requests). + */ +export interface DiscoveryServiceInfo { + enabled: boolean; + /** Known values today; servers may introduce new statuses, so plain strings are accepted. */ + status?: 'available' | 'registered' | 'unavailable' | 'degraded' | 'stub' | (string & {}); + /** + * Whether the HTTP handler is confirmed mounted. Omitted = unknown; + * false = route declared but stubbed/missing (expect 501/404). + */ + handlerReady?: boolean; + route?: string; + provider?: string; + message?: string; +} + +/** + * The single capability predicate for discovery services (ADR-0076 D12): + * trust only what genuinely serves. `handlerReady` is authoritative when + * present; otherwise only `status: 'available'` counts. A `degraded` + * fallback with `handlerReady: true` (e.g. the built-in analytics fallback) + * IS usable; a dev stub (`status: 'stub'`, `handlerReady: false`) is NOT. + * + * Returns false for a missing entry — callers that need a different default + * for an absent service (e.g. the auth bootstrap's fail-closed "assume auth + * exists") should branch on presence before calling this. + */ +export function isServiceUsable(svc: DiscoveryServiceInfo | undefined | null): boolean { + if (!svc || svc.enabled !== true) return false; + if (typeof svc.handlerReady === 'boolean') return svc.handlerReady; + return svc.status === 'available'; +} + /** * Detect "missing resource" errors regardless of where they originate. * diff --git a/packages/data-objectstack/src/serviceUsable.test.ts b/packages/data-objectstack/src/serviceUsable.test.ts new file mode 100644 index 000000000..de6cf85ba --- /dev/null +++ b/packages/data-objectstack/src/serviceUsable.test.ts @@ -0,0 +1,52 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect } from 'vitest'; +import { isServiceUsable, type DiscoveryServiceInfo } from './index'; + +describe('isServiceUsable (framework#2462 ADR-0076 D12 honest capabilities)', () => { + it('returns false for missing service entries', () => { + expect(isServiceUsable(undefined)).toBe(false); + expect(isServiceUsable(null)).toBe(false); + }); + + it('returns false when enabled is not strictly true', () => { + expect(isServiceUsable({ enabled: false })).toBe(false); + expect( + isServiceUsable({ enabled: 'yes' as unknown as boolean }) + ).toBe(false); + }); + + it('trusts handlerReady when present, regardless of status', () => { + const cases: Array<[DiscoveryServiceInfo, boolean]> = [ + // dev stub: enabled but no live handler → NOT usable + [{ enabled: true, status: 'stub', handlerReady: false }, false], + // degraded fallback with a real handler (e.g. analytics fallback) → usable + [{ enabled: true, status: 'degraded', handlerReady: true }, true], + [{ enabled: true, status: 'available', handlerReady: false }, false], + [{ enabled: true, handlerReady: true }, true], + ]; + for (const [svc, expected] of cases) { + expect(isServiceUsable(svc)).toBe(expected); + } + }); + + it("falls back to status === 'available' when handlerReady is absent", () => { + expect(isServiceUsable({ enabled: true, status: 'available' })).toBe(true); + expect(isServiceUsable({ enabled: true, status: 'registered' })).toBe(false); + expect(isServiceUsable({ enabled: true, status: 'degraded' })).toBe(false); + expect(isServiceUsable({ enabled: true, status: 'unavailable' })).toBe(false); + expect(isServiceUsable({ enabled: true, status: 'stub' })).toBe(false); + }); + + it('returns false for enabled entries with neither handlerReady nor status', () => { + // pre-D12 servers never send bare { enabled: true } without status; + // treat unknown shapes conservatively + expect(isServiceUsable({ enabled: true })).toBe(false); + }); +}); diff --git a/packages/react/src/hooks/useDiscovery.ts b/packages/react/src/hooks/useDiscovery.ts index 3468e6226..3719a841f 100644 --- a/packages/react/src/hooks/useDiscovery.ts +++ b/packages/react/src/hooks/useDiscovery.ts @@ -8,6 +8,7 @@ import { useState, useEffect, useContext } from 'react'; import { SchemaRendererContext } from '../context/SchemaRendererContext'; +import { isServiceUsable, type DiscoveryServiceInfo } from '@object-ui/data-objectstack'; /** * Discovery service information structure. @@ -31,31 +32,21 @@ export interface DiscoveryInfo { bannerMessage?: string; }; - /** Service availability status */ + /** + * Service availability status map. Entries mirror the framework's + * ServiceInfoSchema — since ADR-0076 D12 (framework#2462) stubs/fallbacks + * report `status: 'stub' | 'degraded'` and `handlerReady`; gate real + * functionality with `isServiceUsable(...)`, never on `enabled` alone. + */ services?: { /** Authentication service status */ - auth?: { - enabled: boolean; - status?: 'available' | 'unavailable'; - message?: string; - }; + auth?: DiscoveryServiceInfo; /** Data access service status */ - data?: { - enabled: boolean; - status?: 'available' | 'unavailable'; - }; + data?: DiscoveryServiceInfo; /** Metadata service status */ - metadata?: { - enabled: boolean; - status?: 'available' | 'unavailable'; - }; + metadata?: DiscoveryServiceInfo; /** AI service configuration */ - ai?: { - enabled: boolean; - status?: 'available' | 'unavailable'; - /** AI service endpoint route (e.g. '/api/v1/ai') */ - route?: string; - }; + ai?: DiscoveryServiceInfo; [key: string]: any; }; @@ -152,17 +143,19 @@ export function useDiscovery() { isLoading, error, /** - * Check if authentication is enabled on the server. - * Defaults to true if discovery data is not available. + * Check if authentication is genuinely usable on the server + * (ADR-0076 D12: a stubbed/dev-fake auth service does not count). + * Defaults to true (fail closed: assume auth exists) when discovery + * data or the auth entry is not available. */ - isAuthEnabled: discovery?.services?.auth?.enabled ?? true, + isAuthEnabled: discovery?.services?.auth + ? isServiceUsable(discovery.services.auth) + : true, /** - * Check if AI service is enabled and available on the server. + * Check if the AI service is genuinely usable on the server. * Defaults to false if discovery data is not available. */ - isAiEnabled: - discovery?.services?.ai?.enabled === true && - discovery?.services?.ai?.status === 'available', + isAiEnabled: isServiceUsable(discovery?.services?.ai), }; }