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: '返回',