Skip to content

fix(app-shell): decide the marketplace admin refusal before the package load - #5621

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-5583-marketplace-admin-guard-order
Aug 21, 2026
Merged

fix(app-shell): decide the marketplace admin refusal before the package load#5621
os-sales merged 2 commits into
mainfrom
claude/issue-5583-marketplace-admin-guard-order

Conversation

@os-sales

@os-salesos-sales commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Fixes#5583

What changed

packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx decided
!isAdminafter both the loading branch and the error || !data branch, and gated
its two fetch effects on features.marketplace alone. On a runtime that mounts a
marketplace, a non-admin who opened a package URL was walked through the fetch and the
skeleton, and — when the load failed — was handed the destructive "Failed to load
package" card carrying the server's own error message. Whether that viewer was refused
or handed a diagnosis about a surface they may not use came down to whether an
unrelated request happened to succeed.

The guard now sits ahead of both branches, and getMarketplacePackage /
getCloudInstallationInfo are gated on isAdmin too, so the page stops requesting on
behalf of a viewer it has already decided to turn away — the discipline #5533
established on this page for features.marketplace, applied to the other predicate
that decides the same thing. It is also the ordering MarketplacePage carries after
#5557, so the two sibling pages now answer one runtime the same way for every viewer.
The server remains the authority on what a non-admin may fetch.

Line numbers: re-verified on the merged ref, not assumed

The card cited :593 / :521 / :531 against f2158ec86 and the triage note warned
they would have drifted once #5582 landed. Measured on origin/main at 8c87f0583
(with #5582 merged), they had not moved — the branches were found by their code and
sat at exactly the cited lines:

branchcitedmeasured on 8c87f0583after this PR
if (!marketplaceEnabled) return <MarketplaceDisabled />519519538
if (loading) {521521558
if (error || !data) {531531568
if (!isAdmin) return <MarketplaceAccessDenied />593593556

Reorder, or behaviour change? Both — stated precisely

Pure reorder with respect to this page's own load state.isAdmin comes from
useIsWorkspaceAdmin(), which derives only from useAuth()'s activeMember / user.
It reads none of data, loading or error, so the guard was never waiting on the
fetch for its input — it was only sequenced behind it.

A real behaviour change in what a viewer sees, which is the point of the card: a
non-admin gets the refusal on the first commit instead of the skeleton, and instead of
the error card on a failed load. On a load that succeeds, the outcome was already
MarketplaceAccessDenied — this only stops the detour. Two requests are no longer
issued for that viewer.

One coupling it removes, worth naming.AuthProvider.refreshActiveMember resolves
activeMember asynchronously after the session settles, so an admin whose role comes
from the org member row renders once as a non-admin. Today the package fetch
incidentally masks that window with a skeleton — for as long as an unrelated request
happens to take. It is not an auth gate, and MarketplacePage has had no such mask
since #5557. Consequence handled here rather than inherited: loading stays seeded
from marketplaceEnabledalone, deliberately not marketplaceEnabled && isAdmin.
Seeding false would leave that first admin render with loading: false and no data —
i.e. the destructive card, painted for a frame before the effect could raise the flag
again. The residual (a real admin briefly seeing a refusal, on this and every other
useIsWorkspaceAdmin() surface) is filed separately as #5619 — it is a property of the
hook's bare-boolean shape, not of this ordering.

Tests

New: packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.guardOrder.test.tsx.
The non-admin cases assert synchronously after render() — no findBy, no
waitFor — because under the retired ordering loading was seeded true on a
marketplace-ON runtime and the first commit was the skeleton. Reaching the refusal with
nothing awaited is the whole claim.

Controls, scoped to exactly what the probes vary (same config, same package id, same
rejection; only isAdmin differs):

Fixture triage on MarketplacePage.guardOrder.test.tsx (#5557's suite): its
settledAnswerOf helper and its stubbed resolving package existed only because the
detail page could not refuse anyone until a package loaded. Its header and its
detail-page leg asserted that ordering as live fact. That leg now reads both pages'
answers with answerOf against the ambient 404 — strictly stronger, and no longer a
comment that describes a defect this PR removes.

Reverse verification

From the committed state, MarketplacePackagePage.tsx was reverted to origin/main
(restore under an EXIT/INT/TERM trap). Mutation confirmed on disk before measuring —
guard back at line 593, 0 occurrences of the injected if (!isAdmin) return;, 0
occurrences of the marketplaceEnabled, isAdmin] dependency, guard once again sitting
after categoryLabel:

Tests 5 failed | 10 passed (15)
× is refused on the FIRST render, without being walked through the skeleton
× is refused rather than handed the server's diagnosis when the load fails
× issues neither of the requests whose answers it would have thrown away
× fetches once the flag flips, instead of sitting on a page that never loads
× and the detail page refuses that same non-admin the same way (the #5557 suite)

The 10 that stayed green are exactly the controls — the admin legs and the
marketplace-off legs — which is the discriminating pattern, not merely "it went red".

Green union, at 25be3f582 (final commit)

gatehow it was runverdict line
targeted vitestpnpm vitest run --maxWorkers=2 from the repo root (package-cwd is refused, #3378), over the provable superset belowTest Files 8 passed (8) · Tests 122 passed (122)
type-checkpnpm --filter @object-ui/app-shell type-check (tsc --noEmit && tsc -p tsconfig.test.json)VERDICT command-exit 0
linteslint . inside packages/app-shell — the exact command turbo run lint invokes for this package919 files, 0 errors (2549 pre-existing warnings; none on any line this PR adds)
check-lint-coveragenode scripts/check-lint-coverage.mjs46/46 packages linted, 0 with outstanding errors
check-control-bytesnode scripts/check-control-bytes.mjsOK (scanned 4672 tracked text file(s))
check-changeset-presencenode scripts/check-changeset-presence.mjs3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-no-majornode scripts/check-changeset-no-major.mjsNo changeset declares a major bump
check-changeset-fixednode scripts/check-changeset-fixed.mjsprivatePackages declared: version=true, tag=false
check-i18n-call-site-keysnode scripts/check-i18n-call-site-keys.mjsEvery in-scope call-site key resolves against the en pack (2918 keys)

Every heavy command ran through the shared verification lock; all reported
VERDICT command-exit 0.

Why the test run is a superset and not a sample.packages/app-shell has ~487 test
files and its full suite exceeds this container's foreground cap. The suites that can
observe this change are the ones that render the component, and that set is closed by
reading the importers: the only non-test importers are src/index.ts (a re-export, no
render, no module-level side effect) and AppContent.tsx's lazy route — and the one
test that drives that route, AppContent.pseudoRouteSegments.test.tsx, vi.mocks the
component out to a stub div, so the real module never loads there. That leaves the three
suites in console/marketplace/__tests__/ that import it directly. All six files in
that directory were run, plus AppContent.pseudoRouteSegments.test.tsx (proving its
mock still resolves) and packages/i18n/.../marketplace-preview-namespace-3546.test.tsx,
which reads this file as text and so is sensitive to the edit even though it renders
nothing.

Changeset — measured, not assumed

.changeset/marketplace-detail-admin-guard-order-5583.md (patch, @object-ui/app-shell).

Both legs built at the real dist/ path and compared uncompressed, so no gzip header
carries a filename into the number:

filestotal bytes
before (origin/main source)8625,891,126
after8625,893,632

Exactly one emitted file differs — dist/console/marketplace/MarketplacePackagePage.js,
42,591 → 45,097 bytes (+2,506). No .d.ts differs (0 of them), so the criterion
read literally as "does dist/*.d.ts move?" answers no while the shipped JS plainly
does; the emitted delta is the behaviour change plus the comments this package's build
preserves. The changeset gate is independently decisive here — it guards source of a
versioned package
, and @object-ui/app-shell@17.6.0 is one.

Filed, not fixed here

Not claimed

That the refusal itself is wrong, or that the server stops being the authority on what
a non-admin may fetch. This only stops the client doing work it would discard. The
catalog page's features.marketplace ordering (#5557) is untouched and pinned by a
control here.


Generated by Claude Code


Generated by Claude Code

…ge load
MarketplacePackagePage placed its `!isAdmin` guard after both the loading
branch and the `error || !data` branch, and gated its two fetch effects on
`features.marketplace` alone. On a runtime that mounts a marketplace, a
non-admin who opened a package URL was walked through the fetch and the
skeleton, and — when the load failed — was handed the destructive "Failed to
load package" card carrying the server's own error message instead of the
refusal. Whether they were refused or diagnosed came down to whether an
unrelated request happened to succeed.
Move the guard ahead of both branches and gate `getMarketplacePackage` and
`getCloudInstallationInfo` on `isAdmin` too, so the page stops requesting on
behalf of a viewer it has already decided to turn away (the discipline #5533
established on this page for `features.marketplace`). This is the ordering
MarketplacePage carries after #5557; the sibling pages now answer one runtime
the same way for every viewer. The server remains the authority on what a
non-admin may fetch.
`loading` stays seeded from `marketplaceEnabled` alone, deliberately: `isAdmin`
reads `activeMember`, which AuthProvider resolves asynchronously after the
session, so an org-role admin renders once as a non-admin — seeding `false`
there would paint the destructive card for a frame before the effect could
raise the flag again.
Part of #5583
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3785.7 KB3867.2 KB
Main entry chunk (gzip)151.6 KB350 KB
Entry fileindex-DpW5K8Rg.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)10.04KB3.72KB
app-shell (runtime-config.js)8.91KB2.99KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)29.34KB7.05KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)6.35KB2.43KB
auth (index.js)2.77KB1.22KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.02KB0.89KB
auth (useIsWorkspaceAdmin.js)3.04KB1.45KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)506.99KB113.73KB
core (index.js)4.51KB1.80KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)159.80KB44.33KB
fields (index.js)238.85KB60.13KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.22KB3.08KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.35KB3.31KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.42KB1.42KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.81KB0.83KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.72KB18.35KB
plugin-chatbot (index.js)181.41KB43.22KB
plugin-dashboard (index.js)128.36KB32.95KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)242.15KB60.89KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.07KB30.43KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.70KB27.17KB
plugin-map (index.js)20.06KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.50KB20.68KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)43.66KB14.77KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)10.76KB3.17KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)6.92KB2.40KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.08KB1.53KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — domain:ui seat review, round 8

Gate read by name on 25be3f582: 19 success + the 3 always-skipped no-ops (Test (coverage), the unexpanded shard placeholder, dependabot) = 22. All four test shards completed success at 19:17–19:20Z. No re-run issued, none warranted.

Read independently of the report: the diff, and the four check_suite.completed bursts that arrived within ~14 seconds of the PR existing. Those are not a gate reading and were not treated as one — at the moment they claimed success, three shards were still running.

What the review checked, beyond re-reading the report

The guard's data dependencies, because the reorder-vs-behaviour-change answer rests on them. Confirmed from the diff: isAdmin enters from useIsWorkspaceAdmin() and the moved guard reads none of data / loading / error. So "pure reorder with respect to this page's own load state" is a property of the code, not an assertion.

The loading seed, which is the subtle half. Keeping it marketplaceEnabled alone rather than marketplaceEnabled && isAdmin is correct and the reasoning is right: with && isAdmin, the first render of an org-role admin would carry loading: false and no data, which lands on the error || !data branch — the destructive card, for a frame. The comment at the seed records that, so the next reader who "tidies" the seed into symmetry with the effects has the argument in front of them.

The one thing this PR makes worse, which is disclosed rather than buried. Moving the guard above loading removes the skeleton that was incidentally masking the activeMember resolution window, so an org-role admin now sees MarketplaceAccessDenied for a frame where they previously saw a skeleton. That is a real, if brief, regression for that viewer — and it is in the PR body, in the changeset, in a code comment, and filed as #5619. Accepted because the mask was never an auth gate, the sibling page has been unmasked since #5557, and the alternative fix widens useIsWorkspaceAdmin's return type across 11 call sites — out of scope for this card by any reading. Flagged to the maintainer on #5619, since after this lands two refusal-rendering surfaces reach that window unmasked where today one does.

listLocalInstalls left alone. Correct: the intended shape is not pinned by anything in the tree, features.installLocal is a separate deployment axis, and #5533 gated the two marketplace requests while leaving this one — so whether that was deliberate is not readable. Filed as #5620 rather than widened into this PR.

The fixture triage on MarketplacePage.guardOrder.test.tsx is a strengthening, not collateral. The retired settledAnswerOf helper and its stubbed resolving package existed only because the detail page could not refuse anyone until a package loaded. Reading both pages' answers with answerOf against the ambient 404 is strictly stronger, and it removes a comment that would otherwise have described a defect this PR deletes.

Open questions — ruled

Q1 · the .d.ts criterion. → A, and the criterion is corrected at the source. My dispatch handed you "does dist/*.d.ts move?" without its scope. That criterion was derived across five packages from prose/comment-only cards, where a moving .d.ts is the signal that anything shipped at all. On a behaviour change it inverts — JS moves, .d.ts does not — exactly as measured here (+2,506 B of JS, zero .d.ts files differing). I sent this correction mid-flight to the two other round-8 devs whose cards are behaviour changes before they reached the same decision. Standing wording from now on: a changeset is owed when the diff touches source of a versioned package; check-changeset-presence.mjs is the authority and is decisive on its own. The dist/ measurement stays worth taking, as evidence rather than as the test. This was a PM error, not a dev error.

Q2 · comment bytes. → A, keep them. The surrounding module already emits 42 KB with comments intact, this package's build deliberately preserves them, and the added text carries the #5533 / #5557 / #5583 rulings that made the ordering question answerable at all. Trading a durable record for ~2.5 KB uncompressed in a 5.9 MB dist is the wrong direction.

Q3 · dispatch-gates.mjs has no objectui counterpart. → A for this PR. The hand derivation is stated explicitly enough to be reviewable, which is what the script would have bought. Not filing B as a card — it is PM tooling rather than a product defect; recorded on the seat post instead.

Q4 · the marker, and this is the valuable one. → Your measurement is right, and it overturns a standing lane rule. I did not take it on report: confirmed two independent ways. pull_request_read method:"get" renders this PR's body with the JSX stripped, while method:"get_diff" on the same PR returns the same characters intact; and fetching the rendered GitHub page shows the angle brackets present in the description. The write path never destroyed anything — the MCP body reader does, on the way to the agent.#5581 has been filed since 15:05Z with the opposite diagnosis and is now corrected there.

Why my original probe got it backwards is worth stating, because it is the failure mode this lane已经 recorded against someone else: I wrote content, read it back through the same lossy reader, and had no second reader to serve as a control. That control could not have failed. It is the same defect as the crud.ts row this seat's ACCEPT passed in round 7 — and I logged that rule three hours before repeating it.

The operational consequence is the part that was costing real work: the standing instruction to rewrite the report comment when the marker "did not survive" fires on a false signal every time, because through that reader it always looks absent. Your near-miss on two duplicate writes is the report that caught it. Relayed to the four other round-8 devs.

Not counted as evidence

check-action-forward-parity's green (structurally guaranteed on this diff), and the bundle bot's PASS.

Landing now: flipping ready and merging via the queue.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review August 21, 2026 19:22
@os-sales
os-sales added this pull request to the merge queueAug 21, 2026
Merged via the queue into main with commit 13413f3Aug 21, 2026
23 checks passed
@os-sales
os-sales deleted the claude/issue-5583-marketplace-admin-guard-order branch August 21, 2026 19:22
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MarketplacePackagePage decides "not an admin" only after the package load, so a non-admin sees a load-failure card instead of a refusal

2 participants

@os-sales@claude