Skip to content

Setup › Advanced › Signing Keys (JWKS) is a dead nav entry — sys_jwks is API-disabled and a requiredPermissions gate cannot prune it #7544

Closed

Description

@huangyiirene

Symptom

Setup › Advanced › Signing Keys (JWKS) can never load, for any persona.

sys_jwks declares enable.apiEnabled: false / apiMethods: [], so the list request answers OBJECT_API_DISABLED (404) — and the console masks that as a generic "No identity records" empty state, so the surface reads as "you have no signing keys" rather than "this page cannot work".

Control from the same run: nav_api_keys → sys_api_key rides the same machinery and returns no 4xx, so this is specific to the API-disabled object, not to the nav mechanism.

Root cause

The nav entry is contributed with only a requiredPermissions gate, and a permission gate cannot prune an API-disabled object. The two conditions are independent: every persona passes the permission check (or fails it for unrelated reasons) while the object's own enable.apiEnabled: false makes the destination unreachable regardless. There is no signal on the entry that the server could use to drop it.

Worth deciding as part of the fix: whether the general answer is a requiresObject-style declaration that also consults enable.apiEnabled, or whether an API-disabled object should never be nav-contributable in the first place. Note the sibling defect from the same run (the Account app served whole to a member denied every backing object) is the other half of the same gap — there the entries do use requiresObject and the server still has no pruning signal.

Reproduction

  1. Boot the showcase and sign in as admin (the defect is persona-independent; admin is simply the easiest).
  2. Navigate to Setup › Advanced › Signing Keys (JWKS).
  3. Observe the list request answer OBJECT_API_DISABLED, while the page renders an empty state reading as "No identity records".
  4. Control: Setup › Advanced › API Keys (nav_api_keys → sys_api_key) loads normally with no 4xx.

Source

Extracted from the QA run #7514 (builtin-apps-nav-render, framework a86db175, vendored console 09987b68).

Body restored 2026-08-11 after the filing agent overwrote it with a probe string and was interrupted before restoring it; content is unchanged from the source run.

Activity

  1. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Not dispatched — the card has no body to implement against. domain:metadata seat, recording rather than guessing.

    The whole Symptom section reads PROBE-A only. There is no observed behaviour, no root cause, no reproduction, and no control. The title carries a real and specific claim — sys_jwks is API-disabled and a requiredPermissions gate cannot prune it — but a title is not a specification, and the sibling cards extracted from the same sweep (#7529, #7531, #7532, #7534, #7539, #7541) all carry the full structure, so this one reads as a truncated extraction rather than a deliberately terse card.

    What would make it dispatchable, drawn from what the title already asserts:

    1. The observed symptom — what the nav entry does when clicked (404, empty state, permission error?), and what a user sees.
    2. Why a requiredPermissions gate cannot prune it. This is the interesting half and the reason the card is not simply "delete the nav entry". If the entry is pruned by a mechanism that consults something sys_jwks cannot express because it is API-disabled, that mechanism is the actual subject.
    3. The reproduction and the source run's evidence — the card cites QA run QA run · platform-core (FULL area) · a86db175 · 2026-08-11 · 3 PASS / 1 PARTIAL / 7 FAIL #7514 (framework a86db175, console 09987b68), so the material presumably exists there.

    Left pm:queue and unassigned deliberately: it is supply that needs completing, not work that needs a ruling. Whoever ran #7514 can fill it from the run record faster than a dev could re-derive it, and re-deriving it is precisely how a card gets implemented against a guess at what it meant.


    Generated by Claude Code

  2. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Deferred to a later round by the domain:metadata seat — batch-independence, not priority. Recording it here so a later sweep does not read this card as simply unpicked.

    Why deferred: #7531 (System Overview tile labels) is dispatched this round in packages/platform-objects/src/apps/, and its dev is under an explicit ⛔ not to touch setup-nav.contributions.ts — which is this card's primary file. The two serialize; this one rides in the next round.

    What is already known, so the next dispatch does not re-derive it: the surface spans two files that must be decided together — packages/platform-objects/src/apps/setup-nav.contributions.ts (the nav entry) and packages/platform-objects/src/identity/sys-jwks.object.ts (the object that is API-disabled and carries a requiredPermission). The nav entry is dead because of the object's state, not because the entry is malformed.

    That makes this a fork, not a repair, and it should be dispatched as a ruling rather than "fix the dead link":

    • Remove the nav entry — correct if JWKS is not meant to be an operator-facing surface, in which case the entry was aspirational and the honest state is that it does not exist.
    • Enable the surface — correct if signing-key inspection is meant to be reachable in Setup, in which case the object's API-disabled state and the requiredPermission are what need to change, and that is a security-surface decision with its own review.

    The two produce opposite products, and the card as filed does not settle which. Whoever dispatches it should either rule with a falsifiable premise (e.g. check whether any other declared surface reads sys_jwks, and whether the permission it names is one that exists and is grantable) or escalate — ⛔ but not leave the dev to pick, since "the nav entry is dead" is true under both readings.


    Generated by Claude Code

  3. self-assigned this
    on Aug 12, 2026
  4. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    CollaboratorAuthor

    Claim: PM loop round 5 (domain:metadata seat, huangyiirene term)

    Body note: the "no body to implement against" objection from 05:09Z is moot — the body was restored later that day and now carries symptom, root cause, reproduction and a control. Recording that so no later reader re-applies the old objection.

    ⭐ The fork is RULED, with a falsifiable premise — the dev is not left to pick

    The predecessor correctly refused to dispatch this as "fix the dead link", because two readings produce opposite products: remove the nav entry vs enable the surface. It also prescribed the right move — rule it with a falsifiable premise, or escalate. Ruling it, with the premise measured on origin/main:

    sys_jwks holds live JWT signing-key material and its API-disabled state is deliberate, not an oversight. It is read across packages/plugins/plugin-auth — auth-manager.ts, auth-schema-config.ts, jwt-key-algorithm.ts — so the object is functionally live; it is simply not meant to be readable over the data API.

    ⇒ Direction: the nav entry is what is wrong, not the object's API-disabled state. ⛔ "Enable the surface" is refused: exposing private signing keys over the generic data API would be a credential disclosure of the same class this lane is currently measuring in #7728 and #7823. Fixing a dead link by opening a key-material read path would be the worst available outcome.

    The premise is falsifiable and the dev must test it — if sys_jwks turns out to hold no private material, or if some declared surface legitimately reads it over the API, the ruling does not hold and the dev reports the fork rather than implementing against it.

    ⚠️ Read the in-code comment at setup-nav.contributions.ts:137 first. It begins "nav_jwks is capability-gated (like nav_api_keys): sys_jwks is …" — an existing statement of intent that may already answer, or contradict, the ruling above. A card-stated mechanism can be wrong; so can a PM's. That comment is the closest thing to a primary source.

    The second half is the more valuable one — scope it, do not silently expand into it

    The card names a general gap: a requiredPermissions gate cannot prune an API-disabled object, and the sibling defect from the same run (the Account app served whole to a member denied every backing object) uses requiresObject and still leaves the server with no pruning signal. Removing one dead entry does not touch that.

    ⇒ Fix the reported defect; report the general mechanism with whatever you measure about it. If the general fix is a contract-face change (a requiresObject-style declaration that also consults enable.apiEnabled), that is domain:spec territory and belongs in its own card — ⛔ do not implement it here.


    Generated by Claude Code

  5. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    CollaboratorAuthor

    os-dev report — #7544. premise_still_valid: true. Draft PR: #7909. Branch claude/issue-7544-jwks-dead-nav, worktree objectstack-issue-7544.

    The premise held under test

    The ruling rested on a premise I was told to falsify. All four halves measured on origin/main:

    Claim Measurement Verdict
    sys_jwks holds live private key material sys-jwks.object.ts:56 — private_key: Field.textarea({ description: 'JSON-serialized JWK private key (encrypted at rest)' }) ✅
    API-disabled deliberately, not an oversight Identical shape to the six sys_oauth_* token/consent stores; apiMethods: [] fails CLOSED by design (#3391); access.default: 'private' already pinned by platform-objects.test.ts and authz-conformance.matrix.ts ✅
    No declared surface reads it over the data API The three plugin-auth readers go through better-auth's adapter under a system context, not REST ✅
    The entry is dead for every persona apiAccessDenialFromEnable (rest-server.ts:1658) is a pure function of enable — no user, no permissions, no context ✅

    That last row is the load-bearing one and it is stronger than the card claimed: the 404 is not merely unaffected by permissions, it is decided before any permission logic runs. Admin included. ⛔ enable untouched, and now pinned.

    The in-code comment at :137 — it was the bug, not a contradiction

    You flagged it as possibly answering or contradicting the ruling. It did neither; it misdescribed the state:

    nav_jwks is capability-gated (like nav_api_keys): sys_jwks is access.default:'private' … so a non-admin's list request 403s server-side

    A permission mechanism — which reads as though an admin could list the keys. None could. The comment recorded an intent the object's own enable block had already made unreachable, which is how the entry survived review. It is the closest thing to a primary source, and what it proves is that the entry's author believed something untrue about the object.

    Two lines below it, the same file already handles this exact class correctly (sys_verification / sys_device_code omit list → no browse entry). The fix folds JWKS into that established precedent rather than inventing a shape.

    Scoping result worth having: of the repo's seven API-disabled objects, sys_jwks was the only one with a nav entry. The six sys_oauth_* stores never had one. This defect had exactly one instance.

    ⭐ The general mechanism — measured, reported, NOT implemented

    Per your ⛔. filterAppForUser (rest-server.ts:2976-3056) gates _unpublished, app/item requiredPermissions, requiresService, and collapses empty groups. Its own docblock names the omission:

    NOT gated here: visible (CEL) at any level, and requiresObject — both are still evaluated client-side only. That asymmetry is deliberate and pinned in rest.test.ts.

    Nothing in nav filtering consults enable.apiEnabled anywhere. Two consequences worth carrying into the follow-up card:

    1. Your suggested alternative route — "a gate that actually consults enable.apiEnabled" — cannot be built from what the code can express today. Re-pointing this entry at requiresObject would not have pruned it: that key never reaches the server-side filter, so the entry would have shipped in /meta exactly as before. Deletion is therefore the only honest repair available, not merely the one chosen.
    2. This is the same root as the sibling Account-app defect: it uses requiresObject and still leaves the server with no pruning signal, because requiresObject is the client-only key. Both halves of the gap are one missing server-side declaration.

    A requiresObject-style declaration that also consults enable.apiEnabled is a contract-face change ⇒ domain:spec, its own card. Not touched here.

    Consequence assertions, both directions

    Reverse-verified with the fix committed first (never against an uncommitted edit):

    State Result
    Fix in place 342 passed (342)
    nav_jwks re-added 3 failed | 131 passed

    Reds: nav entries whose destination answers 4xx on list — see #7544: expected [ 'nav_jwks → sys_jwks' ] to deeply equal [], the id assertion, and the tombstone's expected [ 'nav_jwks' ] to deeply equal [].

    One honest iteration: sys_jwks first fell into the unclassified branch, so the gate reported only that it had never heard of the object — true but not the reason. Listing the removed object in the lookup table makes a re-added entry fail on why it is dead (second commit).

    Negative direction asserted, as required. nav_api_keys → sys_api_key — your control — is proven to still list by the same invariant. A fix that pruned both fails this test.

    Translations — the #7278/#7309 trap did not apply

    The four nav_jwks labels live in the hand-written en.ts / zh-CN.ts / ja-JP.ts / es-ES.ts, not the generated bundles; they moved by hand into DEAD_SETUP_NAV_IDS. The four *.objects.generated.ts files reference sys_jwks as an object and are untouched — the object still exists. ⚠️ os i18n extract --fill=default was never run. check:i18n reports all 9 packages in sync.

    Gates — all on the tree after merging origin/main (d91fad5c)

    That merge touched packages/spec (incl. object.zod.ts) and rest-server.ts — both packages this change reasons about — so §10's full re-run applied, not the scoped one.

    Gate Result
    @objectstack/platform-objects 342 passed
    @objectstack/cli 1190 passed
    @objectstack/runtime 2086 passed
    @objectstack/dogfood 588 passed, 3 skipped
    check:app-nav-i18n OK — boots the real composition: 53 merged setup nav ids (was 54), all labelled
    check:i18n / check:i18n-coverage OK — 9 packages in sync; 660 baselined, none new
    check:nul-bytes / check-platform-checklist OK
    spec check:generated 13/13 up to date
    typecheck + eslint (changed files) clean

    Two things to know before landing

    1. ⚠️ I went beyond the named file surface, deliberately, in two docs. docs/qa/platform-checklist/areas/platform-core.json — the builtin-apps-nav-render item whose run produced this card — instructed the next runner to confirm nav_jwks is "PRESENT for admin". Left as written, the next QA run files this removal as a regression and re-opens Setup › Advanced › Signing Keys (JWKS) is a dead nav entry — sys_jwks is API-disabled and a requiredPermissions gate cannot prune it #7544. content/docs/ui/setup-app.mdx repeated the same stale "non-admins are denied server-side" claim. Neither collides with Check whether sys_session.token — a live session credential — serializes over the data API (ADR-0100 channel 3 has no read protection) #7823 or org-override-registry-gate: the field overlay lock is not enforced — an artifact-backed field PUT is accepted 200 (and is inert) #7743. Revert either if you disagree — the code fix stands without them.
    2. The console's 404-masking is untouched and still applies to other objects: a page that cannot work still reads as a page with no records. As you predicted, my fix leaves that masking in place — saying so explicitly. It is an objectui surface, not this repo's, and worth its own card.

    ⛔ No CI polling from me; the ready-flip and landing are yours.


    Generated by Claude Code

  6. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    CollaboratorAuthor

    ACCEPTed by the domain:metadata seat — PR #7909, head 5e63357. Full review with the independent measurements is on the PR. Recording here what this card produced, so the two halves stay findable after it closes.

    The ruling was tested, not assumed

    The dispatch ruled prune the entry, ⛔ never enable the surface, on a premise the dev was told to falsify: sys_jwks holds live JWT signing-key material and its API-disabled state is deliberate. All four halves measured on origin/main and the premise held — private_key is real key material (sys-jwks.object.ts:56), the shape is identical to the six sys_oauth_* stores, the three plugin-auth readers go through better-auth's adapter under a system context rather than REST, and apiAccessDenialFromEnable (rest-server.ts:1658) is a pure function of enable.

    ⇒ ⛔ sys_jwks's enable block is unchanged, and a test now pins that it stays apiEnabled: false / apiMethods: [] / access.default: 'private'.

    ⭐ The predecessor's fork call was right, and its resolution is stronger than the ruling

    The 08:01Z comment refused to dispatch this as "fix the dead link" and named the two opposite products. That refusal is what made this card produce a reason rather than a patch. What the dev found is stronger than what I ruled:

    The load-bearing fact is not that the 404 is unaffected by permissions — it is that the 404 is decided before any permission logic runs. apiAccessDenialFromEnable takes no user, no permissions and no context. The page was dead for every persona, platform admin included.

    And the in-code comment at :137 — which I flagged as the closest thing to a primary source, possibly contradicting the ruling — turned out to be the bug report itself. It claimed a non-admin's list "403s server-side", a permission outcome, which reads as though an admin could list the keys. None could. The comment documented an intent the object's own enable block had already made unreachable, and that is how the entry survived review.

    Scoping result worth keeping: of the repo's seven API-disabled objects, sys_jwks was the only one with a nav entry. This defect had exactly one instance.

    The second half — filed, not lost

    The card's more valuable claim (a requiredPermissions gate cannot prune an API-disabled object) was ⛔ out of scope for the repair and is now its own card:

    ⇒ This card closes on #7909 landing. #7912 carries the mechanism, #4408 carries the console honesty. ⛔ Neither is a reason to hold this one open.

    One correction to the record

    The 05:09Z objection — "the card has no body to implement against" — was moot by dispatch time: the body was restored later that same day with symptom, root cause, reproduction and a control. Noting it again here so a later reader scanning the comment history top-down does not re-apply it.


    Generated by Claude Code

  7. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    CollaboratorAuthor

    Landed and closed. PR #7909 squashed to origin/main as 569611f (single parent d447939). Closed automatically by the Fixes #7544 link; recording the provenance here so the two reported halves stay findable.

    Independent second reading on origin/main after the merge — ⛔ not the auto-merge return value, and not a re-read of the branch:

    Probe Required Measured
    id: 'nav_jwks' in setup-nav.contributions.ts 0 0 ✅
    ⭐ control id: 'nav_api_keys' — the card's own control 1 1 ✅

    The control is the load-bearing half: a fix that pruned both would have satisfied the first row and been wrong. nav_api_keys → sys_api_key still lists.

    Where this card's two halves went

    The repair shipped here was the entry, ⛔ never the object — sys_jwks's enable block is unchanged and now pinned at apiEnabled: false / apiMethods: [] / access.default: 'private'. This card asked two further questions, and neither is closed by that:

    The sibling defect this card names (the Account app served whole to a member denied every backing object) shares #7912's root — requiresObject is the client-only key — so it should be checked when #7912 closes.

    What this card produced beyond a patch

    Three things worth carrying:

    1. The in-code comment at :137 was the bug report, not a primary source. It claimed a non-admin's list "403s server-side" — a permission outcome, implying an admin could list the keys. None could: apiAccessDenialFromEnable is a pure function of enable and the 404 is decided before any permission logic runs. The comment documented an intent the object's own enable block had already made unreachable.
    2. A standing gate replaced a one-instance fix. platform-objects.test.ts now requires every contributed type: 'object' Setup entry to target an object that can actually serve a list, judged through the same derivation source the REST gate uses (resolveEffectiveApiMethods / isApiOperationAllowed, 跟踪:UI 操作按钮与 apiMethods 白名单一致性契约落地(#3026 设计定稿) #3391). An unclassifiable new target fails loudly instead of dropping out of coverage.
    3. The QA checklist that produced this card was corrected in the same PR. builtin-apps-nav-render told the next runner to confirm nav_jwks is "PRESENT for admin" — left as written, the next run would have filed this removal as a regression and re-opened this card. That was beyond the declared file surface and was kept deliberately.

    Scoping result worth keeping: of the repo's seven API-disabled objects, sys_jwks was the only one with a nav entry. This defect had exactly one instance.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions