fix(console): an unauthorized app says so — access denial no longer masquerades as publishing (#4252) - #4521
Merged
Conversation
…asquerades as publishing (#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 were byte-identical to the console: both absent from the list. `AppContent` rendered its only copy for an absent app — "it may still be publishing" — over a permanent authorization decision, under a Retry button that could never succeed. Measured cost: a downstream acceptance round filed it as a platform defect and carried it through two test batches before finding the account was missing a permission-set binding. Per the maintainer ruling of 2026-08-12, objectstack#8013 (PR #8135) put the distinction on the BY-NAME route — 403 `PERMISSION_DENIED` in the declared envelope for exists-but-unauthorized — while the list route stays filtered with no `authorized: false` flag. This is the console half: when the readiness re-check still cannot find a requested app, the console asks that route through a new `ObjectStackAdapter.probeAppAccess(name)` and, on the measured ADR-0112 code alone, renders a plain authorization message with a way back to /home. Every other answer — absence, an unreachable server, an adapter that cannot ask — keeps today's publishing copy byte for byte. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #4252
An app the session may not open rendered the console's copy for an app that is not there yet — "This app is not available yet — it may still be publishing. Try again in a moment." — under a Retry button that could never succeed.
GET /api/v1/meta/appsis filtered per session server-side (filterAppForUser), so a withheld app and a nonexistent one are byte-identical in the list: both simply absent. One fact, two conditions, and the console picked the wrong one.The cost is measured, not cosmetic: 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.
Ruling provenance
The disclosure stance was decided, not chosen here. Maintainer, 2026-08-12, on the consolidated decision digest (objectui#4252 comment 5266079914):
Binding split, quoted from that comment:
authorized: falseleakage into the list).objectstack#8013 closed via PR #8135 (merged 2026-08-12), which is what unblocked this.
The envelope, as measured
Read off PR #8135's merged diff (
packages/rest/src/rest-server.ts+meta-app-publish-gate.test.ts), not inferred:coderequiredPermissionsPERMISSION_DENIED{ success: false, error: { code, message } }RESOURCE_NOT_FOUNDRESOURCE_NOT_FOUNDRESOURCE_NOT_FOUNDExactly one of the four refusals converts; the partition is the security boundary of #8013 and this side depends on it rather than re-deriving it. The list route is untouched, so nothing here reads a flag out of
/meta/apps.The console branches on the code, never the status (the #4408 lesson): the two answers under test are both errors one status apart, so a status-reading implementation passes the happy path and goes blind exactly where the defect lives.
@objectstack/clientstampserror.codefrombody.code ?? body.error.code, which is what makesbody.error.codereach the branch intact.Read path — the seam, and why this one
MetadataProvider.getItemdegrades every failure tonull, andObjectStackAdapter.getAppdoes the same and memoises the result — both are the conflation being undone, so neither could carry the verdict. The probe is therefore a new, narrow adapter method (@object-ui/data-objectstackowns fetch logic per AGENTS §3):(the space inside the generic is for GitHub's body sanitizer, which strips
<followed by a letter; the source has none.)[]short-circuits initialLoading) #4486, provider cache keying, is a separate card and is untouched here.)meta/app/:name— the address #8013 pinned its cases against and the oneMetadataProvideralready reads items by.In
AppContent, the probe runs after the existing post-publish readiness re-check has settled and the app is still missing — it is what happens after a refreshed list still cannot find it, not instead of it. Onlydeniedchanges what renders. An absent app, an unreachable server, or a host that injected a DataSource without the probe (AGENTS #1 — the console is protocol-agnostic) all keep today's screen byte for byte. This bug exists because the console asserted a state it had not measured; guessing in the other direction would be the same defect mirrored.The verdict is stored with the app name it describes and read back only for that name. Two missing apps in a row keep
requestedAppMissingtrue across the whole transition, so nothing in the branch is reset by it — a verdict held loose from its name would ride into the next URL and tell a user their typo is a permission problem. Found while reviewing this PR's own code, pinned red-first, then fixed.Rendering
Denied renders a plain authorization message and, deliberately, no Retry: retrying a permission decision cannot change it, and a button promising otherwise is the same misdirection one layer down. It offers
/homeinstead — this screen returns above the singleConsoleLayoutmount and so carries no header, navigation or workspace switcher, and a dead end here would recreate the #4473 strand that PR #4483 just fixed. Genuine absence keeps its copy, its Retry button and itsdata-testidunchanged.Three new
empty.*keys in all ten packs, with inline defaults matchingenbyte for byte (check:i18n-keysverifies exactly that).Red-first — the card's own repro
Pre-fix run, 4 of 9 cases red:
The DOM those cases failed against is the reported screen verbatim — rendered by
data-slot, so it is quoted here as slot → text rather than as markup (the sanitizer strips tags from a stored body):Post-fix the same session gets
data-testid="app-access-denied"and "You don't have access to this app". The by-name route is stubbed at the transport — a realObjectStackAdapterover a stubbedfetch, so the real client and its real error stamping are in the path; a verdict injected into component state would have asserted the branch against a fixture of its own conclusion.The staleness case above was red-first too, on its own lap (1 failed | 9 passed), before the verdict was keyed to its app.
Reverse verification
isAppPermissionDeniedErrorwas temporarily rewritten to readhttpStatus === 403— the shape #4408 forbids. Predicted direction: the happy-path cases stay green (which is the blindness being pinned) and only the code-vs-status cases fall. Measured: 4 failed | 6 passed, the four being both crossed cases (a 403 without the code; the code under a non-403 status) plus the two predicate cases. Restored, green again.Must not change — green on both sides
AppContent.inaccessibleAppStrand.test.tsx) and the console: isSystemRoute / isMetadataRoute 的子串判断把 /apps/未知app/system_log 认成伪路由,静默回退渲染另一个 app #3638 pseudo-route pins are untouched and green.Verification
Both tsc commands ran for
app-shellandi18n;data-objectstackdeclares onlytsc --noEmit. The consumer sweep is the downstream direction (prefix...), after a full workspace build — a first pass reportedCannot find modulefor unbuilt siblings in the fresh worktree, which cleared oncedistexisted and was never a type error from this change..d.ts: purely additive —probeAppAccess,isAppPermissionDeniedError,APP_PERMISSION_DENIED_CODE,AppAccessVerdictappear in the builtpackages/data-objectstack/dist/index.d.ts; the only non-addition line in that package's diff is the widenedimport { errorCodeIs, errorCodeIsAnyOf }. Hence@object-ui/data-objectstack: minor(additive public API),@object-ui/app-shell: patch,@object-ui/i18n: patch. Never major (AGENTS §9 version alignment).Generated by Claude Code