Skip to content

fix(console): an unauthorized app says so — access denial no longer masquerades as publishing (#4252) - #4521

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4252-denied-vs-unpublished
Aug 13, 2026
Merged

fix(console): an unauthorized app says so — access denial no longer masquerades as publishing (#4252)#4521
yinlianghui merged 1 commit into
mainfrom
claude/issue-4252-denied-vs-unpublished

Conversation

@yinlianghui

@yinlianghui yinlianghui commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

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/apps is 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:

  • Backend half (objectstack#8013): the by-name meta app route answers an explicit permission-denied envelope for an existing-but-unauthorized app; the list route stays filtered as-is (no authorized: false leakage into the list).
  • This card (console half): on the denied envelope render a plain "you don't have access to this app" message; keep the current "not published yet" copy only for genuine absence.

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:

condition status code body shape
exists, session lacks requiredPermissions 403 PERMISSION_DENIED { success: false, error: { code, message } }
nonexistent name 404 RESOURCE_NOT_FOUND unchanged
unpublished app (ADR-0045 §3) 404 RESOURCE_NOT_FOUND unchanged
absent optional service (ADR-0057 D10) 404 RESOURCE_NOT_FOUND unchanged

Exactly 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/client stamps error.code from body.code ?? body.error.code, which is what makes body.error.code reach the branch intact.

Read path — the seam, and why this one

MetadataProvider.getItem degrades every failure to null, and ObjectStackAdapter.getApp does 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-objectstack owns fetch logic per AGENTS §3):

probeAppAccess(appName: string): Promise< AppAccessVerdict >   // 'granted' | 'denied' | 'unknown'

(the space inside the generic is for GitHub's body sanitizer, which strips < followed by a letter; the source has none.)

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. Only denied changes 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 requestedAppMissing true 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 /home instead — this screen returns above the single ConsoleLayout mount 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 its data-testid unchanged.

Three new empty.* keys in all ten packs, with inline defaults matching en byte for byte (check:i18n-keys verifies exactly that).

Red-first — the card's own repro

Pre-fix run, 4 of 9 cases red:

× THE DEFECT — a 403 PERMISSION_DENIED renders access denied, never "may still be publishing"
× asks the BY-NAME route for the app it was asked for — the list is never re-read for a flag
× renders the denial in the active language — zh, from the shipped pack
× the denial screen offers a way back to /home
  Tests  4 failed | 5 passed (9)

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):

empty-title                                  →  App not available
empty-description                            →  This app is not available yet — it may still be publishing. Try again in a moment.
button[data-testid=app-not-available-retry]  →  Retry

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 real ObjectStackAdapter over a stubbed fetch, 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

isAppPermissionDeniedError was temporarily rewritten to read httpStatus === 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

Verification

pnpm exec vitest run packages/app-shell/ packages/data-objectstack/
  Test Files  399 passed (399)      Tests  3988 passed | 1 skipped (3989)
pnpm exec vitest run packages/app-shell/src/console/ packages/data-objectstack/ packages/i18n/
  Test Files  124 passed (124)      Tests  1604 passed (1604)
pnpm --filter @object-ui/app-shell --filter @object-ui/data-objectstack --filter @object-ui/i18n run type-check   → all Done
pnpm --filter '...@object-ui/data-objectstack' run type-check                    → 32 packages Done (downstream consumers; prefix filter)
node scripts/check-control-bytes.mjs        → OK (4231 files)
node scripts/check-i18n-call-site-keys.mjs  → every key resolves, every inline default matches its en value
node scripts/check-i18n-en-drift.mjs        → 0 en values changed (3 keys added — parity's business, not drift's)
node scripts/check-changeset-presence.mjs   → 12 source files of 3 released packages, 1 changeset

Both tsc commands ran for app-shell and i18n; data-objectstack declares only tsc --noEmit. The consumer sweep is the downstream direction (prefix ...), after a full workspace build — a first pass reported Cannot find module for unbuilt siblings in the fresh worktree, which cleared once dist existed and was never a type error from this change.

.d.ts: purely additive — probeAppAccess, isAppPermissionDeniedError, APP_PERMISSION_DENIED_CODE, AppAccessVerdict appear in the built packages/data-objectstack/dist/index.d.ts; the only non-addition line in that package's diff is the widened import { 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

…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
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 13, 2026 3:51am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-DKGsbZgA.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 25.13KB 5.40KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 38.46KB 10.17KB
auth (createAuthenticatedFetch.js) 6.34KB 2.43KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.02KB 0.88KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 489.32KB 108.45KB
core (index.js) 3.37KB 1.34KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 156.23KB 42.29KB
fields (index.js) 230.14KB 57.12KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.84KB 1.45KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.98KB 10.85KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.86KB 12.91KB
plugin-charts (index.js) 62.07KB 17.65KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 120.95KB 31.53KB
plugin-designer (index.js) 212.58KB 42.83KB
plugin-detail (index.js) 239.88KB 59.99KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.14KB 39.98KB
plugin-grid (index.js) 188.40KB 50.10KB
plugin-kanban (index.js) 48.62KB 13.42KB
plugin-list (index.js) 111.13KB 27.12KB
plugin-map (index.js) 18.16KB 5.81KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 41.16KB 10.96KB
plugin-timeline (index.js) 26.21KB 7.52KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.08KB 20.55KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 23.73KB 7.96KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants