Skip to content

fix(rest): the API-description endpoints refuse an anonymous caller (#22430) - #22446

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-22430-api-description-anonymous-deny
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-22430-api-description-anonymous-deny

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #22430
Clause-②: no (narrowing)

What changed

RestServer.registerOpenApiEndpoints registers the API-description endpoints: the document and its viewer page, on every base the server mounts. Neither handler checked who was calling. Both now open with the package's existing anonymous-deny floor (enforceAuth, which asks shouldDenyAnonymous from @objectstack/core). The check runs first, before the bundled artifact is loaded or a protocol is resolved, so a refused request does no work.

  • An anonymous caller gets 401 with code UNAUTHENTICATED. The body is ANONYMOUS_DENY_BODY, byte-identical to the anonymous 401 the data routes answer.
  • A signed-in caller is served as before.
  • A session held by the ADR-0069 auth-policy gate (an expired password, enforced MFA) gets that gate's 403, as on every other protected route.
  • No new error code, no spec key: Clause-②: no.

This executes ruling 6074960686 on #22146, item 2, verbatim:

  1. The API-description endpoints refuse an anonymous caller (401).

Measured first: a real boot, before and after

pnpm dev -- --fresh (the showcase) on this branch, before the fix (base b9222dc70) and after it. Statuses only. "Gated" is a member created by the admin, still under the must-change-password policy, probed before the password change. "Member" is the same account after it.

endpoint caller before after
the document, unscoped base anonymous 200 (served) 401 UNAUTHENTICATED
the document, unscoped base gated session 200 (served) 403 PASSWORD_EXPIRED
the document, unscoped base signed-in member 200 200
the document, unscoped base signed-in admin 200 200
the viewer, unscoped base anonymous 200 (page) 401 UNAUTHENTICATED
the viewer, unscoped base gated session 200 (page) 403 PASSWORD_EXPIRED
the viewer, unscoped base signed-in member 200 200
the viewer, unscoped base signed-in admin 200 200
either, environment-scoped base every caller 404 (not mounted) 404 (not mounted)
control: a data route anonymous / gated / member 401 / 403 / 200 401 / 403 / 200

The environment-scoped base is registered only under project scoping, and the showcase does not enable it. So on this boot both bases answer the same way for the scoped twin: it is not mounted. The scoped twin runs the same handler closures. It is measured at the handler level in rest-api-description-anonymous-deny.test.ts, for both bases. Against the base rest-server.ts, all four anonymous cells answer 200, and the test turns red.

The viewer carries the session (H4)

The viewer is an HTML page whose script fetches the document from the browser. I measured it in Chromium with the real viewer bundle. The CDN the page names is unreachable from this container, so the browser was served the same package (@scalar/api-reference 1.73.1) from its npm tarball.

  • Before: anonymous navigation 200; the viewer fetched the document with no cookie and got 200.
  • After, anonymous: navigation 401; the viewer never runs.
  • After, signed in (cookie session): navigation 200; the viewer's fetch of the document carried the session cookie and got 200.

A signed-in browser is served on both endpoints, as before.

The ruling's confidence gap: an anonymous consumer in the tree?

Not found. I searched objectstack at this branch, objectui origin/main 049012bf, and the objectui pin a58626c88 for anything that fetches the document or the viewer.

  • @objectstack/client, the CLI, scripts/, .github/, examples/, apps/docs (its reference pages are generated from the Zod schemas, not from the served document): zero hits.
  • objectui apps/** and packages/**, at both commits: zero fetches. The only openapi.json mentions are the @objectstack/spec package-export name in build tooling and its tests. The Console's /docs routes are its own book portal, not this viewer.
  • One reader of the served document exists in the tree: showcase-declarative-endpoints.dogfood.test.ts. It reads it with an admin token, so it is unaffected.
  • The platform checklist's route-parity item probes the document "as admin", so it is unaffected.
  • /discovery does not advertise the document's URL.

A deployed public API portal outside this repository is NOT MEASURED.

Translation-flip sweep

Pins and prose that held the old meaning, all in this PR:

  • packages/rest/src/openapi-builtin-paths.ts: the coverage note listed these endpoints among "the routes that answer anonymously". It now names only the discovery routes and says the inherited document-level requirement is true of these two. A new case pins that neither operation carries its own security, and that the document states a requirement.
  • Four rest suites drove the document with no session and read a 200: rest-openapi-route, rest-openapi-info-overlay, direct-mount-introspection and direct-mount-base-follows-apipath. A fifth, rest-endpoint-surfaces-served-only, was already signed in. The four test the document's contents, so they now read it as a signed-in caller. The refusal itself is asserted in the new suite: status, code, the whole body, no document keys, no page, and no artifact load or protocol read.
  • execctx-consumer-census.test.ts: 66 to 68 sites, 45 to 47 bare. Both new sites are bare, with the shared floor on the next line. Its §3 drives them: an absent context gets 401 UNAUTHENTICATED, an entitled one clears the floor.
  • Authorization matrix: the openapi REST family moves from the shrink-only unclassified baseline (32 to 31) to a new enforced row, anonymous-deny-api-description. Its cited proof is showcase-anonymous-deny-surfaces.dogfood.test.ts, which now drives both endpoints on the booted showcase. Anonymous gets 401, the REST flat envelope, the whole ANONYMOUS_DENY_BODY, nothing served. A signed-in member gets 200 for the document and 200 for the viewer page. Both endpoints joined the envelope-family table. The population pin in authz-conformance.test.ts lists the family as classified.
  • The authz probe blind-spot census (authz-probe-blind-spot.census.ts and its test), re-measured from deriveProbeFileCensus() on the tree, as in the /analytics precedent:
    • PROBE_TABLE moves from { entries: 19, files: 14, keys: 17 } to { 19, 14, 18 }. The new row covers one more key; no probe and no file joined.
    • The rest-server.ts control enforceAuth moves from 56 to 58: the two handlers' call sites, with no prose mention.
    • Population, reach and blind spot re-derive unchanged at 71 / 19 / 52, and every other control is unchanged. Guarding a route is not reaching it with a key, so the blind spot does not shrink.
    • The test's count moves from 17 to 18 covers keys. The ledgers still mint 39 keys: 8 classified, 31 baselined.
    • The rest-route-ledger.ts row's note now says 2 families classified (metadata and openapi) and 16 baselined.

Tests and gates (at d1cc56281)

  • Reverse verification, unit level: the new suite against the base rest-server.ts. 7 failed and 4 passed. The 4 anonymous cells read expected 200 to be 401. The signed-in controls passed. Restored to the HEAD blob, git diff HEAD empty. After the fix: 13 of 13.
  • Ablation through dist/ (scripts/ablation-replace.mjs + ablation-dist-preflight.mjs): I removed only the document's floor, rebuilt @objectstack/rest, and confirmed the marker in both built files. The booted-showcase proof then failed exactly its two document-anonymous cases (2 failed / 65 passed); the viewer cases and the controls stayed green. Restored to the HEAD blob, rebuilt, and confirmed the marker absent from dist/.
  • @objectstack/rest, at 1d4ad614f: the full suite gave 271 files, 5164 passed, 327 skipped; typecheck passed (tests are covered through tsconfig.test.json, 6 of 6 touched files). Nothing under packages/rest changed after that commit.
  • @objectstack/dogfood, the WHOLE suite, through the verify lock: 233 files (232 passed, 1 skipped), 1847 tests (1838 passed, 9 skipped), exit 0. It ran at 0c0b1f7e5; the next commit, d1cc56281, changes only a string and a comment in the census note. At d1cc56281, authz-probe-blind-spot.test.ts + authz-conformance.test.ts passed 90 of 90.
  • pnpm check:doc-authoring exits 0 at d1cc56281. It was red at 0c0b1f7e5 on a tracker id inside the census note's string prose. The id now sits in an adjacent comment.
  • dispatch-gates.mjs --commands derives the same 69 commands at d1cc56281, and all 69 exited 0 there. Three gates read built output and first answered PREREQUISITE NOT MET (exit 3) in the fresh worktree: check:dual-build-cjs-loads, check:dts-closure and check:sourcemap-no-sources-content (the last two swept 63 and 60 packages). I built the eight packages that had no dist/ (all turbo cache hits) and re-ran them: exit 0, sweeping 66, 71 and 68 packages. --ran reconciles 69 derived, 69 run, 0 not measured.
  • pnpm lint (the full run) exited 0 at d1cc56281. @objectstack/dogfood typecheck exited 0 at d1cc56281.

Acceptance notes

  • Operators who served the API description publicly: readers must now sign in. Otherwise, publish a static copy of the document. The changeset says so.
  • Not in this change, by the ruling's scope: /discovery still answers anonymously, as before. On the environment-scoped twin, a signed-in caller is judged by the auth service of the environment the URL names. These handlers do not add the ownership comparison the UI-view route makes. I did not measure that, because the showcase mounts no scoped base. It is a question for whoever re-measures the everything-else class for ADR-0138.
  • Observation: the viewer page loads its script from a CDN with no version pinned, on the API's own origin. That was true before this change and is unchanged by it.
  • Left as is: the ledger rows for this family in rest-route-ledger.ts could carry the optional authz: 'anonymous-deny-api-description' field. I left that file alone because another hold covers its notes.

Generated by Claude Code

claude added 6 commits October 9, 2026 07:41
RestServer.registerOpenApiEndpoints now opens both handlers (the document
and its viewer) with the shared anonymous-deny floor, before any work, so
an anonymous caller gets the same 401 UNAUTHENTICATED body the /data and
/meta routes answer. A signed-in caller is served as before.

Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS
Co-authored-by: Claude <noreply@anthropic.com>
…signed-in service

Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS
Co-authored-by: Claude <noreply@anthropic.com>
… two new bare identity sites

Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS
Co-authored-by: Claude <noreply@anthropic.com>
…us refusal on the booted showcase

Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS
Co-authored-by: Claude <noreply@anthropic.com>
…aller; pin the inherited security statement

Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 9, 2026
@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/rest/src/openapi-builtin-paths.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/rest/src/openapi-builtin-paths.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: /api/v1 (route, 91 pages)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 3ca71b6e05efbfc6ec5908c8c263fee6cceba389 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from a5a74ae43a7c8906d4846cc22dc77adcd5ef493a — the merge of head 9aaebffb35891b6eb4d170fad3610550521af398 into base 3ca71b6e05efbfc6ec5908c8c263fee6cceba389, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a5a74ae43a7c8906d4846cc22dc77adcd5ef493a && git checkout a5a74ae43a7c8906d4846cc22dc77adcd5ef493a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3ca71b6e05efbfc6ec5908c8c263fee6cceba389 9aaebffb35891b6eb4d170fad3610550521af398 && git checkout -B drift-repro 3ca71b6e05efbfc6ec5908c8c263fee6cceba389 && git merge --no-ff 9aaebffb35891b6eb4d170fad3610550521af398

node scripts/docs-audit/affected-docs.mjs --json 3ca71b6e05efbfc6ec5908c8c263fee6cceba389

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

…PI-description family

The anonymous-deny-api-description row covers one more key (17 -> 18
covers keys; 8 ledger keys classified, 31 baselined), and the two
API-description handlers add two enforceAuth call sites to
rest-server.ts (56 -> 58). Population, reach and blind spot re-derive
unchanged (71 / 19 / 52).

Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS
Co-authored-by: Claude <noreply@anthropic.com>
The rest-route-ledger row's note named the issue inside a string
literal, which check:doc-authoring refuses in sibling-package prose.
The anchor moves to an adjacent comment; the note keeps its counts.

Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS
Co-authored-by: Claude <noreply@anthropic.com>
…nor, breaking)

Closing a door that served an anonymous caller narrows what the door
accepts: the changeset becomes minor with the breaking mark, declares
Clause-② no (narrowing), and carries its ADR-0087 disposition.

Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 9, 2026 11:32
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 9, 2026 11:32
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 9, 2026
Merged via the queue into main with commit 166a94f Oct 9, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22430-api-description-anonymous-deny branch October 9, 2026 11:49
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

2 participants