Skip to content

fix(platform-objects): drop the dead Setup › Advanced › Signing Keys (JWKS) nav entry (#7544) - #7909

Merged
huangyiirene merged 4 commits into
mainfrom
claude/issue-7544-jwks-dead-nav
Aug 12, 2026
Merged

huangyiirene merged 4 commits into
mainfrom
claude/issue-7544-jwks-dead-nav

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Fixes #7544

Setup › Advanced › Signing Keys (JWKS) could never load, for any persona. sys_jwks declares enable.apiEnabled: false / apiMethods: [], so the list request answers OBJECT_API_DISABLED (404) — and the console masked that as a generic "No identity records" empty state, so the surface read as "you have no signing keys" rather than "this page cannot work".

The premise was testable, and it held

The dispatch ruled prune the entry, ⛔ never "enable the surface", on a premise I was asked to falsify: sys_jwks holds live JWT signing-key material and its API-disabled state is deliberate. Measured on origin/main:

Claim Measurement Verdict
Holds private key material sys-jwks.object.ts:56 — private_key: Field.textarea({ description: 'JSON-serialized JWK private key (encrypted at rest)' }) ✅ confirmed
API-disabled deliberately Same shape as the six sys_oauth_* token/consent stores; apiMethods: [] fails CLOSED by design (#3391); access.default: 'private' pinned by platform-objects.test.ts "secure-by-default posture" and by authz-conformance.matrix.ts ✅ confirmed
No declared surface reads it over the API The three plugin-auth readers (auth-manager.ts, auth-schema-config.ts, jwt-key-algorithm.ts) go through better-auth's adapter under a system context, not REST ✅ confirmed
The entry is dead for everyone apiAccessDenialFromEnable (rest-server.ts:1658) is a pure function of enable — no user, no permissions, no context — and enforceApiAccess passes it only object metadata ✅ confirmed

So the fork is settled by measurement, not only by ruling: the object is right and the entry was wrong.

The in-code comment was the actual bug report

The dispatch flagged setup-nav.contributions.ts:137 as a possible primary source. It contradicted itself:

nav_jwks is capability-gated (like nav_api_keys): sys_jwks is access.default:'private' (ADR-0066 ④ — signing keys), so a non-admin's list request 403s server-side

That describes a permission outcome, which reads as though an admin could list the keys. None could — the 404 precedes and ignores permissions entirely. The comment documented an intent the object's own enable block had already made unreachable, which is why the entry survived review. It is replaced with what actually happens.

What landed

⛔ sys_jwks's enable block is unchanged, and a test now pins that it stays apiEnabled: false / apiMethods: [] / access.default: 'private'. better-auth keeps signing and verifying tokens through its adapter.

Verification

Reverse-verified (fix committed first, then the entry re-added — never against an uncommitted edit):

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

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

One iteration was needed to earn that first message: sys_jwks initially fell into the unclassified branch, so the gate reported only that it had never heard of the object. Listing the removed object in the lookup table makes a re-added entry fail on why it is dead (commit 2).

The negative direction is asserted. nav_api_keys → sys_api_key — the card's own control, same machinery, no 4xx — must keep working: the invariant test proves it still lists, so a fix that pruned both would fail here.

All gates run on the tree after merging origin/main (d91fad5c, which 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 in 4 locales
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

⭐ Reported, not implemented: the general gap

The card's more valuable half — a requiredPermissions gate cannot prune an API-disabled object — is not fixed here, per the dispatch's ⛔. What I measured about it:

filterAppForUser (rest-server.ts:2976-3056) gates _unpublished, app- and item-level requiredPermissions, requiresService (ADR-0057 D10), and collapses empty groups (#7380). Its own docblock names what it does not gate:

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 at all. So re-pointing this entry at a requiresObject gate would not have pruned it either — it would have shipped in the /meta payload exactly as before, which is also why the sibling defect (the Account app served whole to a member denied every backing object) survives despite using requiresObject. There is today no declaration a nav entry can carry that expresses "prune when the destination cannot serve", which is what makes deletion the only honest repair available rather than merely the chosen one.

Closing that is a contract-face change (domain:spec) and belongs in its own card.

Separately, still open: the console masks a 404 as a generic empty state. That masking is untouched here and still applies to other objects — a page that cannot work will keep reading as a page with no records. Flagged as the dispatch asked; it is a objectui surface, not this repo's.


Generated by Claude Code

claude added 4 commits August 12, 2026 02:57
…(JWKS) nav entry (#7544)

`Setup › Advanced › Signing Keys (JWKS)` could never load, for any persona.
`sys_jwks` declares `enable.apiEnabled: false` / `apiMethods: []`, so the list
request answers `OBJECT_API_DISABLED` (404) — and the console masked that as a
generic "No identity records" empty state, so the surface read as "you have no
signing keys" rather than "this page cannot work".

The entry carried `requiredPermissions: ['manage_platform_settings']` and a
comment claiming a non-admin "403s server-side", which reads as though an admin
could list the keys. None could: `apiAccessDenialFromEnable` (rest-server.ts) is
a pure function of the object's `enable` block — no user, no permissions, no
context — so the 404 is identical for every persona, platform admin included. A
permission gate on the entry and an API-disabled object are independent
conditions, and no combination of the first prunes the second.

The repair is the entry, never the object: `sys_jwks` rows are the environment's
JWT signing keys (`private_key`), and opening a read path onto them over the
generic data API would be a credential disclosure. `enable` is unchanged and now
pinned. better-auth keeps reading the keys through its adapter under a system
context, so token signing and verification are unaffected.

This matches how the same class is handled two lines below: `sys_verification`
and `sys_device_code` omit `list` and get no browse entry. `sys_jwks` was the
only one of the repo's seven API-disabled objects that still had a nav entry.

- The four `apps.setup.navigation.nav_jwks` labels move into the
  `DEAD_SETUP_NAV_IDS` tombstone, per the order that file states. The `sys_jwks`
  object labels in the generated bundles are untouched — the object still exists.
- New invariant in `platform-objects.test.ts`: every contributed `type: 'object'`
  Setup entry must target an object that can actually serve a `list`, judged
  through the same derivation source the REST gate uses (#3391). It asserts the
  control too — `nav_api_keys` → `sys_api_key` still lists, so a fix that pruned
  both fails.

Not addressed here (reported on #7544): nav gating has no declaration that can
express "prune when the destination cannot serve" — `filterAppForUser` leaves
`requiresObject` to the client and nothing consults `enable.apiEnabled`.

Co-Authored-By: Claude <noreply@anthropic.com>
…not on being unknown (#7544)

Listing `sys_jwks` in the lookup table makes a re-added entry fail the
"cannot serve a list" assertion — the actual reason — instead of falling into
the unclassified branch, which reports only that the test file has never heard
of the object.

Co-Authored-By: Claude <noreply@anthropic.com>
…aviour (#7544)

`builtin-apps-nav-render` — the item whose run produced this card — told the
next runner to confirm `nav_jwks` is "PRESENT for admin". It never could be:
`sys_jwks` is `apiEnabled: false`, so its list answers `OBJECT_API_DISABLED`
for every caller. Left as written, the next run would have filed the removal as
a regression.

The member-side step loses JWKS from its gated-entry list for the same reason:
an entry absent for admins too proves nothing about whether the server prunes
by permission. API Keys and the settings URLs still carry that step.

Co-Authored-By: Claude <noreply@anthropic.com>
@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)
objectstack Ignored Ignored Aug 12, 2026 3:48am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/platform-objects.

2 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/plugins/packages.mdx (via @objectstack/platform-objects)
  • content/docs/ui/setup-app.mdx (via @objectstack/platform-objects)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Copy link
Copy Markdown
Collaborator Author

ACCEPT — domain:metadata seat, PM review. Binds to head 5e63357. ⚠️ Re-verify if the head moves.

The ruling's premise was falsifiable and the dev tested all four halves rather than implementing against it. That is the outcome the three-partition brief exists to produce. Everything below I measured myself on the branch — ⛔ not read off the report.

Review gates, each measured

Gate (set at dispatch) Probe Result
The entry is actually gone, not re-gated id: 'nav_jwks' in setup-nav.contributions.ts main 1 → branch 0 ✅
⭐ CONTROL — nav_api_keys must survive id: 'nav_api_keys', same file main 1 → branch 1 ✅
⛔ The object is untouched git diff on src/objects/** empty ✅
⚠️ The --fill=default trap (#7278/#7309) git diff on *objects.generated.ts empty ✅
…and no English seeded into a non-en bundle + lines in es-ES.ts / ja-JP.ts none — pure deletions ✅
⛔ content/docs/releases/ untouched git diff --name-only empty ✅
Changeset .changeset/setup-nav-jwks-dead-entry.md, platform-objects: patch present ✅
Fixes #7544 is the first body line read directly ✅

⚠️ My own first pass misread this: git grep -c 'nav_jwks' returns 1 on the branch, which looked like a survivor. It is the replacement comment. The declaration form (id: 'nav_jwks') is the discriminating probe, and it is zero. Recording the false positive because the loose grep is the one a later reader will reach for first.

The invariant test earns its place

I checked the one thing that could quietly rot: whether canList reimplements the gate or derives it. It derives — resolveEffectiveApiMethods / isApiOperationAllowed imported from @objectstack/spec/data, the same source apiAccessDenialFromEnable (rest-server.ts:1658) uses, in the same order (apiEnabled === false short-circuit first, whitelist second). It also carries a vacuity control (entries.length > 0, contains nav_api_keys), so the toEqual([]) assertions cannot pass by finding nothing.

The unclassified branch is the part I most want kept: a new nav target this package cannot resolve fails loudly instead of dropping out of coverage. That is what turns a one-instance fix into a standing gate.

Reverse-verification is real and in the right order — fix committed first, then the entry re-added → 3 failed | 131 passed, with the failure message naming the reason (nav entries whose destination answers 4xx on list), not merely the fact. The honest note about the first iteration landing in the unclassified branch is worth more than a clean story.

The two out-of-surface files: both kept, one was load-bearing

You flagged going beyond the named surface and offered to revert. ⛔ Don't.

docs/qa/platform-checklist/areas/platform-core.json 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 #7544 — the fix would have been undone by its own verification procedure. Correcting it is not scope creep; omitting it would have been the defect. content/docs/ui/setup-app.mdx repeated the same stale claim, and the Docs Drift Check flagged exactly that file. I checked the second file it flagged, content/docs/plugins/packages.mdx: zero hits for jwks / signing key, so nothing stale is left there. A repo-wide sweep for "Signing Keys" outside the two edited files returns nothing.

⭐ Both splits are now filed — a sentence in a report is not a card

Standing rule on this seat: the existence test for a split is an issue number. Your two reports are cards, not paragraphs:

Before filing #7912 I re-ran your central measurement independently, because a PM transcribing a dev's mechanism into a card that a third dev implements against is exactly where an unverified claim gets laundered into a specification:

Probe Result
apiEnabled in rest-server.ts 9 — all in the data-access path (:1665, :2468-2505, :10128, :10177, :10423)
…inside filterAppForUser (:2976-3056) 0
requiresObject in rest-server.ts 1 — the docblock line saying it is not gated there

Confirmed. Your point 1 is the one I carried into #7912 as its load-bearing claim: re-pointing the entry at requiresObject would not have pruned it either, so deletion was the only repair available, not merely the one chosen. I filed it as a fork with three readings (make requiresObject server-side / new declaration / derive from objectName with no new key) rather than a task, because overturning the "deliberate and pinned in rest.test.ts" asymmetry is a decision to take in the open.

The #4408 body marks its own mechanism section as ⛔ a PM assumption, not a finding — I have not read that repo's fetch path — and names what to measure first, including whether ADR-0112's code even survives the transport.

Landing

19/24 checks green, 1 skipped, 0 failed; Test Core (3 shards), Dogfood Regression (3/3) and TypeScript Type Check still running. ⛔ in_progress is not green, so auto-merge is armed only once each concludes. Landing is confirmed by a queue branch appearing or a single-parent commit on origin/main — ⛔ never by the auto-merge return value.

Nothing to change. Excellent work — particularly reading the in-code comment as a primary source and finding that it documented an intent the object's own enable block had already made unreachable. That is the finding that explains how the entry survived review, and it would have been invisible to anyone who took the comment at face value.


Generated by Claude Code

@huangyiirene
huangyiirene marked this pull request as ready for review August 12, 2026 03:55
@huangyiirene
huangyiirene added this pull request to the merge queue Aug 12, 2026
Merged via the queue into main with commit 569611f Aug 12, 2026
27 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-7544-jwks-dead-nav branch August 12, 2026 04:16
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 17, 2026
…t serve, and refuse them at authoring time (objectstack-ai#7912) (objectstack-ai#8426)

* fix(rest,lint,spec): prune nav entries whose destination object cannot serve, and refuse them at authoring time (objectstack-ai#7912)

A `type: 'object'` nav entry pointing at an object that cannot answer a list
was served in the `/meta` payload anyway. Two independent conditions make a
destination unservable — `enable.apiEnabled: false` (404) and an
`enable.apiMethods` whitelist without `list` (405) — and both are pure
functions of the object's own `enable` block, so the destination is dead for
every persona. That is why no `requiredPermissions` gate on the entry could
ever prune it.

Per the maintainer ruling of 2026-08-12 (option (c)): derive the fact, mint no
new key.

- `filterAppForUser` consults the destination's `enable` for every
  `type: 'object'` entry, on both `/meta` app routes and inside `children` and
  `areas[]`. Fail-open on unknown objects and unreadable metadata, so
  `requiresObject`'s client-only pin and objectstack-ai#3770 both stand.
- The mandatory companion: `os validate` / `os build` / `os lint` now fail with
  `nav-object-unservable`, naming the entry, the object, the offending `enable`
  key path and which condition fired. The serving side logs the same facts.
- The two-step gate order is declared once as `apiExposureDenialReason` /
  `canServeApiOperation` in `@objectstack/spec/data`. The REST data gate, the
  nav prune and objectstack-ai#7909's invariant test all read that one export instead of
  re-spelling it — the third spelling is what this extraction removes.

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

* chore(spec): regenerate api-surface / export-origins after merging origin/main (objectstack-ai#7912)

Both artifacts were merged without a text merge: this branch and objectstack-ai#8342 each
added exports to `@objectstack/spec/data`, so neither side's baseline covered
the union. Regenerated from the merged tree, after rebuilding dist — the gate
reads the BUILT dist, and on a stale one it reports phantom removals that the
generator would then write.

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

* fix(rest): give the objectstack-ai#7912 nav-servability test's import an explicit .js extension (objectstack-ai#7912)

`check:type-check-debt` went red: @objectstack/rest's TEST_DEBT records 155 raw
tsc errors and the re-measure reported 156 (+1).

Attributed, not assumed. The package-level `typecheck` is genuinely clean —
`packages/rest` is one of the 20 packages whose tsconfig hides its own tests from
tsc, so nothing local can see the test layer. Replaying the ledger's own program
(the package tsconfig with the test globs dropped from `exclude`) gives 156, of
which exactly ONE is in the new file and exactly 155 are not — matching the
recorded number, so nothing pre-existing on main drifted:

  meta-app-nav-servability-gate.test.ts(22,28): error TS2835: Relative import
  paths need explicit file extensions in ECMAScript imports when
  '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './rest-server.js'?

The package resolves NodeNext, so the extensionless spelling the older test
files use is a TS2835 — 67 of them are frozen in the ledger. The fix is the
extension, so a new file does not add the 68th.

⛔ The TEST_DEBT entry was NOT raised, and `scripts/check-type-check-coverage.mjs`
was not touched. The ledger is a ratchet: `--re-measure` now reports "none above
its recorded number / surplus: none — every entry sits exactly at its
measurement".

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants