diff --git a/.changeset/app-denied-vs-unpublished-4252.md b/.changeset/app-denied-vs-unpublished-4252.md new file mode 100644 index 000000000..c0c3893e0 --- /dev/null +++ b/.changeset/app-denied-vs-unpublished-4252.md @@ -0,0 +1,19 @@ +--- +'@object-ui/data-objectstack': minor +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +An app you are not allowed to open now says so, instead of reporting that it may still be publishing + +`GET /api/v1/meta/apps` is filtered per session server-side (`filterAppForUser`), so an app withheld by its `requiredPermissions` and an app that does not exist were byte-identical to the console: both simply absent from the list. With one fact and two conditions, `AppContent` rendered its only copy for an absent app — "This app is not available yet — it may still be publishing. Try again in a moment." — over a permanent authorization decision, under a Retry button that could never succeed. + +That is not a cosmetic complaint. On a downstream acceptance round one role hit this screen while another opened the same app fine, and because the copy names a transient deployment state the finding was filed as a suspected platform defect and carried through two test batches before a clean-baseline investigation found the account was missing a permission-set binding. The gate had been working exactly as designed; the message is what sent everyone to the wrong place. + +The maintainer ruling (2026-08-12) took the contract half first. objectstack#8013 made the BY-NAME route answer an explicit denial — `403` with the ADR-0112 catalog code `PERMISSION_DENIED` in the declared `{ success: false, error: { code, message } }` envelope — for an app that exists and whose `requiredPermissions` the session lacks, while the LIST route stays filtered exactly as before, with no `authorized: false` flag, so the enumeration surface is not widened past what a direct by-name probe already implies. Absence keeps answering `404 RESOURCE_NOT_FOUND`, and so do the two neighbouring refusals the same ruling deliberately left alone: an unpublished app (ADR-0045 §3 keeps it externally unobservable) and an app gated by an absent optional service (ADR-0057 D10 — nothing was denied to the caller). + +This is the console half. When a requested app is missing from the list and the existing post-publish readiness re-check still cannot find it, the console asks the by-name route which of the two it is, through a new `ObjectStackAdapter.probeAppAccess(name)`. On the measured code it renders a plain authorization message with a way back to the launcher; on anything else — an absent app, an unreachable server, a host that injected a DataSource without the probe — today's publishing copy renders byte for byte, retry button included. + +Two properties of that seam are load-bearing rather than incidental. It branches on the ADR-0112 **code**, never the status (objectui#4408): the two answers under test are both errors one status apart, and a status-reading implementation passes the happy path while going blind exactly where the defect lives. And only `denied` moves the copy: this bug exists because the console asserted a state it had not measured, so a probe that fails, times out or cannot be issued must leave the screen alone rather than guess in the other direction. + +`probeAppAccess` is deliberately separate from `getApp` rather than a flag on it: `getApp` degrades every failure to `null` — the very conflation being undone — and memoises in the adapter's metadata cache, where a verdict about the CALLER would outlive the session it described. New public API on the adapter (`probeAppAccess`, `isAppPermissionDeniedError`, `APP_PERMISSION_DENIED_CODE`, `AppAccessVerdict`), purely additive; nothing existing changed shape. Three new `empty.*` keys ship in all ten locale packs. diff --git a/packages/app-shell/src/console/AppContent.tsx b/packages/app-shell/src/console/AppContent.tsx index c0024a98a..7983b64e8 100644 --- a/packages/app-shell/src/console/AppContent.tsx +++ b/packages/app-shell/src/console/AppContent.tsx @@ -16,7 +16,7 @@ import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/componen import { toast } from 'sonner'; import { useActionRunner, useGlobalUndo, useMutationInvalidationBridge, notifyDataChanged, FilterScopeProvider } from '@object-ui/react'; import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; -import type { ConnectionState } from '@object-ui/data-objectstack'; +import type { AppAccessVerdict, ConnectionState } from '@object-ui/data-objectstack'; import { useAuth, useIsWorkspaceAdmin } from '@object-ui/auth'; import { useMetadata } from '../providers/MetadataProvider'; import { useAdapter } from '../providers/AdapterProvider'; @@ -242,6 +242,57 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = } }, [requestedAppMissing, metadataLoading, previewDrafts, missingRecheck, refreshMetadata]); + // objectui#4252 — WHY the app is missing, once the re-check above has settled + // and it still is. `GET /meta/apps` is filtered per session server-side + // (`filterAppForUser`), so a withheld app and a nonexistent one are + // byte-identical in the list: both absent. Everything this component can read + // from the list has therefore already been read, and the remaining question + // has to be asked of the BY-NAME route, which answers it explicitly since + // objectstack#8013 (403 `PERMISSION_DENIED` for exists-but-unauthorized; 404 + // for every kind of absence). The maintainer ruling put the answer there + // rather than as a flag in the list, so nothing below re-reads the list. + // + // Only the measured verdict `denied` changes what renders. `unknown` — an + // absent app, an unreachable server, a host that injected an adapter without + // the probe — keeps the existing screen exactly as it is today: this fix + // exists because the console asserted a state it had not measured, and + // guessing in the other direction would be the same defect mirrored. + // The verdict is stored WITH the app it describes, and read back only for + // that app. Two missing apps in a row keep `requestedAppMissing` true the + // whole way across, so nothing in this branch is reset by the transition — a + // verdict held loose from its name would ride into the next URL and answer + // for an app it never probed, telling a user their typo is a permission + // problem. (`missingRecheck` above has the same shape and is deliberately + // left alone: its staleness costs one skipped refresh, not a wrong screen.) + const [accessProbe, setAccessProbe] = useState<{ app: string; verdict: AppAccessVerdict } | null>(null); + useEffect(() => { + if (!requestedAppMissing || previewDrafts || missingRecheck !== 'done' || !appName) { + // Cleared on the way out too, so the Retry button below (which returns + // `missingRecheck` to 'idle') re-asks instead of replaying an answer. + setAccessProbe(p => (p === null ? p : null)); + return; + } + if (accessProbe?.app === appName) return; + const probe = dataSource?.probeAppAccess; + if (typeof probe !== 'function') { + // AGENTS #1 — the console is protocol-agnostic: a host may inject a + // DataSource that cannot answer this. Degrade to today's copy. + setAccessProbe({ app: appName, verdict: 'unknown' }); + return; + } + let cancelled = false; + const probed = appName; + void Promise.resolve(probe.call(dataSource, probed)) + .then(verdict => { if (!cancelled) setAccessProbe({ app: probed, verdict }); }) + .catch(() => { if (!cancelled) setAccessProbe({ app: probed, verdict: 'unknown' }); }); + return () => { cancelled = true; }; + }, [requestedAppMissing, previewDrafts, missingRecheck, appName, accessProbe, dataSource]); + // Never the previous app's answer: while a probe for a newly-requested app is + // in flight, this is null and the branch below waits rather than rendering the + // one it already has. + const accessVerdict: AppAccessVerdict | null = + accessProbe && accessProbe.app === appName ? accessProbe.verdict : null; + useEffect(() => { if (!activeApp?.name) return; // ADR-0048 — build against the URL's own segment (`appName`, which may be the @@ -565,7 +616,43 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = // different app, and don't show the misleading "no apps configured" screen // below (there ARE apps). requestedAppMissing already excludes pseudo-routes. if (requestedAppMissing) { - if (missingRecheck !== 'done') return ; + if (missingRecheck !== 'done' || accessVerdict === null) return ; + // objectui#4252 — the app EXISTS and this session may not open it. Say that, + // and say it as the permanent decision it is: the old copy named a transient + // deploy state, which sent a downstream acceptance round chasing a platform + // defect for two test batches over a missing permission-set binding. + // + // No Retry here — retrying a permission decision cannot change it, and a + // button that promises otherwise is the same misdirection one layer down. + // The way back is `/home` instead, because this screen (like every no-app + // surface in this file) returns ABOVE the single `ConsoleLayout` mount and + // so carries no header, no navigation and no workspace switcher — the + // objectui#4473 strand, which a dead end here would recreate. Router-relative + // for the same reason as that fix: ``/`navigate` resolve through + // the host's `basename`, and `/home` is part of the outer skeleton every + // host mounting this component provides (see this file's header). + if (accessVerdict === 'denied') { + return ( +
+ + + {t('empty.appAccessDenied', { defaultValue: "You don't have access to this app" })} + + + {t('empty.appAccessDeniedDescription', { + defaultValue: + 'This app exists, but your account is not authorized to open it. Ask an administrator to grant you access.', + })} + +
+ +
+
+
+ ); + } return (
diff --git a/packages/app-shell/src/console/__tests__/AppContent.deniedVsUnpublished.test.tsx b/packages/app-shell/src/console/__tests__/AppContent.deniedVsUnpublished.test.tsx new file mode 100644 index 000000000..bda710be4 --- /dev/null +++ b/packages/app-shell/src/console/__tests__/AppContent.deniedVsUnpublished.test.tsx @@ -0,0 +1,382 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * An app the session may not open must SAY so — not report a deploy state + * (objectui#4252). + * + * ## The measured defect + * + * `GET /api/v1/meta/apps` is filtered per session server-side + * (`filterAppForUser`), so an app withheld by `requiredPermissions` and an app + * that does not exist were byte-identical to the console: both simply absent + * from the list. `AppContent`'s `requestedAppMissing` branch therefore rendered + * its only copy for an absent app — "This app is not available yet — it may + * still be publishing. Try again in a moment." — over a PERMANENT authorization + * decision, under a Retry button that can never succeed. + * + * The cost was measured on a downstream acceptance round and is not cosmetic: + * one role hit this screen, another opened the same app fine, and because the + * copy names a transient deployment state the finding was filed as a platform + * defect and carried through two test batches. The account was missing a + * permission-set binding; the gate had been working exactly as designed. + * + * ## What made the fix possible, and what it must not become + * + * The maintainer ruling (2026-08-12) took the contract-first half first: + * objectstack#8013 (PR #8135) made the BY-NAME route answer an explicit + * permission denial, while the LIST route stays filtered exactly as before — + * no `authorized: false` flag, so the enumeration surface is not widened past + * what a by-name probe already implies. Measured off that merged diff, the + * envelope this console consumes is: + * + * 403 { success: false, error: { code: 'PERMISSION_DENIED', message } } + * + * and absence keeps its 404 `RESOURCE_NOT_FOUND`. So the branch here is on the + * ADR-0112 **code**, never on the status (the objectui#4408 lesson: a status + * cannot separate two refusals that share it), and ONLY the measured code + * changes the copy — every other answer, including a transport failure, keeps + * today's screen byte-for-byte. That direction is deliberate: a console that + * guessed "denied" from anything else would re-tell the same lie the other way + * round. + * + * ## Why the route is stubbed at the TRANSPORT + * + * The adapter is a real `ObjectStackAdapter` over a stubbed `fetch`, so these + * cases exercise the real client, the real error stamping (`error.code` / + * `httpStatus` off the envelope) and the real probe. Injecting a verdict into + * component state instead would assert the branch against a fixture of its own + * conclusion, and could not see the accessor (`body.error.code`) change. + * + * NOTE ON SCOPE: like `AppContent.inaccessibleAppStrand.test.tsx`, this file + * measures WHICH SURFACE renders. `ConsoleLayout` and the lazy pages are + * stubbed; none of their internals are part of the question. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route, useLocation, useNavigate } from 'react-router-dom'; +import { ObjectStackAdapter } from '@object-ui/data-objectstack'; + +// --------------------------------------------------------------------------- +// Mocks — everything that takes part in the DECISION stays real. +// --------------------------------------------------------------------------- + +vi.mock('@object-ui/plugin-designer', () => ({ + CreateAppPage: () =>
, + EditAppPage: () =>
, + DashboardDesignPage: () =>
, +})); + +vi.mock('../../layout/ConsoleLayout', () => ({ + ConsoleLayout: ({ activeAppName, children }: { activeAppName?: string; children?: React.ReactNode }) => ( +
+
chrome
+ {children} +
+ ), +})); +vi.mock('../../chrome/CommandPalette', () => ({ CommandPalette: () => null })); +vi.mock('../../chrome/KeyboardShortcutsDialog', () => ({ KeyboardShortcutsDialog: () => null })); +vi.mock('../../chrome/OnboardingWalkthrough', () => ({ OnboardingWalkthrough: () => null })); +vi.mock('../../views/ObjectView', () => ({ ObjectView: () =>
})); + +/** + * `t` resolves out of the REAL locale packs, so what these cases read is the + * shipped string for the active language rather than the call site's inline + * default. That is what makes the `zh` case below a rendering fact. + */ +let locale: 'en' | 'zh' = 'en'; +vi.mock('@object-ui/i18n', async (importOriginal) => { + const actual = await importOriginal>(); + const lookup = (pack: unknown, key: string): unknown => + key.split('.').reduce((node, k) => (node == null ? undefined : node[k]), pack); + return { + ...actual, + useObjectTranslation: () => ({ + t: (key: string, options?: Record) => { + const hit = lookup(locale === 'zh' ? actual.zh : actual.en, key); + return typeof hit === 'string' ? hit : String(options?.defaultValue ?? key); + }, + }), + useObjectLabel: () => ({ objectLabel: ({ label }: { label?: string }) => label }), + }; +}); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ + user: { id: 'u_b', name: 'B', email: 'b@example.com', role: 'member' }, + getAuthConfig: async () => ({ features: {} }), + activeOrganization: { id: 'org_jia', name: '甲' }, + }), + // Irrelevant to this branch (`requestedAppMissing` returns above the no-app + // guard), pinned to the least-privileged value so nothing here can be an + // admin-only result. + useIsWorkspaceAdmin: () => false, +})); + +const actionRunnerStub = { registerHandler: vi.fn(), getContext: () => ({}) }; +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + useActionRunner: () => ({ execute: vi.fn(), runner: actionRunnerStub }), + useGlobalUndo: () => {}, + useMutationInvalidationBridge: () => {}, +})); + +// --------------------------------------------------------------------------- +// The transport. One stubbed `fetch` behind a real adapter + real client. +// --------------------------------------------------------------------------- + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +/** + * The two answers `GET /api/v1/meta/app/:name` gives after objectstack#8135, + * transcribed from that merged diff — `sendEnvelopeError(res, 403, + * 'PERMISSION_DENIED', …)` and the pre-existing absence body. + */ +const DENIED_BODY = { + success: false, + error: { + code: 'PERMISSION_DENIED', + message: "You do not have permission to open the 'finance' app.", + }, +}; +const ABSENT_BODY = { + error: { code: 'RESOURCE_NOT_FOUND', message: 'Metadata item not found or access denied.' }, +}; + +/** URLs the by-name meta route was asked for, in order. */ +let metaItemRequests: string[] = []; +/** How the by-name route answers, per app name. */ +let byName: Record Response> = {}; + +function makeAdapter() { + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/api/v1/discovery')) { + return json(200, { success: true, data: { version: 'v0', routes: {}, capabilities: {} } }); + } + const item = /\/api\/v1\/meta\/app\/([^/?]+)/.exec(url); + if (item) { + metaItemRequests.push(url); + const answer = byName[decodeURIComponent(item[1])]; + return answer ? answer() : json(404, ABSENT_BODY); + } + // Nothing else is part of this question; a loud 500 keeps an unexpected + // call from passing as a silent success. + return json(500, { error: { code: 'UNEXPECTED_REQUEST', message: url } }); + }); + return new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchImpl }); +} + +/** The adapter this render sees — a real one by default. */ +let adapter: unknown = null; +vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => adapter, +})); + +/** + * The list as the SERVER hands it to this session: the withheld app is simply + * ABSENT, exactly as `filterAppForUser` leaves it. The ruling keeps it that way + * — nothing in this file may depend on a flag in the list. + */ +let metadataApps: unknown[] = []; +const refreshMetadata = vi.fn(async () => {}); +vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadata: () => ({ + apps: metadataApps, + objects: [], + loading: false, + ensureType: undefined, + error: null, + refresh: refreshMetadata, + }), +})); + +import { AppContent } from '../AppContent'; + +function LocationProbe() { + const location = useLocation(); + return
{location.pathname}
; +} + +/** Drives an in-tree navigation, so the verdict meets a SECOND app name. */ +let navTarget = '/apps/no_such_app'; +function NavProbe() { + const navigate = useNavigate(); + return ( + + ); +} + +function renderConsoleAt(initialUrl: string) { + return render( + + + + + } /> + home
} /> + + , + ); +} + +/** The publishing copy, verbatim from the `en` pack — the must-not-change half. */ +const PUBLISHING_COPY = 'This app is not available yet — it may still be publishing. Try again in a moment.'; + +describe('AppContent — a denied app says so; an absent one keeps the publishing copy (objectui#4252)', () => { + beforeEach(() => { + vi.clearAllMocks(); + locale = 'en'; + metadataApps = [{ name: 'crm', label: 'CRM', navigation: [] }]; + metaItemRequests = []; + byName = {}; + adapter = makeAdapter(); + }); + + it('THE DEFECT — a 403 PERMISSION_DENIED renders access denied, never "may still be publishing"', async () => { + // The card's own repro: a session lacking the app's `requiredPermissions` + // opens the app URL directly. The list withheld it; the by-name route says + // WHY. + byName.finance = () => json(403, DENIED_BODY); + + renderConsoleAt('/apps/finance'); + + expect(await screen.findByTestId('app-access-denied')).toBeInTheDocument(); + expect(screen.getByText("You don't have access to this app")).toBeInTheDocument(); + // Pre-fix, THIS is what the same session was shown. + expect(screen.queryByText(PUBLISHING_COPY)).not.toBeInTheDocument(); + // …and not under a Retry button whose promise is false: the decision is + // permanent, so retrying it forever is the misdirection, one layer down. + expect(screen.queryByTestId('app-not-available-retry')).not.toBeInTheDocument(); + // The screen renders above `ConsoleLayout`, so it owns its own way back + // (objectui#4473's strand, not to be recreated here). + expect(screen.getByTestId('app-access-denied-home')).toBeInTheDocument(); + }); + + it('asks the BY-NAME route for the app it was asked for — the list is never re-read for a flag', async () => { + byName.finance = () => json(403, DENIED_BODY); + + renderConsoleAt('/apps/finance'); + await screen.findByTestId('app-access-denied'); + + expect(metaItemRequests).toHaveLength(1); + expect(metaItemRequests[0]).toContain('/api/v1/meta/app/finance'); + // The readiness re-check still runs FIRST — the probe is what happens after + // a refreshed list still cannot find the app, not instead of it. + expect(refreshMetadata).toHaveBeenCalled(); + }); + + it('MUST NOT CHANGE — a genuinely nonexistent app keeps the publishing copy, byte for byte', async () => { + byName.no_such_app = () => json(404, ABSENT_BODY); + + renderConsoleAt('/apps/no_such_app'); + + expect(await screen.findByTestId('app-not-available-retry')).toBeInTheDocument(); + expect(screen.getByText('App not available')).toBeInTheDocument(); + expect(screen.getByText(PUBLISHING_COPY)).toBeInTheDocument(); + expect(screen.queryByTestId('app-access-denied')).not.toBeInTheDocument(); + }); + + it('MUST NOT CHANGE — a transport failure never claims a denial', async () => { + // Only the measured code flips the copy. An unreachable server is the case + // "try again in a moment" was always honest about. + byName.finance = () => { + throw new Error('network down'); + }; + + renderConsoleAt('/apps/finance'); + + expect(await screen.findByTestId('app-not-available-retry')).toBeInTheDocument(); + expect(screen.getByText(PUBLISHING_COPY)).toBeInTheDocument(); + expect(screen.queryByTestId('app-access-denied')).not.toBeInTheDocument(); + }); + + it('MUST NOT CHANGE — an adapter that cannot answer the probe keeps the publishing copy', async () => { + // A host may inject a DataSource without this probe (AGENTS #1 — the + // console is protocol-agnostic). Degrading to today's screen is the honest + // answer; crashing, or asserting a denial it never measured, is not. + adapter = { onConnectionStateChange: () => () => {}, getConnectionState: () => 'connected' }; + + renderConsoleAt('/apps/finance'); + + expect(await screen.findByTestId('app-not-available-retry')).toBeInTheDocument(); + expect(screen.getByText(PUBLISHING_COPY)).toBeInTheDocument(); + expect(screen.queryByTestId('app-access-denied')).not.toBeInTheDocument(); + }); + + it('MUST NOT CHANGE — an authorized session enters the app, and nothing is probed', async () => { + metadataApps = [{ name: 'finance', label: 'Finance', navigation: [] }]; + + renderConsoleAt('/apps/finance'); + + const layout = await screen.findByTestId('console-layout'); + expect(layout).toHaveAttribute('data-active-app', 'finance'); + expect(screen.queryByTestId('app-access-denied')).not.toBeInTheDocument(); + // The probe is a consequence of the app being missing; an app that resolved + // must not cost a request. + expect(metaItemRequests).toEqual([]); + }); + + it('renders the denial in the active language — zh, from the shipped pack', async () => { + locale = 'zh'; + byName.finance = () => json(403, DENIED_BODY); + + renderConsoleAt('/apps/finance'); + + expect(await screen.findByTestId('app-access-denied')).toBeInTheDocument(); + expect(screen.getByText('你没有访问此应用的权限')).toBeInTheDocument(); + expect(screen.queryByText(PUBLISHING_COPY)).not.toBeInTheDocument(); + }); + + it('renders the absence in the active language too — zh keeps its publishing copy', async () => { + locale = 'zh'; + byName.no_such_app = () => json(404, ABSENT_BODY); + + renderConsoleAt('/apps/no_such_app'); + + expect(await screen.findByTestId('app-not-available-retry')).toBeInTheDocument(); + expect(screen.getByText('此应用尚不可用 —— 可能仍在发布中。请稍后重试。')).toBeInTheDocument(); + }); + + it('the verdict belongs to the app it was asked about — a second missing app is judged afresh', async () => { + // Both apps are missing from the list, so `requestedAppMissing` never goes + // false between them and no state in this branch is reset by the transition. + // A verdict held loose from the name it describes would therefore ride into + // the next URL and answer for an app it never probed — telling a user their + // typo is a permission problem. + byName.finance = () => json(403, DENIED_BODY); + byName.no_such_app = () => json(404, ABSENT_BODY); + + renderConsoleAt('/apps/finance'); + await screen.findByTestId('app-access-denied'); + + navTarget = '/apps/no_such_app'; + screen.getByTestId('go-elsewhere').click(); + + expect(await screen.findByTestId('app-not-available-retry')).toBeInTheDocument(); + expect(screen.getByText(PUBLISHING_COPY)).toBeInTheDocument(); + expect(screen.queryByTestId('app-access-denied')).not.toBeInTheDocument(); + // …and each app was asked about itself, once. + expect(metaItemRequests).toHaveLength(2); + expect(metaItemRequests[1]).toContain('/api/v1/meta/app/no_such_app'); + }); + + it('the denial screen offers a way back to /home', async () => { + byName.finance = () => json(403, DENIED_BODY); + + renderConsoleAt('/apps/finance'); + const home = await screen.findByTestId('app-access-denied-home'); + home.click(); + + await waitFor(() => expect(screen.getByTestId('pathname').textContent).toBe('/home')); + }); +}); diff --git a/packages/data-objectstack/src/appAccessProbe.test.ts b/packages/data-objectstack/src/appAccessProbe.test.ts new file mode 100644 index 000000000..4fd919d0b --- /dev/null +++ b/packages/data-objectstack/src/appAccessProbe.test.ts @@ -0,0 +1,147 @@ +/** + * 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. + */ + +/** + * objectui#4252 — an app the session may not open must be distinguishable from + * an app that is not there. + * + * `GET /api/v1/meta/apps` is filtered per session server-side + * (`filterAppForUser`), so those two conditions are byte-identical in the list: + * both are simply absent. The maintainer ruling (2026-08-12) put the + * distinction on the BY-NAME route instead of flagging the list — the + * enumeration surface is not widened past what a by-name probe already implies + * — and objectstack#8013 (PR #8135) shipped it: + * + * - EXISTS + session lacks `requiredPermissions` → `403` + * `{ success: false, error: { code: 'PERMISSION_DENIED', message } }` + * - a nonexistent name, an unpublished app (ADR-0045 §3 keeps it externally + * unobservable), an app gated by an absent optional service (ADR-0057 D10 — + * nothing was denied to the CALLER) → `404 RESOURCE_NOT_FOUND`, unchanged. + * + * Every case here goes through the real `ObjectStackClient` over a stubbed + * `fetch`, so what is measured includes the client's own error stamping + * (`error.code` off `body.error.code`, `error.httpStatus` off the status) — + * the seam a hand-built rejection would have skipped straight past. + * + * The discrimination is on the ADR-0112 CODE, never the status (objectui#4408), + * and the last two cases are what hold that: they cross the two apart, so a + * reimplementation that reads `httpStatus === 403` goes red in both directions + * rather than passing on the happy path. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + ObjectStackAdapter, + isAppPermissionDeniedError, + APP_PERMISSION_DENIED_CODE, +} from './index'; + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +/** The 403 body objectstack#8135 emits, transcribed from that merged diff. */ +const DENIED_BODY = { + success: false, + error: { + code: 'PERMISSION_DENIED', + message: "You do not have permission to open the 'finance' app.", + }, +}; + +/** The absence body the same route keeps answering for every other refusal. */ +const ABSENT_BODY = { + error: { code: 'RESOURCE_NOT_FOUND', message: 'Metadata item not found or access denied.' }, +}; + +function makeAdapter(answer: (url: string) => Response) { + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => answer(String(input))); + const adapter = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchImpl }); + return { adapter, fetchImpl }; +} + +describe('isAppPermissionDeniedError', () => { + it('matches the by-name route\'s denial code, in either ADR-0112 spelling', () => { + expect(APP_PERMISSION_DENIED_CODE).toBe('PERMISSION_DENIED'); + expect(isAppPermissionDeniedError({ code: 'PERMISSION_DENIED' })).toBe(true); + // The console is versioned separately from the server it talks to; the + // pre-ADR-0112 lowercase spelling still resolves (`errorCodeIs`). + expect(isAppPermissionDeniedError({ code: 'permission_denied' })).toBe(true); + }); + + it('does NOT match absence, the enable-block denials, or a bare status', () => { + expect(isAppPermissionDeniedError({ code: 'RESOURCE_NOT_FOUND' })).toBe(false); + // `API_ACCESS_DENIED_CODES` (objectui#4408) is a different question: those + // are pure functions of an object's `enable` metadata and identical for + // every persona. This one is a statement about the caller. + expect(isAppPermissionDeniedError({ code: 'OBJECT_API_DISABLED' })).toBe(false); + expect(isAppPermissionDeniedError({ httpStatus: 403 })).toBe(false); + expect(isAppPermissionDeniedError(undefined)).toBe(false); + }); +}); + +describe('ObjectStackAdapter.probeAppAccess — over the wire', () => { + it('reports `denied` for the 403 PERMISSION_DENIED envelope', async () => { + const { adapter, fetchImpl } = makeAdapter(() => json(403, DENIED_BODY)); + + await expect(adapter.probeAppAccess('finance')).resolves.toBe('denied'); + // The by-name address, singular — what objectstack#8013 pinned its cases + // against and what `MetadataProvider` already reads items by. + expect(String(fetchImpl.mock.calls[0][0])).toContain('/api/v1/meta/app/finance'); + }); + + it('reports `unknown` for the 404 absence envelope — the copy must not move', async () => { + const { adapter } = makeAdapter(() => json(404, ABSENT_BODY)); + await expect(adapter.probeAppAccess('no_such_app')).resolves.toBe('unknown'); + }); + + it('reports `granted` when the route serves the app', async () => { + const { adapter } = makeAdapter(() => + json(200, { type: 'app', name: 'finance', item: { name: 'finance', label: 'Finance' } }), + ); + await expect(adapter.probeAppAccess('finance')).resolves.toBe('granted'); + }); + + it('reports `unknown` — never `denied` — when the server cannot be reached', async () => { + const { adapter } = makeAdapter(() => { + throw new Error('network down'); + }); + await expect(adapter.probeAppAccess('finance')).resolves.toBe('unknown'); + }); + + it('reports `unknown` for an empty name without asking anything', async () => { + const { adapter, fetchImpl } = makeAdapter(() => json(200, {})); + await expect(adapter.probeAppAccess('')).resolves.toBe('unknown'); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('never throws — a caller renders a screen off this, not a catch block', async () => { + const { adapter } = makeAdapter(() => new Response('gateway', { status: 502 })); + await expect(adapter.probeAppAccess('finance')).resolves.toBe('unknown'); + }); + + // ── code, not status ───────────────────────────────────────────────────── + // + // Both directions, because a status-reading implementation passes every case + // above. 403 is not the fact; `PERMISSION_DENIED` is. + + it('a 403 WITHOUT the code is `unknown` — a status alone never denies', async () => { + const { adapter } = makeAdapter(() => + json(403, { error: { code: 'CSRF_TOKEN_INVALID', message: 'stale token' } }), + ); + await expect(adapter.probeAppAccess('finance')).resolves.toBe('unknown'); + }); + + it('the code decides even when the status is not 403', async () => { + // Hypothetical on today's server, and deliberately so: the contract this + // console consumes is the code. If the route ever answers the same denial + // under a different status, the branch must follow the code — and if this + // case is ever deleted, the reason must be that the CODE changed. + const { adapter } = makeAdapter(() => json(401, DENIED_BODY)); + await expect(adapter.probeAppAccess('finance')).resolves.toBe('denied'); + }); +}); diff --git a/packages/data-objectstack/src/index.ts b/packages/data-objectstack/src/index.ts index 862008e58..9ded1e4fc 100644 --- a/packages/data-objectstack/src/index.ts +++ b/packages/data-objectstack/src/index.ts @@ -36,7 +36,7 @@ import type { ImportJobUndoResult, ListImportJobsOptions, } from '@object-ui/types'; -import { errorCodeIsAnyOf } from '@object-ui/types'; +import { errorCodeIs, errorCodeIsAnyOf } from '@object-ui/types'; import { convertFiltersToAST, emulateBatchTransaction, @@ -492,6 +492,52 @@ export function isApiAccessDeniedError(error: unknown): boolean { return errorCodeIsAnyOf(error, API_ACCESS_DENIED_CODES); } +/** + * What the by-name meta app route said about THIS session's access to an app + * (objectui#4252 / objectstack#8013). + * + * - `granted` — the route served the app document. + * - `denied` — the app EXISTS and the session lacks its `requiredPermissions`. + * The only verdict a caller may render as an authorization refusal. + * - `unknown` — anything else: an absent app, an unpublished one, an app + * withheld by an absent optional service, an unreachable server, an adapter + * that cannot ask. All of these are cases where the server declined to say + * that a permission of the caller's is missing, so no caller may claim it. + * + * Three values rather than a boolean because the third is not a shade of the + * other two: "the app is missing" and "I could not find out" both have to leave + * the caller's existing copy alone, and collapsing them into `false` invites a + * consumer to read a failed probe as a positive absence. + */ +export type AppAccessVerdict = 'granted' | 'denied' | 'unknown'; + +/** + * The ADR-0112 standard catalog code the by-name meta app route answers with + * when an app exists and the session lacks its `requiredPermissions` + * (objectstack#8013, `sendError(res, 403, 'PERMISSION_DENIED', …)` in + * `packages/rest/src/rest-server.ts`). + * + * Deliberately NOT a member of {@link API_ACCESS_DENIED_CODES}: those two are + * pure functions of an object's `enable` metadata — permanent, identical for + * every persona — whereas this one is a statement about the CALLER, and the same + * request by a different session succeeds. Same word "denied", different + * question, so a consumer that wants one must never match the other. + */ +export const APP_PERMISSION_DENIED_CODE = 'PERMISSION_DENIED'; + +/** + * True when `error` is the by-name app route's permission denial. + * + * Discriminates on the ADR-0112 `code`, never on the status (objectui#4408): the + * route answers 403 for this and 404 for absence today, but a status is a + * transport fact many conditions share, while the code is the contract. The + * console's whole reason to call this is to tell two REFUSALS apart, and both + * are errors. + */ +export function isAppPermissionDeniedError(error: unknown): boolean { + return errorCodeIs(error, APP_PERMISSION_DENIED_CODE); +} + /** * Thrown when the deployment has no analytics capability installed * (framework#3891 / #4019). @@ -3451,6 +3497,58 @@ export class ObjectStackAdapter implements DataSource { } } + /** + * Ask the by-name meta app route WHY an app is not in this session's app list + * (objectui#4252). + * + * `GET /api/v1/meta/apps` is filtered per session server-side + * (`filterAppForUser`), so an app withheld by `requiredPermissions` and an app + * that does not exist are byte-identical there: both are simply absent. A + * console reading only that list has one fact and two conditions, and it + * renders its copy for the wrong one — "it may still be publishing" over a + * permanent authorization decision, which cost a downstream acceptance round + * two test batches spent chasing a platform defect that was a missing + * permission-set binding. + * + * The maintainer ruling (2026-08-12) put the answer on the BY-NAME route + * rather than in the list, so the enumeration surface is not widened past what + * a by-name probe already implies (objectstack#8013 / PR #8135): an app that + * exists and whose `requiredPermissions` the session lacks answers `403` with + * `PERMISSION_DENIED` in the declared envelope, and absence — a nonexistent + * name, an unpublished app, an app gated by an absent optional service — + * keeps answering `404 RESOURCE_NOT_FOUND`. + * + * ## Why this is a separate method and not a flavour of {@link getApp} + * + * - `getApp` degrades EVERY failure to `null`, which is exactly the + * conflation this exists to undo; changing it would silently re-point its + * own callers' fallback-to-static-config path. + * - `getApp` memoises in `metadataCache`. A verdict about the CALLER must not + * be cached beside a document about the APP — one grant, and a cached + * denial outlives the session it described. + * + * Nothing here throws: a probe that cannot reach an answer returns `unknown` + * and the caller keeps whatever it was already showing. Only the measured + * `code` produces `denied` — never a status, never a message (objectui#4408). + * + * @param appName - the app name as it appears in the URL segment + */ + async probeAppAccess(appName: string): Promise { + if (!appName) return 'unknown'; + try { + // Singular `app`, the address objectstack#8013 pinned its cases against, + // and the same one `MetadataProvider` reads items by. No `connect()` + // first: this route is only ever asked after the app LIST has already + // loaded through this same client, and the client's route resolution + // falls back to the conventional `/api/v1/meta` regardless — so a + // discovery round trip here could only add a failure mode. + await this.client.meta.getItem('app', appName); + return 'granted'; + } catch (err) { + return isAppPermissionDeniedError(err) ? 'denied' : 'unknown'; + } + } + /** * Get a page definition from ObjectStack. * Uses the metadata API to fetch page layouts. diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 0cbb5a871..538b90254 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -2416,6 +2416,9 @@ const ar = { noAppsConfiguredDescription: "لا تطبيقات مسجلة. أنشئ تطبيقك الأول أو زر إعدادات النظام.", appNotAvailable: "التطبيق غير متاح", appNotAvailableDescription: "هذا التطبيق غير متاح بعد — قد يكون النشر ما زال جارياً. أعد المحاولة بعد لحظات.", + appAccessDenied: "ليس لديك حق الوصول إلى هذا التطبيق", + appAccessDeniedDescription: "هذا التطبيق موجود، لكن حسابك غير مخوَّل بفتحه. اطلب من المسؤول منحك حق الوصول.", + appAccessDeniedHome: "العودة إلى الرئيسية", createFirstApp: "إنشاء أول تطبيق", systemSettings: "إعدادات النظام", back: "رجوع", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 76b8d600a..e352fcba9 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -2409,6 +2409,9 @@ const de = { noAppsConfiguredDescription: "Es sind keine Anwendungen registriert. Erstellen Sie Ihre erste App oder besuchen Sie die Systemeinstellungen.", appNotAvailable: "App nicht verfügbar", appNotAvailableDescription: "Diese App ist noch nicht verfügbar — sie wird möglicherweise noch veröffentlicht. Versuchen Sie es in einem Moment erneut.", + appAccessDenied: "Sie haben keinen Zugriff auf diese App", + appAccessDeniedDescription: "Diese App existiert, aber Ihr Konto ist nicht berechtigt, sie zu öffnen. Bitten Sie einen Administrator um Zugriff.", + appAccessDeniedHome: "Zurück zur Startseite", createFirstApp: "Erste App erstellen", systemSettings: "Systemeinstellungen", back: "Zurück", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 1f00e4fff..13a8b83a1 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -2588,6 +2588,9 @@ const en = { noAppsConfiguredDescription: 'No applications have been registered. Create your first app or visit System Settings to configure your environment.', appNotAvailable: 'App not available', appNotAvailableDescription: 'This app is not available yet — it may still be publishing. Try again in a moment.', + appAccessDenied: "You don't have access to this app", + appAccessDeniedDescription: 'This app exists, but your account is not authorized to open it. Ask an administrator to grant you access.', + appAccessDeniedHome: 'Back to home', createFirstApp: 'Create Your First App', systemSettings: 'System Settings', back: 'Back', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 081faa274..684234783 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -2413,6 +2413,9 @@ const es = { noAppsConfiguredDescription: "No hay aplicaciones registradas. Cree su primera aplicación o visite la configuración del sistema.", appNotAvailable: "Aplicación no disponible", appNotAvailableDescription: "Esta aplicación aún no está disponible — puede que todavía se esté publicando. Vuelva a intentarlo en un momento.", + appAccessDenied: "No tiene acceso a esta aplicación", + appAccessDeniedDescription: "Esta aplicación existe, pero su cuenta no está autorizada para abrirla. Solicite acceso a un administrador.", + appAccessDeniedHome: "Volver al inicio", createFirstApp: "Crear primera aplicación", systemSettings: "Configuración del sistema", back: "Atrás", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index f9c07bca5..ec23eb7f5 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -2411,6 +2411,9 @@ const fr = { noAppsConfiguredDescription: "Aucune application n'est enregistrée. Créez votre première application ou visitez les paramètres système.", appNotAvailable: "Application non disponible", appNotAvailableDescription: "Cette application n'est pas encore disponible — sa publication est peut-être en cours. Réessayez dans un instant.", + appAccessDenied: "Vous n'avez pas accès à cette application", + appAccessDeniedDescription: "Cette application existe, mais votre compte n'est pas autorisé à l'ouvrir. Demandez l'accès à un administrateur.", + appAccessDeniedHome: "Retour à l'accueil", createFirstApp: "Créer la première application", systemSettings: "Paramètres système", back: "Retour", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 3d43ec365..52f280d7e 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -2409,6 +2409,9 @@ const ja = { noAppsConfiguredDescription: "登録されているアプリケーションがありません。最初のアプリを作成するか、システム設定を参照してください。", appNotAvailable: "アプリを利用できません", appNotAvailableDescription: "このアプリはまだ利用できません — まだ公開処理中の可能性があります。しばらくしてからもう一度お試しください。", + appAccessDenied: "このアプリへのアクセス権限がありません", + appAccessDeniedDescription: "このアプリは存在しますが、お使いのアカウントには開く権限がありません。管理者にアクセス権の付与を依頼してください。", + appAccessDeniedHome: "ホームに戻る", createFirstApp: "最初のアプリを作成", systemSettings: "システム設定", back: "戻る", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index e53c37c6a..fd68981f3 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -2408,6 +2408,9 @@ const ko = { noAppsConfiguredDescription: "등록된 앱이 없습니다. 첫 번째 앱을 만들거나 시스템 설정을 방문하세요.", appNotAvailable: "앱을 사용할 수 없습니다", appNotAvailableDescription: "이 앱을 아직 사용할 수 없습니다 — 아직 게시 중일 수 있습니다. 잠시 후 다시 시도하세요.", + appAccessDenied: "이 앱에 접근할 권한이 없습니다", + appAccessDeniedDescription: "이 앱은 존재하지만 계정에 여는 권한이 없습니다. 관리자에게 접근 권한을 요청하세요.", + appAccessDeniedHome: "홈으로 돌아가기", createFirstApp: "첫 번째 앱 만들기", systemSettings: "시스템 설정", back: "뒤로", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index b6e7cf585..54884d96f 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -2408,6 +2408,9 @@ const pt = { noAppsConfiguredDescription: "Nenhum aplicativo está registrado. Crie seu primeiro aplicativo ou visite as configurações do sistema.", appNotAvailable: "Aplicativo indisponível", appNotAvailableDescription: "Este aplicativo ainda não está disponível — a publicação pode estar em andamento. Tente novamente em instantes.", + appAccessDenied: "Você não tem acesso a este aplicativo", + appAccessDeniedDescription: "Este aplicativo existe, mas sua conta não tem autorização para abri-lo. Peça acesso a um administrador.", + appAccessDeniedHome: "Voltar ao início", createFirstApp: "Criar primeiro aplicativo", systemSettings: "Configurações do sistema", back: "Voltar", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 1c98df51f..c8fa5fb2d 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -2420,6 +2420,9 @@ const ru = { noAppsConfiguredDescription: "Нет зарегистрированных приложений. Создайте первое приложение или посетите системные настройки.", appNotAvailable: "Приложение недоступно", appNotAvailableDescription: "Это приложение пока недоступно — возможно, публикация ещё идёт. Повторите попытку через мгновение.", + appAccessDenied: "У вас нет доступа к этому приложению", + appAccessDeniedDescription: "Это приложение существует, но у вашей учётной записи нет прав на его открытие. Обратитесь к администратору за доступом.", + appAccessDeniedHome: "На главную", createFirstApp: "Создать приложение", systemSettings: "Системные настройки", back: "Назад", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index b24211573..1ba9dda48 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -2485,6 +2485,9 @@ const zh = { noAppsConfiguredDescription: '当前没有任何已注册的应用。请创建您的第一个应用,或前往系统设置进行配置。', appNotAvailable: '应用不可用', appNotAvailableDescription: '此应用尚不可用 —— 可能仍在发布中。请稍后重试。', + appAccessDenied: '你没有访问此应用的权限', + appAccessDeniedDescription: '此应用存在,但你的账号未获授权打开它。请联系管理员为你开通访问权限。', + appAccessDeniedHome: '返回首页', createFirstApp: '创建您的第一个应用', systemSettings: '系统设置', back: '返回',