Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/app-denied-vs-unpublished-4252.md
Original file line number Diff line number Diff line change
@@ -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.
91 changes: 89 additions & 2 deletions packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <LoadingScreen />;
if (missingRecheck !== 'done' || accessVerdict === null) return <LoadingScreen />;
// 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>`/`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 (
<div className="h-screen flex items-center justify-center">
<Empty data-testid="app-access-denied">
<EmptyTitle>
{t('empty.appAccessDenied', { defaultValue: "You don't have access to this app" })}
</EmptyTitle>
<EmptyDescription>
{t('empty.appAccessDeniedDescription', {
defaultValue:
'This app exists, but your account is not authorized to open it. Ask an administrator to grant you access.',
})}
</EmptyDescription>
<div className="mt-4">
<Button onClick={() => navigate('/home')} data-testid="app-access-denied-home">
{t('empty.appAccessDeniedHome', { defaultValue: 'Back to home' })}
</Button>
</div>
</Empty>
</div>
);
}
return (
<div className="h-screen flex items-center justify-center">
<Empty>
Expand Down
Loading
Loading