Skip to content

fix(cors)!: one CORS admission rule at every door — createHonoApp's default, loopback beside a list, all-host patterns, auth's trusted origins - #24873

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-24809-cors-family-closure
Oct 21, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-24809-cors-family-closure

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #24809
Clause-②: yes (narrowing)

What changes

Every door in this repository that mounts CORS or reads OS_CORS_ORIGIN now gives one configuration one answer. The governing text is docs/HARDENING.md § CORS: "Do not use origin: '*' with credentials: true", and the go-live checklist's "CORS origin list is explicit, not *". #24738's Expected adds: "Localhost stays admitted in dev only, if at all." This PR closes the family #24738 (PR #24806) opened, at the four locations the card and its comment 6288561947 name.

  • One rule. resolveCorsAdmission (new, packages/plugins/plugin-hono-server/src/cors-admission.ts) decides what a transport door admits from the door's own options and the OS_CORS_* environment. HonoServerPlugin reads it in init(). @objectstack/hono's createHonoApp reads it when the app is created. Each door only maps its own option names (cors.origins and cors.origin) onto it, and nothing is copied.
  • Location 1, createHonoApp. Its old default was the combination security: the HTTP server's default CORS answer admits every origin for credentialed requests (detail withheld under the disclosure ruling) #24738 removed from HonoServerPlugin: with no list, every origin was admitted with credentials, and '*' with credentials on was accepted. It now answers exactly as HonoServerPlugin does.
  • Location 2, loopback beside a list. matchOriginPattern admitted every loopback origin ahead of any pattern, in every mode. It now matches the pattern and nothing else. createOriginMatcher admits a loopback origin beside a non-empty list on a development boot only.
  • Location 3, all-host patterns. isAllHostOriginPattern (new) names '*' and a pattern whose host part is the bare *: https://*, *://*, http://*:*, https://*:8443. With credentials on, any such entry is refused exactly as '*' is, and the message names the entry.
  • Location 4, auth's trusted origins. auth-manager.ts tested the whole OS_CORS_ORIGIN value against '*' and split it afterwards. It now judges each entry, and an entry that admits every host never enters better-auth's trusted origins. Only that block is touched. OS_TRUSTED_ORIGINS, the preview and root-domain wildcards, SSO discovery and the development substitution are unchanged.
  • The enumeration pin. packages/qa/http-conformance/src/cors-doors.conformance.test.ts lists the three doors and asks each one the same nine configurations.

The development boot is isDevelopmentBoot from @objectstack/core (NODE_ENV exactly development), the reading PR #24806 uses. The rule reads it once and passes it to the matcher. The matcher takes it as an optional { developmentBoot }. When that option is omitted, the matcher reads the same signal when it is created. That default is deliberate. The cloud's serverless preflight short-circuit imports createOriginMatcher with one argument, so it now follows the matcher's new answer and agrees with the transport on the same boot without a change there. An option that defaulted to false would have made that preflight refuse loopback on a development boot while the transport admitted it, which is the preflight-versus-request divergence the module header records. The cross-repo consumer is NOT MEASURED from here.

Contract

Every input whose answer changes. "Admitted" means the origin is echoed in Access-Control-Allow-Origin. "Development boot" means NODE_ENV is exactly development.

Door Input Before After
createHonoApp No list, credentials on (the default), not a development boot every origin admitted, with credentials no cross-origin caller admitted
createHonoApp No list, credentials on, development boot every origin admitted, with credentials loopback origins only, with credentials
createHonoApp No list, OS_CORS_CREDENTIALS=false, not a development boot every origin admitted, without credentials no cross-origin caller admitted
createHonoApp No list, OS_CORS_CREDENTIALS=false, development boot every origin admitted, without credentials loopback origins only, without credentials
createHonoApp A list holding * (option or OS_CORS_ORIGIN, alone or among other entries), credentials on created; every origin admitted, with credentials createHonoApp throws
HonoServerPlugin, createHonoApp A list holding a pattern whose host part is the bare *, credentials on booted; every host the pattern matches admitted, with credentials init() throws and the boot fails, or createHonoApp throws
HonoServerPlugin, createHonoApp A list with at least one pattern, not a development boot the list, plus every loopback origin the list only
HonoServerPlugin A list of exact origins only, not a development boot the list, plus every loopback origin the list only
createHonoApp A list of exact origins only, development boot the list only the list, plus loopback origins (a gain, development boot only)
createHonoApp The cors.origin option as one comma-separated string of exact origins (no *), credentials on or off no origin admitted (OS_CORS_ORIGIN written the same way was already split, and is unchanged) each listed origin admitted, and on a development boot loopback origins too, as in the row above (a widening under the yes line, only toward what the operator listed, which the option's TSDoc already promised: "Comma-separated string or array"; the one exception to the exact-origin line under "Unchanged")
AuthManager (plugin-auth) OS_CORS_ORIGIN holding * among other entries * entered better-auth's trusted origins * is dropped; the other entries are trusted as before
AuthManager OS_CORS_ORIGIN of * written with surrounding space * entered the trusted origins nothing enters from it
AuthManager OS_CORS_ORIGIN holding a pattern whose host part is the bare * the pattern entered the trusted origins the pattern is dropped

Unchanged:

  • An exact origin, or a pattern that names part of the host (https://*.example.com, http://localhost:*), is admitted as before, with or without credentials, at both transport doors, and auth trusts it as before.
  • On a development boot, HonoServerPlugin admits loopback origins beside a list as before. With no list, HonoServerPlugin answers as security: the HTTP server's default CORS answer admits every origin for credentialed requests (detail withheld under the disclosure ruling) #24738 left it.
  • *, or an all-host pattern, with credentials off still boots and admits without credentials.
  • An empty list (cors.origins: [], or OS_CORS_ORIGIN of whitespace) admits nothing, loopback included, in every mode, as before.
  • cors: false, cors.enabled: false and OS_CORS_ENABLED=false still mount nothing. Allowed and exposed headers, methods and max-age are unchanged. Preflights are still answered 204, admitted or not.
  • Same-origin requests are outside CORS, so the Console and every page the server serves are unaffected. OS_TRUSTED_ORIGINS admits nothing to CORS (ruling A, 6288093770).

Public surface added (outside the table). @objectstack/plugin-hono-server gains these exports so that one rule is shared by two published doors, HonoServerPlugin and createHonoApp. They, and the createHonoApp development-boot row above, are why the Clause-② line reads yes:

  • resolveCorsAdmission, with its types CorsAdmission, CorsAdmissionInput and CorsAdmissionDoor;
  • isAllHostOriginPattern;
  • OriginMatcherOptions, the type of createOriginMatcher's new optional second parameter.

The refusal message now names the entry, and createHonoApp gains the refusal. Nothing is removed or renamed, and no spec key moves.

Mechanism (measured)

  • Before-state, per location. The new pins were committed first, as a9c5f629a (base b3fc122f0 plus the test files only), and run against that tree's source. plugin-hono-server and @objectstack/hono were run from source, and http-conformance from a dist/ built at that commit:
    • plugin-hono-server src/cors-default-admission.test.ts: Tests 5 failed | 15 passed (20). In each failure, a loopback origin beside a list was admitted outside development, or an all-host pattern with credentials booted.
    • @objectstack/hono src/hono-cors-admission.test.ts: Tests 7 failed | 3 passed (10). A foreign origin was echoed with no list, a loopback origin beside an exact list was not admitted on a development boot, and createHonoApp did not throw for * or an all-host pattern with credentials.
    • plugin-auth src/auth-manager.cors-origin-trust.test.ts: Tests 3 failed | 1 passed (4). In each failure, the trusted origins held * or an all-host pattern.
    • http-conformance src/cors-doors.conformance.test.ts: Tests 11 failed | 7 passed (18).
    • After the fix, all four files passed (see Tests).
  • What better-auth does with an all-host entry. The card's mechanism note said this was not verified. It was measured on the installed better-auth 1.7.3, using matchesOriginPattern in dist/auth/trusted-origins.mjs. better-auth reads https://* and *://* each as matching every origin of their scheme, as it reads * (and https://*.* nearly so). So an all-host pattern in the trusted set has the same effect as *, and auth drops the same narrow class the transport refuses. Auth does not depend on plugin-hono-server, so its block carries a one-line predicate of the same class. The enumeration pin holds the two equal. No other non-origin entry is judged.
  • "Host part is the bare *", defined narrowly. It is the regular expression in isAllHostOriginPattern: an optional scheme://, then only * characters, then an optional :port or :*. A pattern that names any part of the host is not in the class, including https://*.*. No general pattern validator is added.
  • Location 2's consumers in this repo. Two places relied on loopback beside a list outside development, and both were the matcher's own unit tests in pattern-matcher.test.ts ("should always allow localhost origins regardless of configured patterns", and 127.0.0.1 beside http://localhost:*). They pinned the old convenience, and they now pin the development-only rule. No other test, example or shipped code relies on it:
    • the only Access-Control-Allow-Origin assertions in the repo are @objectstack/hono's hono.test.ts, whose loopback case names http://localhost:* in its own list, and plugin-hono-server's pin file;
    • no example configures a CORS list;
    • objectui's console reaches the API same-origin (its Vite /api proxy in dev, and VITE_SERVER_URL empty in both .env files; read-only, ../objectui at cd4623d06).
  • The doors. A grep of packages/ for non-test readers of OS_CORS_ORIGIN and importers of hono/cors returns three doors: hono-plugin.ts, adapters/hono/src/index.ts and auth-manager.ts. No other adapter mounts CORS.

Tests

All runs are on fb509fc0a (this branch merged with origin/main 9a06f0be0) unless a line names another commit. bb2fde296, the head this PR opens with, differs from it only in the changeset's text. The pins, by what they assert:

  • plugin-hono-server src/cors-default-admission.test.ts, describes 5 and 6:
    • outside development, a list admits what it names and no loopback origin, whether the list comes from the option or from OS_CORS_ORIGIN;
    • on a development boot, a loopback origin is admitted beside the list;
    • a list that names http://localhost:* admits it in every mode, and nothing else;
    • each all-host pattern, alone or in a list, by option or by env, is refused in init(), and the message names the entry and both remedies;
    • an all-host pattern without credentials boots;
    • https://*.example.com boots with credentials.
  • plugin-hono-server src/pattern-matcher.test.ts:
    • matchOriginPattern matches the pattern only;
    • createOriginMatcher admits loopback beside a list on a development boot only, and an empty list admits nothing;
    • the default development reading is NODE_ENV exactly development, read at creation;
    • isAllHostOriginPattern names the class and not its neighbours.
  • @objectstack/hono src/hono-cors-admission.test.ts: createHonoApp answers rows 1 to 3 of the plugin's pin file the same way, with its refusal thrown when the app is created.
  • plugin-auth src/auth-manager.cors-origin-trust.test.ts, read off a real better-auth instance:
    • a * entry among real origins, a spaced *, and each all-host pattern never enter the trusted origins;
    • the real origins and a host-naming pattern do.
  • http-conformance src/cors-doors.conformance.test.ts, for each of nine configurations:
    • both transport doors give the same answer (refused, or the same admitted probe origins and credentials flag), and it is the row's answer;
    • auth trusts exactly the row's listed entries, and never * or https://*.

Suite runs, each command exiting 0:

  • pnpm --filter @objectstack/plugin-hono-server test: 35 files, 448 tests passed. typecheck passed.
  • pnpm --filter @objectstack/hono test: 12 files, 225 tests passed. typecheck passed.
  • pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2: 171 files, 3264 passed and 10 skipped. typecheck passed.
  • pnpm --filter @objectstack/http-conformance exec vitest run --maxWorkers=2: 19 files, 246 tests passed. typecheck passed.
  • Each new test file is listed by its package's tsc -p tsconfig.test.json --listFiles.

Gates

  • node scripts/pm/dispatch-gates.mjs --commands derived 85 families for this diff on fb509fc0a, and all 85 ran.
    • 84 exited 0.
    • pnpm check:dual-build-cjs-loads exited 3 with PREREQUISITE NOT MET. It reads every package's built output, and this worktree built only the closure it tests. So it is NOT MEASURED locally, and CI's Lint & Repo Gates runs it.
    • --ran reconciliation, with every exit code recorded: 85 derived, 84 run, 1 NOT-MEASURED, 0 UNRUN.
  • The changeset family was re-run on bb2fde296, after the changeset's last edit. Each of these exited 0:
    • check-adr-0087-registration.mjs --base origin/main, with the no-migration-prescription exemption noticed;
    • check-changeset-no-major.mjs --base origin/main;
    • check-empty-changeset.mjs --base origin/main;
    • check-changeset-fixed.mjs;
    • check:doc-authoring.
  • pnpm lint on bb2fde296: exit 0.
  • check-platform-checklist.mjs --changed-from was not run, because no exported symbol is renamed or removed.
  • CI check-runs were pending when this was written.

Changeset

.changeset/24809-cors-doors-one-admission-rule.md bumps @objectstack/plugin-hono-server, @objectstack/hono and @objectstack/plugin-auth as minor (the fixed group; on main it is at 18.0.0, versioned in 06cab4849 and tagged, and .changeset/pre.json is gone, so no pre-release tag applies). It is marked BREAKING: an accept-set narrowing shipped as minor under the launch-window convention. It carries the Clause-② line and the ADR-0087 disposition not-required (no-migration-prescription), the same form #24738's changeset uses. Its before and after bullets match the Contract table, and it names the development-boot gain at createHonoApp. @objectstack/http-conformance is private and gains only a test, a devDependency on @objectstack/plugin-auth and a source alias for it. 18.0.0 was versioned after this branch's merge base, so the next version pass consumes this changeset, not 18.0.0; landing waits on the maintainer's word.

Acceptance notes

  • Release. 18.0.0 is versioned (06cab4849, chore: version packages) and tagged, after this branch's merge base, so this changeset is consumed by the next version pass. Landing waits on the maintainer's word (the seat's ACCEPT 6291362699, item 2), which also covers whether 18.0.0 closed the launch window the minor grade rests on; until then minor with the BREAKING banner is the form the gate enforces on main. Built as if it lands on main now.
  • The roster is declared, not discovered. The enumeration pin lists the three doors measured at the base. A new door is caught only when it is added to DOORS. Discovering doors by scanning every package's source would need a cross-package input radius over all of packages/**/src (check:cross-package-test-inputs), and that would put this suite on every PR. That is a new gate, so it is not added here.
  • content/docs/deployment/environment-variables.mdx (OS_CORS_ORIGIN row) says "* is refused at boot unless OS_CORS_CREDENTIALS=false". That is still true, but it does not name the all-host patterns. It is outside this claim's docs face. The cli.mdx "(CORS)" line is held off as before.
  • A stale comment inside the auth block. "When CORS allows all origins (default)" above the development substitution predates security: the HTTP server's default CORS answer admits every origin for credentialed requests (detail withheld under the disclosure ruling) #24738. That behaviour is held off, so the comment is left as it is.
  • The wildcard note in serve.ts (about :4182) says better-auth's glob does not cross dot separators. Measured on 1.7.3, it does: https://*.example.com matches https://a.b.example.com. This is pre-existing, outside this diff, and a docs precision at most.
  • https://*.* matches nearly every public host at both matchers. It is outside the narrow class by design, and is reported here, not refused.
  • Cloud. The preflight short-circuit follows createOriginMatcher's new answer. A cloud deployment that relied on loopback beside its list outside development names those origins. NOT MEASURED from here.

Generated by Claude Code

… fix

The pins this card's fix turns green, committed first so each one can be read
red against the base's source:

- plugin-hono-server: a configured list admits a loopback origin beside it on
  a development boot only, and a pattern whose host part is the bare `*` is
  refused with credentials as `'*'` is;
- @objectstack/hono: createHonoApp answers the same configurations the same
  way as HonoServerPlugin;
- plugin-auth: OS_CORS_ORIGIN entries reach better-auth's trusted origins one
  by one, and an entry that admits every host never does;
- http-conformance: every door that mounts CORS or reads OS_CORS_ORIGIN is
  listed and asked the same rows (it gains a plugin-auth devDependency and a
  source alias for that door).

Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6
Co-authored-by: Claude <noreply@anthropic.com>
… list on a development boot only; an all-host pattern refused with credentials

`docs/HARDENING.md` § CORS governs: never `'*'` with credentials, and an
explicit origin list.

- plugin-hono-server: `resolveCorsAdmission` (new, `cors-admission.ts`) is the
  one rule. `HonoServerPlugin` reads it in `init()`. `createOriginMatcher`
  admits a loopback origin beside a list on a development boot only
  (`isDevelopmentBoot`, read when the matcher is created unless passed), and
  `matchOriginPattern` matches the pattern and nothing else.
  `isAllHostOriginPattern` (new) names `'*'` and a pattern whose host part is
  the bare `*`; with credentials on, any such entry is refused, as `'*'` was.
- @objectstack/hono: `createHonoApp` reads the same rule, so with no list it
  admits no cross-origin caller, and it refuses the same configurations when
  the app is created.
- plugin-auth: `OS_CORS_ORIGIN` entries reach better-auth's trusted origins
  one by one; an entry that admits every host never does.

Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6
Co-authored-by: Claude <noreply@anthropic.com>
…e one admission rule as shipped

- HARDENING § CORS: the list row says loopback is admitted beside a list on a
  development boot only; the refusal row and the closing paragraph name the
  all-host patterns and both doors; the createHonoApp paragraph says the
  adapter answers by the same rule.
- plugin-hono-server README: the same, and where the rule is exported.
- @objectstack/hono README: the CORS defaults (no list, credentials on) and
  the shared rule.
- One changeset for the three published packages, minor, breaking, with the
  Clause-② line and the ADR-0087 disposition.

Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6
Co-authored-by: Claude <noreply@anthropic.com>
An exact-origin list at createHonoApp never admitted a loopback origin; under
the shared rule it does on a development boot, as HonoServerPlugin already
did. The changeset says so.

Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Oct 20, 2026
@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/hono, @objectstack/plugin-auth, @objectstack/plugin-hono-server, @objectstack/http-conformance, touching 23 documentable anchor(s). ⚠️ 4 changed file(s) yielded no anchor (packages/adapters/hono/README.md, packages/plugins/plugin-hono-server/README.md, packages/plugins/plugin-hono-server/src/index.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/index.mdx (via createHonoApp (symbol, a top-level function))
  • content/docs/api/wire-format.mdx (via createHonoApp (symbol, a top-level function))
  • content/docs/deployment/environment-variables.mdx (via CORS_CREDENTIALS (literal, a string literal in HonoServerPlugin; a string literal in createHonoApp; a string literal in resolveCorsAdmission), CORS_ORIGIN (literal, a string literal in HonoServerPlugin; a string literal in createHonoApp; a string literal in resolveCorsAdmission), OS_CORS_CREDENTIALS (literal, a string literal in HonoServerPlugin; a string literal in createHonoApp; a string literal in resolveCorsAdmission), OS_CORS_ORIGIN (literal, a string literal in HonoServerPlugin; a string literal in createHonoApp; a string literal in resolveCorsAdmission))
  • content/docs/deployment/production-readiness.mdx (via OS_CORS_ORIGIN (literal, a string literal in HonoServerPlugin; a string literal in createHonoApp; a string literal in resolveCorsAdmission))
  • content/docs/deployment/self-hosting.mdx (via OS_CORS_ORIGIN (literal, a string literal in HonoServerPlugin; a string literal in createHonoApp; a string literal in resolveCorsAdmission))
  • content/docs/permissions/authentication.mdx (via HonoServerPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/error-handling.mdx (via createHonoApp (symbol, a top-level function))
  • content/docs/protocol/kernel/index.mdx (via HonoServerPlugin (symbol, a top-level class))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via HonoServerPlugin (symbol, a top-level class))
  • content/docs/releases/v17/17-4.mdx (via createHonoApp (symbol, a top-level function))
  • content/docs/releases/v17/17-5.mdx (via createHonoApp (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/adapters/hono/README.md, packages/plugins/plugin-hono-server/README.md, packages/plugins/plugin-hono-server/src/index.ts, …) — pages documenting those are invisible to this run
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 22 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 fcb40ad673a08b231d34190a60d692f462c5df23 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from e83c8e2e4903ddfe8860e1f92ef796c66bbae630 — the merge of head 0ffd23e64de690718b939fe22ef8898c4c13f05f into base fcb40ad673a08b231d34190a60d692f462c5df23, 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 e83c8e2e4903ddfe8860e1f92ef796c66bbae630 && git checkout e83c8e2e4903ddfe8860e1f92ef796c66bbae630
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fcb40ad673a08b231d34190a60d692f462c5df23 0ffd23e64de690718b939fe22ef8898c4c13f05f && git checkout -B drift-repro fcb40ad673a08b231d34190a60d692f462c5df23 && git merge --no-ff 0ffd23e64de690718b939fe22ef8898c4c13f05f

node scripts/docs-audit/affected-docs.mjs --json fcb40ad673a08b231d34190a60d692f462c5df23

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs fcb40ad673a08b231d34190a60d692f462c5df23 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…ed exports are named

The seat's answer to the dev report's first open question: the diff adds
exports-addressable symbols to @objectstack/plugin-hono-server, so the line
reads `yes (narrowing)`. The changeset now names them and why they exist: one
rule shared by two published doors. The level stays minor and breaking.

Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: df7d1e1fd43c9a6aaa98729fc30ef8bbd3f1b17d
Local-runs: none
Scope: packages/plugins/plugin-hono-server/src/cors-admission.ts, packages/plugins/plugin-hono-server/src/pattern-matcher.ts, packages/plugins/plugin-hono-server/src/hono-plugin.ts, packages/plugins/plugin-hono-server/src/adapter.ts, packages/plugins/plugin-hono-server/src/index.ts, packages/adapters/hono/src/index.ts, packages/plugins/plugin-auth/src/auth-manager.ts, packages/plugins/plugin-hono-server/src/cors-default-admission.test.ts, packages/plugins/plugin-hono-server/src/pattern-matcher.test.ts, packages/adapters/hono/src/hono-cors-admission.test.ts, packages/plugins/plugin-auth/src/auth-manager.cors-origin-trust.test.ts, packages/qa/http-conformance/src/cors-doors.conformance.test.ts, packages/qa/http-conformance/vitest.config.ts, packages/qa/http-conformance/package.json, pnpm-lock.yaml (the whole diff outside the prose faces; record-recognisers.mjs --paths decided no face from the paths and directed --face narrowing, which this record uses) + packages/core/src/stack-auth.ts (isDevelopmentBoot, :135-137), packages/types/src/env.ts (readEnvWithDeprecation), packages/plugins/plugin-hono-server/package.json (exports), packages/plugins/plugin-auth/package.json, packages/adapters/hono/package.json, packages/adapters/hono/vitest.config.ts, content/docs/deployment/environment-variables.mdx (the OS_CORS_ORIGIN and OS_CORS_CREDENTIALS rows, untouched by the PR), scripts/check-changeset-no-major.mjs, scripts/check-adr-0087-registration.mjs, .changeset/config.json, .changeset/pre.json (at the merge-base; absent on origin/main), docs/adr/ (grepped: no ADR names CORS admission, OS_CORS_*, the matcher or trustedOrigins), better-auth 1.7.3 dist/auth/trusted-origins.mjs and dist/utils/wildcard.mjs (the version plugin-auth pins; this checkout has no node_modules, so they were read from an installed copy of the same pinned version in a sibling worktree's node_modules); the four prose faces (.changeset/24809-cors-doors-one-admission-rule.md, docs/HARDENING.md § CORS, packages/adapters/hono/README.md, packages/plugins/plugin-hono-server/README.md) read sentence by sentence against the code. The cloud's serverless preflight short-circuit lives in objectstack-ai/cloud and cannot be read from this repository: NOT MEASURED.

Net diff read as merge-base 9a06f0be0 to the head: 19 files, +1005/-161. The head differs from the tree the dev tested (fb509fc0a) in the changeset only (+3/-3), and bb2fde296 is the same tree with an earlier changeset text. origin/main (b1676441b) is 9 commits past the merge-base; none of them moves any of the PR's 19 paths, and GitHub reports the PR mergeable. One of those commits matters to ② and ③: 06cab4849 (chore: version packages, PR #24524) sets @objectstack/plugin-hono-server, @objectstack/hono and @objectstack/plugin-auth to 18.0.0, deletes .changeset/pre.json (which was mode: exit, tag next, at the merge-base) and consumes .changeset/24738-cors-default-no-cross-origin-caller.md into the 18.0.0 CHANGELOG entries.

① Derived judgments

Disclosure (security card, ruling on #9387, comment 5323310017).

  • The PR's artifacts are its title, body, the changeset, the three doc faces, the code comments in cors-admission.ts, pattern-matcher.ts, hono-plugin.ts, adapter.ts, adapters/hono/src/index.ts and auth-manager.ts, and the five commit messages.
  • They state what the ruling allows and nothing further: that each of the four locations exists (an older default at createHonoApp, loopback beside a list in every mode, a literal-only refusal, a whole-string test in auth's merge), where each lives (file and block), and the governing text (docs/HARDENING.md § CORS, the go-live checklist line, security: the HTTP server's default CORS answer admits every origin for credentialed requests (detail withheld under the disclosure ruling) #24738's Expected). The Contract table names configurations and what a door answers; it gives no request sequence against a shipped endpoint and no description of what a foreign page could obtain.
  • The "Mechanism (measured)" section describes how better-auth's matcher reads a pattern class. That is the location of the auth defect, not a path through it.
  • The acceptance note on https://*.* names an operator-written pattern class that is not refused. The previous record on this family (6285870503, ③ item 7) treated the same kind of statement about https://* as within the ruling, and this record reads it the same way: existence and location of an operator-configured class, no exploit path.
  • The four new pin files drive a probe route of their own through app.request and read one response header, or read options.trustedOrigins off a real better-auth instance. They are the fix's evidence, as the claim foresaw, not a reproduction against a deployment.
  • The code comments removed from hono-plugin.ts and adapters/hono/src/index.ts carried the only sentences that described the old behaviour as a mechanism; the replacements describe the rule.
  • The PR keeps to the ruling. This record keeps to it as well: every condition below is a configuration and a file location.

Check-runs on the head (latest run per name, 41 names, read 2026-10-20T12:45:56Z).

  • 27 are completed/success and 8 are completed/skipped (opt-in, live-DB, Console Pin Gate, the dogfood matrix shards, Auto Label, Check PR Size).
  • Of the five required contexts: Lint & Repo Gates, TypeScript Type Check, Build Core and Governed Surface Queue Guard concluded success. Test Core is NOT MEASURED: all six shards (1/6) to (6/6) were still in_progress at render time. Never a pass.
  • Check Changeset concluded success. That job runs check-adr-0087-registration.mjs --base MERGE_BASE and check-changeset-no-major.mjs --base MERGE_BASE, so the disposition and the level form are gate-measured on this head.
  • Every Lint · and Type Check · job, Build Docs, Dogfood Regression Gate, Validate Package Dependencies and the four PR-relation checks concluded success.
  • The earlier head bb2fde296's TypeScript Type Check red is a cancellation by the push of df7d1e1fd, not a verdict, and is not read here.
  • The judgments below are on the diff. The landing's own all-green check stands apart from this verdict and waits on the six shards.

The one rule, read. resolveCorsAdmission (cors-admission.ts:75-115): the door's option wins, else OS_CORS_ORIGIN (legacy CORS_ORIGIN, silent) through normalizeOriginPatterns; credentials is the door's option, else OS_CORS_CREDENTIALS !== 'false'; developmentBoot is the input, else isDevelopmentBoot(undefined), which is NODE_ENV === 'development' and nothing else (stack-auth.ts:135-137). No list: the origin handler is developmentBoot && isLocalhostOrigin(o) ? o : null. A list with credentials on: normalizeOriginPatterns(configured).find(isAllHostOriginPattern) throws, naming the entry, both variables and the door's own option. Otherwise createOriginMatcher(configured, { developmentBoot }). Both doors call it with their option names only (hono-plugin.ts about :392, adapters/hono/src/index.ts about :642) and hand admission.origin and admission.credentials to hono/cors. The adapter's old '*' default, its reflect-the-origin arm and its exact-string pass-through are deleted. Nothing is copied: the rule has one body.

The matcher, read. matchOriginPattern (pattern-matcher.ts:51-63) no longer admits a loopback origin ahead of the pattern; it answers '*', exact equality, or the escaped pattern with * expanded to .*, anchored. createOriginMatcher (:120-138) takes options: OriginMatcherOptions = {}; admitLoopback is true only when the normalized list is non-empty AND options.developmentBoot ?? isDevelopmentBoot(undefined) is true, read once at creation; a loopback origin is answered before the pattern loop only when that is true. isAllHostOriginPattern (:76-80) is the regular expression ^(?:[^:/]+:\/\/)?\*+(?::(?:\d+|\*))?$ on the trimmed pattern: an optional scheme, one or more *, an optional :port or :*. It names *, **, https://*, *://*, http://*:*, https://*:8443; it does not name https://*.*, *.example.com, https://*.example.com, http://localhost:* or any exact origin.

The PR body's ## Contract table, row by row, against the code. "Admitted" is the request origin echoed in Access-Control-Allow-Origin.

  1. createHonoApp, no list, credentials on, not a development boot: every origin with credentials, to no cross-origin caller. RIGHT. Before: configuredOrigin = '*' and credentials true selected the reflect arm. After: the no-list handler with developmentBoot false answers null for every origin. Pinned (adapter describe 1; conformance row 1).
  2. Same, development boot: to loopback origins only, with credentials. RIGHT. Same handler with developmentBoot true; isLocalhostOrigin admits localhost, 127.0.0.1 and [::1], http or https, any port. Pinned (adapter describe 1; conformance row 2).
  3. Same two, OS_CORS_CREDENTIALS=false (rows 3 and 4): to no cross-origin caller, or loopback only, without credentials. RIGHT. Before: '*' with credentials off went through createOriginMatcher('*'), which echoed every origin. After: the handler never reads credentials; the rows differ from 1 and 2 in the credentials header only. Not pinned in the credentials-off variant; the code path is the same expression.
  4. createHonoApp, a list holding * (option or env, alone or among others), credentials on: created and every origin admitted, to a throw. RIGHT. Before: '*' alone took the reflect arm; * inside an array or a comma string went through hasWildcardPattern to the matcher, whose '*' arm admits all. After: isAllHostOriginPattern('*') is true and the rule throws before any middleware is mounted; createHonoApp has no try around it, so the throw reaches the host. Pinned (adapter describe 3, including ' * ' and the env spellings).
  5. Both transport doors, a pattern whose host part is the bare *, credentials on: booted and every host the pattern matches admitted, to init() throws or createHonoApp throws. RIGHT. Before, at both doors, such a pattern escaped the includes('*') test (plugin) or the === '*' test (adapter) and reached the matcher, whose expansion of https://* is ^https://.*$. After, the class is refused with the entry named. Both kernels rethrow an init() throw unwrapped, as the previous record read (6285870503, row 4). Pinned (plugin describe 6, five spellings, option and env, alone and in a list; adapter describe 3; conformance row 7).
  6. Both transport doors, a list with at least one pattern, not a development boot: the list plus every loopback origin, to the list only. RIGHT. Before, both doors reached createOriginMatcher for a wildcard list and matchOriginPattern answered loopback first. After, admitLoopback is false outside development. Pinned (plugin describe 5 for production, unset and test; adapter describe 2; conformance rows 3 and 5).
  7. HonoServerPlugin, a list of exact origins only, not a development boot: the list plus every loopback origin, to the list only. RIGHT. The plugin always used the matcher, so the convenience applied to exact lists too. Pinned (plugin describe 5, first case).
  8. createHonoApp, a list of exact origins only, development boot: the list only, to the list plus loopback. RIGHT, and the gain is confined. Before, an exact list was handed to hono/cors as a string or array and matched exactly. After, the matcher admits loopback beside it only while developmentBoot is true, and developmentBoot is NODE_ENV === 'development' read when the app is created; no other input can set it, since neither door passes the option. Pinned (adapter describe 2, both boots; conformance row 4).
  9. AuthManager, OS_CORS_ORIGIN holding * among other entries: * entered the trusted set, to dropped with the others kept. RIGHT. auth-manager.ts about :3026-3032: the whole-value !== '*' test is gone; each trimmed entry is pushed unless admitsEveryHost(entry). Pinned (auth case 1; conformance row 6).
  10. AuthManager, * with surrounding space: entered, to nothing enters. RIGHT. Before, ' * ' failed the whole-value test and its trimmed entry '*' was pushed. After, the trimmed entry is dropped. Pinned (auth case 2).
  11. AuthManager, an all-host pattern: entered, to dropped. RIGHT. Pinned (auth case 3: https://*, *://*, http://*:*, https://*:8443; conformance rows 7 and 9).

One row the table omits. createHonoApp with cors.origin given as ONE comma-separated string of exact origins (no * anywhere), credentials either way. Before, hasWildcardPattern was false and the raw string went to hono/cors as a single exact origin, so nothing was admitted. After, createOriginMatcher splits it through normalizeOriginPatterns and admits each listed origin. The option's own TSDoc on main already promised "Comma-separated string or array", so this is a documented contract now honoured, and it widens only toward what the operator listed; it is covered by the Clause-②: yes line. The table's heading ("every input whose answer changes") and the "Unchanged" bullet ("an exact origin ... admitted as before ... at both transport doors") are one edge short for it. Not pinned. Non-blocking; the seat may name it in ACCEPT.

"Does any configuration that admitted an origin it should keep admitting now refuse it?" No. Every narrowing the diff makes is one the card asked for: the adapter's no-list default, loopback beside a list outside development at both doors (and at the cloud preflight, below), and the all-host class with credentials. Read for an unintended refusal: an exact origin or a host-naming pattern is admitted as before at both doors (matcher exact arm and expansion unchanged); '*' and an all-host pattern with credentials off still boot and echo every origin (createOriginMatcher with the '*' arm, or the expansion); an empty list (cors.origins: [], or OS_CORS_ORIGIN of whitespace) admits nothing, loopback included, on either boot, as before, since a non-empty normalized list gates admitLoopback (pinned, pattern-matcher test); a cors.origin of '' or an OS_CORS_ORIGIN set to the empty string resolves to "no list" at both doors, as before at the plugin; the legacy CORS_ORIGIN / CORS_CREDENTIALS spellings are still read through readEnvWithDeprecation. A deployment that reached a listed-origin server from a loopback origin outside development loses that admission; that is the card's Expected and the changeset's operator step names it.

The "Unchanged" list. Each bullet is TRUE against the code: host-naming patterns and exact origins unchanged at both doors and in auth; the plugin's development-boot loopback beside a list and its no-list answer as #24738 left them; all-host entries with credentials off still served; the empty list; cors: false, cors.enabled: false and OS_CORS_ENABLED=false (both doors' guards untouched); allowed and exposed headers, methods and max-age untouched; preflights still answered by hono/cors whether or not an allow-origin is set; same-origin outside CORS; OS_TRUSTED_ORIGINS admits nothing to CORS, since the rule reads OS_CORS_ORIGIN and the door's option only (ruling A, 6288093770, holds; serve.ts is untouched and OS_TRUSTED_ORIGINS still reaches AuthPlugin alone).

The two copies of the all-host predicate. ALL_HOST_PATTERN in pattern-matcher.ts:76 and the inline admitsEveryHost in auth-manager.ts about :3028 are the same regular expression character for character, and both are applied to a trimmed entry (pattern.trim() on the plugin side; a .trim() in the split's map on auth's). Equal today. Does the enumeration pin fail if they drift? Only on the spellings it uses: cors-doors.conformance.test.ts holds ADMITS_EVERY_HOST = ['*', 'https://*'] and its rows use those two. The side pins widen that: the plugin's describe 6 and isAllHostOriginPattern's own test cover http://*, *://*, http://*:*, https://*:8443, ** and ' * '; the auth pin covers https://*, *://*, http://*:* and https://*:8443. A drift on either copy that changes the verdict for one of those spellings fails that side's own pin. A drift confined to a spelling none of the three files use (** on the auth side, a bare *:8080, https://**) is caught nowhere. So "the pin holds the two equal" is true on the pinned spellings, not as a textual identity. Non-blocking for this card (③ item 7); the stronger forms are a textual-equality assertion reading both sources, or one implementation.

Public surface. index.ts adds export * from './cors-admission', so @objectstack/plugin-hono-server's root entry (its only exports path) gains resolveCorsAdmission, CorsAdmission, CorsAdmissionInput and CorsAdmissionDoor; pattern-matcher.ts adds isAllHostOriginPattern and OriginMatcherOptions and gives createOriginMatcher an optional second parameter. That is exactly the list the PR body and the changeset name. hasWildcardPattern stays exported (the adapter no longer imports it; its test still does). @objectstack/hono: ObjectStackHonoCorsOptions keeps its shape (TSDoc only); createHonoApp gains a throw, documented at every face. @objectstack/plugin-auth: behaviour only. HonoCorsOptions (adapter.ts) is TSDoc only. pattern-matcher.ts now imports @objectstack/core at module scope; exports publishes the root entry only, which already loaded hono-plugin.ts and therefore core, so no consumer gains a new load. Type Check · workspace and Build Core concluded success. Nothing is removed or renamed, and packages/spec is untouched. RIGHT.

The prose faces, each against the shipped behaviour.

  • docs/HARDENING.md § CORS: TRUE. The list row ("on a development boot, loopback origins are admitted beside the list too; on any other boot, only what the list names") and the refusal row ("*, or a pattern whose host part is the bare * ... refused at boot ... naming the entry and the two remedies") match the rule. The createHonoApp paragraph ("answers by the same rule, the table above holds for it too, with its own cors.origin option") is what the shared call makes true. The closing paragraph ("both doors refuse ... HonoServerPlugin at boot, createHonoApp when the app is created ... accepted only with credentials off ... auth never trusts it from OS_CORS_ORIGIN") matches the throw sites and the auth filter. The one unchanged edge from the previous record stands: an empty list admits no loopback on a development boot, narrower than the list row reads, and not a change of this diff.
  • packages/plugins/plugin-hono-server/README.md: TRUE. Loopback "beside a list or without one" on a development boot; the refusal names the class; "exported as resolveCorsAdmission, and createHonoApp reads it too"; createOriginMatcher(patterns, { developmentBoot }) "reads the development boot from NODE_ENV when the option is not passed".
  • packages/adapters/hono/README.md: TRUE. The new CORS paragraph states the rule, the development-only loopback and the creation-time refusal. The options table's cors.origin default ("env OS_CORS_ORIGIN, else no list") and cors.credentials default ("OS_CORS_CREDENTIALS !== 'false', on") match the code; the previous row said false, which was false against main's code already, so this is a correction, not a change.
  • The changeset's Before / Now / Unchanged bullets: TRUE against the table above, with the same comma-string edge in its "Unchanged" bullet, and with the "at both doors that used the pattern matcher" wording correctly scoped. Its "What an operator does" names deployment variables and the doors' options only. One sentence is time-bound: "It finishes what the HonoServerPlugin default change in this release began" (② below).
  • content/docs/deployment/environment-variables.mdx, untouched: INCOMPLETE, not false. Its OS_CORS_ORIGIN row says unset means no cross-origin caller with loopback excepted on a development boot (now true at both doors), and that * is refused at boot unless OS_CORS_CREDENTIALS=false (still true; it does not say "only *"). Its OS_CORS_CREDENTIALS row says it must be false to serve * (true). Neither row says that the all-host pattern class is refused too, that the refusal is also raised when createHonoApp is created, or that loopback beside a list is now development-only; none of those omissions makes a sentence on the page false. The port note's OS_TRUSTED_ORIGINS wording is unchanged and still right under ruling A.

The pins, by what they assert. The plugin's describes 5 and 6, the pattern-matcher cases (loopback-only-by-name, developmentBoot true and false, the empty list, the creation-time read of NODE_ENV, the class and its neighbours, and the agreement between isAllHostOriginPattern and the matcher), the adapter's three describes, the auth file's four cases off a real betterAuth() instance, and the enumeration pin's nine rows by three doors all read the one header or the real options.trustedOrigins. Each was committed before the fix (a9c5f629a) and the report records them red there; this record does not re-run them. One precision in the enumeration pin: DOORS.transport.HonoServerPlugin maps ANY rejection of init() to 'refused' (a .then whose rejection handler answers true, with no read of the error), so on the two refused rows an unrelated init() failure would pass as a refusal; the plugin's own describe 6 reads the message, so the class is still pinned. Non-blocking.

② Semver level

  • The changeset matches what the diff publishes. .changeset/24809-cors-doors-one-admission-rule.md carries @objectstack/plugin-hono-server: minor, @objectstack/hono: minor, @objectstack/plugin-auth: minor, a BREAKING banner, Clause-②: yes (narrowing), and the ADR-0087 marker not-required (no-migration-prescription). Those three are the only published packages whose source moves; @objectstack/http-conformance is private and gains a test, a devDependency and an alias. The PR body's second line carries the same Clause-②: yes (narrowing). One changeset is right, and skip-changeset would be wrong.
  • Clause-②: yes (narrowing) is RIGHT. (narrowing): three doors admit less. yes: the root exports entry gains six addressable names and one optional parameter, and one door admits more in one configuration (the createHonoApp development-boot loopback row, plus the comma-string row above). yes (narrowing) is a legal declaration (yes takes at least minor; (narrowing) is breaking; only no (widening) is malformed). The amendment 6290874806 supersedes the claim's no (narrowing), and both carriers on this head read the amended line. It is the narrowing arm that owes this CONTRACT_REVIEW_TIER record.
  • Level: RIGHT under the convention as the gate enforces it on origin/main now. scripts/check-changeset-no-major.mjs is present on main and, with .changeset/pre.json absent, is in its enforcing phase ("absent, exit and unreadable all enforce"), so a major is refused and minor plus a BREAKING banner plus a disposition is the only form the gate admits for a narrowing. That is the form of every narrowing changeset on main and of security: the HTTP server's default CORS answer admits every origin for credentialed requests (detail withheld under the disclosure ruling) #24738's. patch would understate it; the fix( type never lowers the level the act requires. Check Changeset concluded success on the head.
  • What 06cab4849 changes about this reading, stated so the seat can carry it. The merge-base and the head carry 18.0.0-next.5 and a pre.json in mode: exit; origin/main now carries 18.0.0 and no pre.json. Two consequences. (a) The PR body's "(the fixed group, pre-release next)" and "whether this enters 18.0 is the maintainer's call" describe the merge-base, not main: this changeset will be consumed by the version pass AFTER 18.0.0, so the changeset sentence "the HonoServerPlugin default change in this release" will be read in a CHANGELOG entry of a later version than the one that carries security: the HTTP server's default CORS answer admits every origin for credentialed requests (detail withheld under the disclosure ruling) #24738's. That is one phrase in a release-owned text, owed before the landing (③ item 14); a head that moves on the changeset's prose alone keeps this record governing. (b) The guard's own header names its end condition, "at GA — the fixed group's first general-availability major — the group returns to STRICT SEMVER", and says the guard is retired by whoever closes the window. Whether the 18.0.0 version pass is that GA is not readable from the inputs (ADR-0087 records the exemption for majors 12 to 15 and names no end), and it is not this PR's to declare. If the maintainer rules the window closed, this narrowing grades major and the guard is retired first, in its own PR; until then minor is right. Escalated (③ item 14).
  • ADR-0087 disposition: RIGHT. No authorable key, spelling, type, export or stored shape moves, so objectstack migrate meta has nothing to rewrite. The body's "What an operator does" names deployment variables and the doors' options and carries no FROM-to-TO mapping and no consumer-code rewrite, so the no-migration-prescription category's statement-against-statement refusal does not fire; the marker also closes the other categories on facts. The gate that reads it concluded success.

③ Boundary flags

The dev's flags (report 6290828606: deviations, open questions, out-of-scope findings; the body's acceptance notes), and this record's own.

  1. Zone 2 [WIP] Fix error in step four of the action run #5 route change: auth drops the whole all-host class, not only *. Answered, RIGHT. better-auth 1.7.3's matchesOriginPattern sends a pattern containing :// and * through wildcardMatch(pattern) with no separator option, so * matches any run of characters: https://* matches every https origin, *://* every origin. An all-host entry in the trusted set has *'s effect, so dropping the class is the same narrow judgement the transport makes. The claim's Location 4 ("each entry is judged") covers it.
  2. File surface: http-conformance gains a devDependency on @objectstack/plugin-auth, three lockfile lines and an anchored source alias. Answered: needed so the pin reads a real AuthManager; the alias is the regex form the alias gate requires; Lint & Repo Gates and Validate Package Dependencies concluded success. Accepted.
  3. Two pattern-matcher.test.ts cases rewritten. Answered: they pinned the always-admit-loopback convenience the card retires, and were Location 2's only in-repo consumers (the only other Access-Control-Allow-Origin assertions name http://localhost:* in their own list). Accepted.
  4. The Clause-② line was first copied from the claim, then amended. Resolved on this head: PR body line 2 and the changeset both read yes (narrowing); the amended claim 6290874806 supersedes 6289236849. No action.
  5. Attribution trailer form, the backgrounded build wait, the plugin-auth suite run through exec vitest, gates derived on fb509fc0a rather than the final head. Not contract matters. The head delta from fb509fc0a is the changeset only (verified), and the changeset family was re-run on bb2fde296, whose changeset differs from the head's only in the Clause-② line and the exports sentence; Check Changeset measured the head itself. No action.
  6. Open question 1 (the Clause-② line; the seat answered A). RIGHT, ② above. A no would have been contradicted by the exports map.
  7. Open question 2 (the inline predicate in auth; the seat answered A). Acceptable for the closure card, with its limit stated: plugin-auth has plugin-hono-server as a devDependency only, so importing the predicate would add a runtime edge that pulls hono, @hono/node-server and observability into auth for one regular expression, and no cycle either way. The two copies are equal today and the pins hold them equal on the pinned spellings (① above), not textually. The card's own governing line ("one operation, one implementation") points at the dev's option B, the predicate in @objectstack/core beside isDevelopmentBoot, as the end state, or at a textual-equality assertion. A follow-up for the seat to file if it wants one implementation; not a card from this record.
  8. serve.ts comment (about :4182): "better-auth's glob does not cross dot separators". Measured from the pinned source this round: wildcardMatch is called with no separator, so https://*.example.com matches https://a.b.example.com. The comment is wrong, has no runtime reach, is outside this diff, and the previous record's ③ item 2 already named it. A comment precision for the cli lane; not a card.
  9. environment-variables.mdx OS_CORS_ORIGIN row. Answered in ①: incomplete, not false. A docs follow-up alongside the cli.mdx "(CORS)" line, which is still held for whichever PR lands second. No action here.
  10. The stale comment in auth's block ("When CORS allows all origins (default)") and the development substitution. The substitution is a declared hold-off and is untouched; the comment predates security: the HTTP server's default CORS answer admits every origin for credentialed requests (detail withheld under the disclosure ruling) #24738. One edge the hold-off now carries: the substitution still tests the WHOLE value (!corsOrigin || corsOrigin === '*'), so on a non-production boot OS_CORS_ORIGIN=' * ' (credentials off, the only boot the transport allows it on) now contributes nothing to the trusted set and also skips the localhost substitution, where '*' unspaced gets it. Narrow, non-production, operator-spelled. A note for the services lane with the comment fix; not a card.
  11. https://*.* is not refused. Answered: it names part of the host, so it is outside the class by design, as https://* was outside security: the HTTP server's default CORS answer admits every origin for credentialed requests (detail withheld under the disclosure ruling) #24738's; a general pattern validator is a separate decision and would be its own card. Reported in the PR under the ruling. Not a card from this record.
  12. The cloud's preflight short-circuit, NOT MEASURED. The direction of the matcher's default is right: a one-argument createOriginMatcher caller reads the same NODE_ENV the transport reads, so preflight and request agree on the same boot. The cost is the one the body names: a cloud deployment that relied on loopback beside its list outside development now needs those origins listed, and any no-list default the preflight carries of its own is unreadable here. Landing-adjacent: the seat names both to the maintainer when cloud takes the version that carries this.
  13. The roster is declared, not discovered. Answered: the card asked for "one test that lists every door", and discovery would be a new cross-package gate. The file's header tells the author of a new door to add it. Accepted.
  14. New from this record: the 18.0.0 version pass landed on main (06cab4849) after the merge-base. Three things for the seat. (a) The "enters 18.0" question the claim put to the maintainer is overtaken; the PR body's "pre-release next" describes the merge-base. (b) The changeset's "in this release" phrase will read one release off in the CHANGELOG entry that carries it: owed before the landing, one phrase; a head moved on the changeset's prose alone keeps this record. (c) Whether 18.0.0 closed the launch window is the maintainer's to say; if it did, the no-major guard is retired first and this changeset grades major (② above). Escalated to the maintainer through the seat.
  15. New from this record: the omitted Contract row (createHonoApp, cors.origin as one comma-separated string of exact origins: nothing admitted before, the list now). A documented contract honoured; a widening under the yes line. The seat may name it in ACCEPT; non-blocking.
  16. New from this record: the enumeration pin's 'refused' reads any init() rejection. A pin precision (read the message, as the plugin's own pin does). Non-blocking.
  17. Six Test Core shards were in_progress at render. NOT MEASURED. The landing's all-green check is separate from this verdict.

Landing. The 18.0.0 ruling on #24738 and ruling A (6288093770) are recorded, and the diff agrees with ruling A. This record is the narrowing face's review of record on this head. Still owed before the queue: every check green on df7d1e1fd (six shards outstanding), the changeset phrase in item 14(b), and the seat's word to the maintainer on item 14(c).

Implemented-by: claude/issue-24809-cors-family-closure
Reviewed-by: session_01LmjU2x1faLZ39DVTFyVca6

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT

domain:cli seat 2 (#22648) · os-justin · session session_01LmjU2x1faLZ39DVTFyVca6 · 2026-10-20T12:56Z · head df7d1e1fd43c9a6aaa98729fc30ef8bbd3f1b17d · claim 6289236849, amended 6290874806 → report 6290828606 → review of record 6291333954 (CONTRACT_REVIEW_TIER, PASS). This seat adopted it after re-reading its cited lines at source:

  • cors-admission.ts:75–:115: the one rule; no list answers loopback on a development boot only; an all-host entry with credentials throws.
  • pattern-matcher.ts:51–:63: no loopback arm ahead of the pattern.
  • pattern-matcher.ts:76–:80: ALL_HOST_PATTERN.
  • auth-manager.ts:3028–:3030: the same expression, applied per trimmed entry.
  • stack-auth.ts:135–:137: isDevelopmentBoot.
Check Reading
Shape Draft, base main. First line Fixes #24809; Clause-②: yes (narrowing) line-initial, per the amended claim. Assignee os-justin = the card's. One changeset: three packages minor, a BREAKING banner, and the ADR-0087 disposition not-required (no-migration-prescription).
Surface 19 files, +1005 / −161. One admission rule (resolveCorsAdmission) is read by both transport doors, auth judges each OS_CORS_ORIGIN entry, and one enumeration pin lists every door. All of it is inside the claim's four locations. serve.ts is untouched, so ruling A (6288093770) holds: OS_TRUSTED_ORIGINS admits nothing to CORS. check-governed-merges.mjs --pr 24873: NOT governed.
Mechanism The review's eleven Contract rows are each RIGHT against the code. No configuration that should keep its admission loses it. Every narrowing is one the card asked for.
Disclosure Within ruling 5323310017: the PR and this chain name configurations and file locations only.
Gates Check-runs on this head (41, latest per name), read at this act: 30 success, 8 skipped, 3 in_progress (Test Core 2, 3, 4 /6). Landing waits on all of them.

Accepted deviations: the review's ③1–③6 and ③13.

  • Auth drops the whole all-host class, not only *, because better-auth's matcher gives such an entry *'s effect.
  • http-conformance gains a devDependency on plugin-auth.
  • Two matcher cases were rewritten because they pinned the retired convenience.
  • The Clause-② line was amended on the dev's measurement.
  • The roster is declared, not discovered.

Owed before landing:

  1. Prose only (③14b, ③15). A patch round moves only the changeset's prose and the PR body. The review says a head moved on changeset prose alone keeps this record governing.
    • The changeset's "the HonoServerPlugin default change in this release" reads one release off, because 06cab4849 (chore: version packages) versioned 18.0.0 after this branch's merge base. It names security: the HTTP server's default CORS answer admits every origin for credentialed requests (detail withheld under the disclosure ruling) #24738's change by version instead.
    • The body's "pre-release next" and "whether this enters 18.0" lines are corrected to main as it now stands.
    • The Contract table and the changeset's "Unchanged" bullet gain the omitted row: createHonoApp with cors.origin as one comma-separated string of exact origins admitted nothing before and admits the listed origins now. That honours the option's TSDoc and widens only toward what the operator listed, under the yes line.
  2. The maintainer's word (③14a, ③14c). The question this seat had open, whether this enters 18.0, is overtaken: 18.0.0 is versioned and tagged, so this lands in the next version.
    • The guard's own text (check-changeset-no-major.mjs, END CONDITION) says "Reaching GA without touching this file means the window did not actually close". The file is untouched, so minor with the banner is the form in force today.
    • Whether 18.0.0 was the GA that closes the window is the maintainer's call. If it was, the guard is retired first in its own PR, and this changeset grades major.
    • This seat lands only on the maintainer's word.
  3. Every check on the head reads success (or an expected skip).

Out-of-scope dispositions (one line each):

  • The two copies of the all-host expression (③7) → Acceptance notes. They are character-equal today, each side's own pins cover the spellings they use, and one implementation in @objectstack/core has no pull yet.
  • The serve.ts comment about better-auth's glob and dot separators (③8) → Acceptance notes. It is this lane's comment, it is wrong and has no runtime reach, and the next PR that touches that block carries the fix.
  • environment-variables.mdx's OS_CORS_ORIGIN row (③9) → Acceptance notes. It is incomplete, not false, and goes with the held cli.mdx "(CORS)" line.
  • The auth block's stale comment and the whole-value test on ' * ' in the development substitution (③10) → Acceptance notes. That is the services lane's hold-off, non-production and operator-spelled.
  • https://*.* (③11) → Acceptance notes. It names part of the host, so it is outside the class by design.
  • The cloud preflight (③12) → NOT MEASURED here. It is named to the maintainer for when cloud takes the version that carries this.
  • The enumeration pin's 'refused' reads any init() rejection (③16) → Acceptance notes. The plugin's own describe reads the message.

Landing: through the merge queue by this seat once items 1–3 hold.


Generated by Claude Code

…ion that shipped it, and add the comma-separated cors.origin row

18.0.0 was versioned on main after this branch's merge base, so "in this
release" would read one release off in the CHANGELOG entry that carries
this changeset; the sentence now names the change by 18.0.0.

createHonoApp's cors.origin written as one comma-separated string of exact
origins admitted no origin before and admits each listed origin now. The
changeset gains that row, and its "Unchanged" bullet no longer claims the
exact-origin answer is unchanged in that one spelling. Prose only.

Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · patch round adopted

domain:cli seat 2 · session session_01LmjU2x1faLZ39DVTFyVca6 · 2026-10-20T13:39Z · head df7d1e1fd → 0ffd23e64de690718b939fe22ef8898c4c13f05f · report 6292161131

  • The head moved on changeset prose alone. git diff --stat df7d1e1fd 0ffd23e64 lists .changeset/24809-cors-doors-one-admission-rule.md only, +3 / −2. The three minor lines, the BREAKING banner's first sentence, the Clause-②: yes (narrowing) line and the adr-0087 marker are unchanged. The review of record 6291333954 keeps governing.
  • ACCEPT 6291362699 item 1 is met.
  • Gates on 0ffd23e64 (local):
    • 85/85 derived families and the 10 Declared WIDE population families exit 0; --ran: 0 NOT-MEASURED, 0 UNRUN.
    • check-changeset-no-major and check-adr-0087-registration exit 0; pnpm lint exit 0.
  • main moved past the review's reading. c2d4072281 (fix(plugin-auth): a credential-scope posture does not hold an impersonation session (#24762) #24866) touches auth-manager.ts away from location 4. The merge-tree has no conflict, and GitHub reports the PR mergeable.
  • Still owed before landing:

Generated by Claude Code

This was referenced Oct 20, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · the maintainer's word recorded · landing

domain:cli seat 2 (#22648) · os-justin · session session_01LmjU2x1faLZ39DVTFyVca6 · 2026-10-21T01:30Z · head 0ffd23e64de690718b939fe22ef8898c4c13f05f

  • ACCEPT 6291362699 item 2 is met. The maintainer, in this seat's session chat at 2026-10-21T01:29Z, verbatim: 「24873 可以合并」. This PR lands in the version after 18.0.0 with its changeset as written: three packages minor, the BREAKING banner, and the ADR-0087 disposition. That is the form the launch-window guard (check-changeset-no-major.mjs) enforces on main today.
  • Item 1 (the prose round) was met at 0ffd23e64 (6292221723). Item 3: check-runs on this head (42, latest per name), read at this act: 34 success, 8 skipped, 0 in_progress.
  • main is 73 commits ahead. Some of them touch auth-manager.ts and plugin-hono-server tests away from this PR's hunks. git merge-tree is clean, and GitHub reports the PR mergeable (clean). The merge queue builds and tests the merged tree.
  • check-governed-merges.mjs --pr 24873: NOT governed. No main-red card is open in this lane.
  • The cloud preflight (review ③12) stays a note for when cloud takes the version that carries this. A cloud deployment that relied on loopback beside its list outside development names those origins.

Landing: armed for the merge queue in the stroke after this comment.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 21, 2026 01:31
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 21, 2026 01:31
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 21, 2026
Merged via the queue into main with commit 02e6d73 Oct 21, 2026
57 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24809-cors-family-closure branch October 21, 2026 02:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants