Every hidden settings destination is GUARDED — the pages render the no-access state instead of a read-only form
Hiding is presentation and never protection. Once the rail stops offering a settings entry, the page behind it is still one typed URL, one bookmark and one old link away — and today most of those pages answer by rendering a read-only form, which is exactly the behaviour the parent story's amended rule replaces. This card closes the other half: a destination the actor cannot use renders the no-access state.
The approach
The exemplar already ships. app/(authed)/settings/project/automation/page.tsx resolves projectAccessService.getPermissions, tests the key, and renders components/projects/NoAccessState when it is absent. Every other settings page follows it.
- Each page reads its key FROM the registry, never re-declares it.
lib/settings/projectSettingsNav.tsis pure data with no JSX and its own comment sanctions server imports; after the registry card each entry carries itsPermissionKey. A page that looks up its own entry cannot drift from the rail that hides it — a hardcoded second copy of the key is how a row hides on one permission while its page refuses on another. - Three pages re-implement the admin check privately —
members,fieldsandcomponentseach computeisWorkspaceManager(wsRole) || myMembership?.role === 'admin'inline. That is a second policy, it predates the catalog, and it goes here. - The refusal keeps the shipped 404-vs-403 posture. A NON-BROWSER must still be answered as though the project does not exist — the no-existence-leak rule
assertPermissionimplements by throwingProjectNotFoundErrorbefore it tests the key. The no-access state is for a BROWSER who simply does not hold this domain's key. Do not collapse the two. - Copy per destination, from panel 3 of the design asset: a title that names the room, a description that says why, and a back action that goes somewhere useful — not one generic apology reused eleven times.
- The drill-down inherits.
roles/[roleKey]has no rail row of its own; it takes its parent entry's key, exactly asnestedRoutesalready declares it does for the rail.
Scope boundary
In: every page.tsx under app/(authed)/settings/project/**, their copy and its zh twin, and their tests.
Out: the registry and the rail (the registry card); every SERVICE and ROUTE gate — those were wired by the per-domain split and the member-facing sweep and this card neither adds nor weakens one, so no file under lib/services/** or app/api/** is touched; the workspace-settings area, which is governed by the workspace role this epic does not change.
Acceptance criteria
- A grep under
app/(authed)/settings/project/for a privately re-implemented admin check —isWorkspaceManagercombined with a membership-role comparison — returns nothing. - A test that ENUMERATES the filesystem (the technique the shipped route↔registry guard already uses) asserts that every
page.tsxunderapp/(authed)/settings/project/**resolves the actor's permissions and rendersNoAccessStatewhen its key is absent — so a settings page added later cannot ship unguarded, and the assertion needs no count to stay true. - No page hardcodes a permission-key string: each resolves its key by looking itself up in
PROJECT_SETTINGS_NAV, and a grep for a quoted catalog key underapp/(authed)/settings/project/returns nothing. roles/[roleKey]is guarded by the same key asroles.- A browser who does not hold the key gets the no-access state; a NON-browser still gets the shipped not-found handling — a test covers both arms, so the refusal never confirms a project the actor may not browse.
- Each destination's no-access copy matches panel 3 of
design/projects/permission-gated-ui.mock.html, and every newenkey has itszhtwin. - A project admin sees every settings page exactly as it renders at the branch's own merge base — no page lost a control, a form or a section — compared against that baseline before the PR lands.
git diff --name-onlylists no file underlib/services/orapp/api/.- The changed files meet the repo's per-file coverage floor.
Context refs
app/(authed)/settings/project/automation/page.tsx— the shipped exemplar:getPermissions, the key test,NoAccessState.app/(authed)/settings/project/members/page.tsx,fields/page.tsx,components/page.tsx— the three private admin re-implementations this card removes.components/projects/NoAccessState.tsx— the refused-state component, and its note on why there is no "Request access" action.lib/settings/projectSettingsNav.ts— the registry each page looks its key up in, andnestedRoutesfor the drill-down.lib/services/projectAccessService.ts—assertPermission's refusal ORDER, which this card must not flatten.tests/settings/projectSettingsNav.test.ts— the filesystem-enumerating guard whose technique the new coverage assertion reuses.- The design card — panel 3 is this card's copy reference.