Skip to content

fix(app-shell): members and invitations tabs gate their affordances by org role (#4475) - #4505

Merged
yinlianghui merged 4 commits into
mainfrom
claude/issue-4475-members-role-gating
Aug 13, 2026
Merged

fix(app-shell): members and invitations tabs gate their affordances by org role (#4475)#4505
yinlianghui merged 4 commits into
mainfrom
claude/issue-4475-members-role-gating

Conversation

@yinlianghui

@yinlianghui yinlianghui commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Closes #4475

The defect

On /_console/organizations//members, a user whose org role is member was shown an enabled Invite member button and a per-row Member actions menu carrying Remove member — on every row, the workspace Owner's included. Nothing was hidden or disabled; the action only failed after the user committed to it. The Settings tab of the same page already gates correctly (form replaced by explanatory copy, Delete disabled, Leave enabled); the members and invitations tabs never got the same treatment.

The measured role rule (what I keyed on)

Not role === 'owner' — that is wrong in both directions, and the server says so. The three affordances on these two tabs sit behind three different better-auth permissions:

affordance route permission roles holding it
Invite member /organization/invite-member invitation:["create"] owner, admin, delegated_admin
Remove member /organization/remove-member member:["delete"] owner, admin
Cancel invitation /organization/cancel-invitation invitation:["cancel"] owner, admin

Evidence, read from the code that enforces it:

  • better-auth 1.6.26, plugins/organization/access/statement.mjsownerAc and adminAc both carry invitation: ["create","cancel"] and member: ["create","update","delete"]; memberAc carries invitation: [] and member: [].
  • Route checks: routes/crud-invites.mjs asserts { invitation: ["create"] } for invite (line 98) and { invitation: ["cancel"] } for cancel (line 437); routes/crud-members.mjs asserts { member: ["delete"] } for remove (line 188).
  • objectstack packages/plugins/plugin-auth/src/auth-manager.ts registers the fourth role on top of those defaults (lines 1896-1901, verbatim):
const stmts = memberAc.statements;          // member: [] — may NOT remove anyone
built[MEMBERSHIP_ROLE_DELEGATED_ADMIN] = defaultAc.newRole({
  ...stmts,
  invitation: ['create'],                   // …but MAY invite
});

delegated_admin is the row a single boolean cannot express: it may invite, may not remove, and deliberately has no cancel — the framework's own comment records why (better-auth's cancel route checks only the permission and never invitation attribution, so granting it would mean "cancel anyone's pending invitation in the org"). That asymmetry is mirrored exactly rather than flattened.

The actor's role comes from activeMember.role — the same source the role-change narrowing (assignableOrgRoles, framework #3697) on this page already reads, so the screen keeps one role source. The new orgCapabilities module composes @object-ui/auth's orgRoleGrade ladder and role names rather than restating them, so the closed ADR-0108 vocabulary keeps exactly one definition. An unresolved role grades below a plain member (orgRoleGrade's documented floor), so nothing privileged is offered to a viewer whose membership could not be read.

Copy-link on the invitations tab is gated with the invite predicate, not the cancel one: the link is the invitation, so handing it out is the issuing capability finishing its job — which is why a delegated_admin keeps it and still loses cancel.

remove-member server verdict (the card's open probe — measured read-only)

The card noted that remove-member's gating was unverified from the client because a probe with a non-existent member returned 400 MEMBER_NOT_FOUND. Measured by reading the route, not by exercising it: the server does gate it. In crud-members.mjs the target lookup runs first (line 175) —

if (!toBeRemovedMember) throw APIError.from("BAD_REQUEST", ORGANIZATION_ERROR_CODES.MEMBER_NOT_FOUND);

— and only afterwards (line 185):

if (!await hasPermission({ role: member.role, permissions: { member: ["delete"] }, … }))
  throw APIError.from("UNAUTHORIZED", ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_MEMBER);

So the ordering explains the maintainer's 400 exactly — the probe never reached the gate — and any real member id does reach it. No server-side defect; nothing to escalate to the objectstack lane. One observation, not filed as a defect: the ordering lets a non-permitted caller distinguish "member exists" (401) from "no such member" (400). It is upstream better-auth behaviour and reveals nothing a member cannot already read from the members list they are shown.

Red-first

Verbatim DOM for a member viewer, captured from a throwaway probe test at both ends.

Pre-fix — members page header and the Owner's row:

<h2>Members (2)</h2>Invite member

…OwnerRemove member

inviteBtn=PRESENT  inviteBtnDisabled=false  removeItems=2  memberActionsMenus=2
copyLink=PRESENT   cancelInvitation=PRESENT

Post-fix — same viewer:

<h2>Members (2)</h2><p>Only organization admins can invite members.</p>

…Owner

inviteBtn=ABSENT   removeItems=0   memberActionsMenus=0
copyLink=ABSENT    cancelInvitation=ABSENT

The whole suite run against the unfixed tree: 12 failed | 12 passed — the 12 passing being exactly the must-not-change half. After the fix: 24 passed.

Targeted reverse verification, direction predicted before running: widening canInviteMembers back to a plain grade >= admin reds precisely the two delegated_admin cases (invite button, copy-link) and nothing else — 2 failed | 22 passed. Restored, green again.

Must-not-change (green on both sides)

  • owner and admin keep the Invite button, Remove on every row, and the row menu (removeItems=2, memberActionsMenus=2).
  • admin still cannot re-role an Owner (better-auth's creatorRole protection) yet keeps Remove on that row — pinned so the new gate cannot be confused with the role-item gate.
  • The member list and the invitation ledger still render in full for a member; only write affordances go. Whether org_member should read the ledger at all is objectstack#8095 and is not coupled here.
  • Leave organization is untouched, and SettingsPage.tsx is byte-identical (git diff origin/main -- SettingsPage.tsx is empty).
  • The console: organization & invitation UI ships untranslated English in a zh locale (6 sites, incl. icon-only aria-labels) #4474 i18n pins stay green.

Presentation

Where the Invite button was, the members page now puts the explanation, in the Settings tab's own text-sm text-muted-foreground voice — the same convention (copy takes the place of the affordance it replaces), sized for a header slot rather than a form. Not a disabled button: a control that exists only to refuse is still an invitation to try. An actor left with no row action gets no menu, rather than a trigger that opens onto nothing. The invitations tab has no header affordance to replace, so it gets no copy — its per-row icons simply do not render.

One new string, organization.members.inviteRestrictedNote, added through the channel #4474 established (PR #4496): inline useObjectTranslation default plus all ten packs. resolveOrgRoleLabel and the error mapper are reused untouched.

Verification

  • pnpm exec vitest run packages/app-shell/src/console/organizations packages/i18n/src55 files, 892 passed (11 files / 91 tests app-shell console; 44 files / 801 tests i18n)
  • tsc --noEmit -p packages/app-shell/tsconfig.json → clean; tsc --noEmit -p packages/app-shell/tsconfig.test.json → clean (--listFiles confirms the new test and orgCapabilities.ts are both in that program, so the green is not an exclusion)
  • eslint packages/app-shell/src/console/organizations packages/i18n/src/locales0 errors, 109 warnings, none of them new-in-kind: 94 @typescript-eslint/no-explicit-any (the component-mock passthroughs this suite's sibling files already use — 24 in acceptInvitationLink.mount, 17 in org-i18n-holdouts-4474, 18 here), 9 react-hooks/set-state-in-effect on untouched fetch effects (the one in MembersPage.tsx reproduces identically on origin/main), 5 react-hooks/refs, 2 exhaustive-deps. The package's lint script is a plain eslint . — no --max-warnings budget.
  • check:i18n-keys, check:i18n-drift, check:control-bytes, changeset:check, check-changeset-presence → all green (drift reports 1 key added, 0 en values changed; presence sees 14 source files across 2 released packages and 1 changeset)
  • .d.ts measured, not asserted: declarations emitted for the whole package from this tree and from the pre-fix tree, then diffed. The only difference in all of app-shell is the new internal console/organizations/manage/orgCapabilities.d.tsMembersPage.d.ts and InvitationsPage.d.ts are byte-identical, and no file outside orgCapabilities.d.ts itself mentions it (it is not re-exported from the package entry). Patch, never major.

Continuation note (second session)

A host restart killed the first session between the push and its verification report, so this PR's verification was re-derived from scratch rather than inherited on trust:

  • Merged origin/main (844ed3aea, 2 commits ahead: test(dom-leak): one attribute judge, shared by both gates (#4434) #4499 test-support, fix(plugin-dashboard): optionsFrom filters commit the raw value, not the display label (#4465) #4504 plugin-dashboard) — clean, no conflicts; pnpm install re-run for the new @object-ui/test-support workspace entry, then the full pass above re-run on the merged tree.
  • Red-first re-derived independently by reverting only the source half to origin/main (both pages, the ten locale packs, and orgCapabilities.ts removed) while keeping the test file — git checkout origin/main -- (paths), never git stash. Result matched the prediction made before the run: Tests 12 failed | 12 passed (24), with the first failure verbatim AssertionError: expected (button …(1)) to be null receiving the real invite-member-btn button, the second expected [ … ] to have a length of +0 but got 2 for Remove items (one per row, the Owner's included), the third the same got 2 for Member actions menus. Restored to a clean tree; 24 passed.
  • The permission claims above were re-read at their sources in this session (better-auth statement.mjs, crud-invites.mjs, crud-members.mjs, objectstack auth-manager.ts) rather than carried over — two wordings were corrected against them: the auth-manager.ts snippet now quotes the real const stmts = memberAc.statements alias, and the eslint summary now reports the true rule mix (the earlier draft attributed all 109 warnings to one rule).

Generated by Claude Code

…y org role (#4475)

A user whose org role is `member` was shown an enabled **Invite member** button
and a per-row **Member actions** menu carrying **Remove member** — on every row,
the workspace Owner's included. Nothing was hidden or disabled; the action only
failed after the user committed to it. The Settings tab of the same page already
gated correctly; these two tabs never got the same treatment.

The roles are MEASURED against the routes that enforce them, not assumed to be
"owner". better-auth 1.6.26's `organization/access/statement.mjs` plus the
`delegated_admin` role the framework registers on top of it
(objectstack `plugin-auth/src/auth-manager.ts`) give three DIFFERENT gates:

  invite  -> `invitation:["create"]` -> owner, admin, delegated_admin
  remove  -> `member:["delete"]`     -> owner, admin
  cancel  -> `invitation:["cancel"]` -> owner, admin

`delegated_admin` is the row a single `isOwner` boolean cannot express: built
from `memberAc.statements` (`member: []`) with `invitation: ['create']` added
and `cancel` deliberately withheld. `orgCapabilities` composes the existing
`orgRoleGrade` ladder rather than restating the closed ADR-0108 vocabulary.

An actor left with no row action gets no menu rather than a trigger opening onto
nothing, and the members page explains the absence where the Invite button sat,
in the Settings tab's own `text-sm text-muted-foreground` voice. An unresolved
role grades below a plain member, so nothing privileged is offered to a viewer
whose membership could not be read.

Reading is untouched: the member list and the invitation ledger still render.
Whether `org_member` should read the ledger at all is objectstack#8095 and is
deliberately not coupled here.

Server verdict on the card's open probe: `remove-member` IS gated. The target
lookup runs BEFORE the permission check, which is why the maintainer's
non-existent-member probe returned `400 MEMBER_NOT_FOUND` and said nothing about
gating — any real member id reaches
`hasPermission({ member: ["delete"] })` and 401s. No server-side defect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercel Bot commented Aug 12, 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 2:15am

Request Review

@github-actions github-actions Bot added the tests label Aug 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-_dRx9hm-.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) 22.10KB 4.37KB
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) 36.76KB 9.60KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
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) 4.91KB 0.87KB
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) 153.79KB 41.35KB
fields (index.js) 230.07KB 57.07KB
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.33KB 1.20KB
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.86KB 31.50KB
plugin-designer (index.js) 212.58KB 42.83KB
plugin-detail (index.js) 239.03KB 59.77KB
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.13KB 50.00KB
plugin-kanban (index.js) 48.62KB 13.42KB
plugin-list (index.js) 111.07KB 27.08KB
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

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-_dRx9hm-.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) 22.10KB 4.37KB
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) 36.76KB 9.60KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
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) 4.91KB 0.87KB
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) 153.79KB 41.35KB
fields (index.js) 230.07KB 57.07KB
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.33KB 1.20KB
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.86KB 31.50KB
plugin-designer (index.js) 212.58KB 42.83KB
plugin-detail (index.js) 239.03KB 59.77KB
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.13KB 50.00KB
plugin-kanban (index.js) 48.62KB 13.42KB
plugin-list (index.js) 111.07KB 27.08KB
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

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-D_Aa5Ppe.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) 22.10KB 4.37KB
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) 36.76KB 9.60KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
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) 4.91KB 0.87KB
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) 153.79KB 41.35KB
fields (index.js) 230.07KB 57.07KB
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.33KB 1.20KB
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.03KB 59.77KB
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

Copy link
Copy Markdown
Collaborator Author

Continuation note (third session) — verify-only re-derivation

A host restart killed the second session after it removed its worktree but before its report landed. This session re-created a fresh worktree at the pushed branch and re-derived the verification independently. No implementation was changed; the only new commits are two origin/main merges.

Branch brought current (twice)

origin/main moved during the run, so the branch was merged forward twice, both clean, neither touching this card's surface:

The card's own delta is unchanged at 15 files / +724 / -84, and nothing outside console/organizations/**, the ten locale packs, and one changeset appears in merge-base..HEAD.

Permission claims re-read at their sources (not inherited)

Every number in the role-rule table above was re-read this session and holds:

  • better-auth 1.6.26 access/statement.mjsadminAc/ownerAc carry invitation: ["create","cancel"] + member: ["create","update","delete"]; memberAc carries organization: [], member: [], invitation: [], ac: ["read"].
  • crud-invites.mjsinvitation: ["create"] at line 98, invitation: ["cancel"] at line 437.
  • crud-members.mjsmember: ["delete"] at line 188.
  • objectstack auth-manager.ts lines 1895-1903 — const stmts = memberAc.statements; then invitation: ['create'], confirming delegated_admin may invite, may not remove, has no cancel.
  • @object-ui/auth orgRoleGrade — only owner/admin elevate, so delegated_admin grades as an ordinary member (hence the separate name check), and the floor is genuinely fail-closed: GRADE_UNRESOLVED = 0 &lt; GRADE_MEMBER = 1 &lt; GRADE_ADMIN = 2.

remove-member verdict re-confirmed read-only (route read, never exercised): lookup throws MEMBER_NOT_FOUND at line 175, the member: ["delete"] gate runs at 185-190. The server does gate it; no objectstack escalation. One detail worth adding to the note above: the creatorRole / only-owner guard at lines 177-183 also precedes the permission check, so a non-permitted caller aiming at a sole Owner meets that 400 before the 401 — same upstream ordering, same non-defect.

Red-first re-derived from scratch

Reverted only the source half to origin/main (both pages + ten packs, orgCapabilities.ts deleted), keeping the test file — git checkout origin/main -- (paths), never git stash; restore verified by sha256 across all 13 files (all OK, tree clean).

Direction predicted before running, and matched: Tests 12 failed | 12 passed (24). Verbatim first three assertions:

AssertionError: expected (button …(1))(/button) to be null
AssertionError: expected [ (button …(1))…(1)(/button), …(1) ] to have a length of +0 but got 2
AssertionError: expected [ (button …(3))…(1)(/button), …(1) ] to have a length of +0 but got 2

The 12 reds are exactly the gating cases (invite button, remove on every row, row menus, the delegated_admin and unresolved-role cases, both invitations affordances, and the two i18n pins). The 12 greens are exactly the must-not-change half — owner/admin affordances, the member list, the ledger, and the #3697 re-role narrowing.

Correction to the .d.ts method (conclusion unchanged)

The declaration diff above is right, but the obvious way to produce it is not reproducible: pnpm --filter @object-ui/app-shell build is a plain tsc with composite: true, so a pre-fix rebuild silently keeps the previous run's dist/ and is skipped entirely by a stale packages/app-shell/tsconfig.tsbuildinfo — my first attempt reported 414 declarations for both trees, with orgCapabilities.d.ts present in the pre-fix set as a leftover. Removing dist/ alone is not enough; tsc then emits nothing at all and exits 0.

Re-measured with both dist/ and the .tsbuildinfo cleared before each build:

  • pre-fix: 413 declarations, no orgCapabilities.d.ts
  • fixed: 414 declarations

diff -rq across the 413 shared declarations reports no content differences whatsoeverMembersPage.d.ts and InvitationsPage.d.ts byte-identical by sha256 — and orgCapabilities is referenced by no other .d.ts, so it is internal. Patch grading confirmed.

Gates re-run on the final merged tree

gate result
vitest run packages/app-shell/src/console/organizations packages/i18n/src 55 files, 892 passed
tsc --noEmit -p tsconfig.json / -p tsconfig.test.json both clean; --listFiles confirms the new test, orgCapabilities.ts and both pages are in the test program
eslint (organizations + locales) 0 errors, 109 warnings — 94 no-explicit-any, 9 set-state-in-effect, 5 refs, 2 exhaustive-deps, matching the mix claimed above
check:control-bytes OK, 4220 files scanned; plus a direct grep -naP self-scan of all changed files — clean
check:i18n-keys / check:i18n-drift green; drift reports 1 key added, 0 en values changed
changeset:check / presence green; 14 source files across 2 released packages, 1 changeset

CI on 15c7ebe9b converged 20/20: 18 success, 2 skipped (dependabot, Test coverage), 0 failures — Lint, Type Check, all four test shards, Control Byte Scan and the three changeset gates all green.

PR intentionally left in draft for the PM.


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 13, 2026 02:25
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 13, 2026
Merged via the queue into main with commit 6d641c9 Aug 13, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4475-members-role-gating branch August 13, 2026 02:26
Sign up for free to 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.

console: workspace members page offers Invite / Remove member to the member role; only the server 403 stops it

2 participants