Skip to content

fix(plugin-auth): the register form's Application Type reaches the OAuth provider as application_type - #24992

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-24773-oauth-application-type
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-24773-oauth-application-type

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #24773: half 2a, the plugin-auth part of the maintainer's ruling D (6293344532). Half 1 (a refusal answered 500) landed as #24853. Half 2b (the form's options and help text in platform-objects) stays on the card.

Clause-②: no — the declared type now takes effect under the OAuth provider's own redirect-URI rule, an authentication behaviour the maintainer ruled on; nothing published widens.

What changes

POST /api/v1/auth/sys-oauth-application/register (Setup → OAuth Applications → Register) forwarded the form's Application Type as type. The installed @better-auth/oauth-provider 1.7.3 declares application_type (web | native) on its /oauth2/create-client body and no type, so the member was stripped and every client was judged and stored as web.

The register wrapper now maps the value onto application_type:

Form value Before After
web judged and stored as web unchanged (now sent as application_type: 'web')
native judged and stored as web: http://localhost:4001/callback refused judged as native: http on exactly localhost, 127.0.0.1 or [::1] (any port) accepted and stored as native; other http refused
user-agent-based, public, any other value web, silently web, as before, plus one server warning per registration naming the value
absent web web, no warning
  • The provider's own rule is the only rule. The wrapper does not judge a URI. Web still needs https on a non-loopback host.
  • Half 1's error mapping is unchanged: resolveThrownHttpError(err, 500) and the 400 OAUTH_REGISTER_FAILED refusal, with details.upstreamError.
  • The warning is a server log line (ctx.logger.warn, meta { requestedType, registeredAs: 'web' }). No response field was added. This door answers { success, data: { client } }, which has no warnings member. The warnings member on the data-layer write answers (CreateDataResponseSchema, packages/spec/src/api/protocol.zod.ts) is declared as advisory validation-rule hits, so it does not fit here. The warning does not reach the Console caller.
  • Diff: auth-plugin.ts (the register wrapper only, +32 / -3), one new test file, one changeset. 382 changed lines in total.

「甲」 reading

Standing rule 「甲」 (#6015 6292005854) applied as ruled in 6293344532:

  • Ships in 18.x. Honouring the declared type is a fix. web and native are forwarded, and Native gets the provider's RFC 8252 loopback rule. Other values get the loud half: one warning per registration.
  • Waits for 19.0. A 400 for user-agent-based / public / any other value narrows what the door accepts, and no ADR-0087 conversion carries it. Removing the two options from the form is half 2b, not this PR.
  • public, measured first. 1.7.3 has a real counterpart. A public client is token_endpoint_auth_method: 'none', and persistOAuthClientRegistration then issues and stores no client secret. It is a separate axis from application_type, which has no public value. Per the dispatch, it is not forwarded or mapped. The mapping question goes to the seat, with options, in the dev report on the card. Here public behaves like user-agent-based: registered as a confidential web client with a secret, plus the warning.
  • The exception's reading, for the seat to record and rule. The question is whether any stored row or any caller ever sent user-agent-based / public. The answer does not establish the exception.
    • Stored rows, console door. Measured on a real ObjectQL + SQLite engine with the auth manifest's objects: on base 78ab6474c and on this head, a registration with user-agent-based, public or spa stores type = 'web', public null and token_endpoint_auth_method = 'client_secret_basic'. No column holds the form's value. 1.7.3 accepts only web / native for application_type on create and on update.
    • Earlier provider lines, read from their npm tarballs, not installed here:
      • @better-auth/oauth-provider 1.7.0-rc.2 and 1.6.33 declared type (web | native | user-agent-based) but refused any non-web type on a confidential client. The door never sends token_endpoint_auth_method, so user-agent-based was refused there, and the wrapper coerced public to web.
      • better-auth 1.6.33's oidc-provider wrote type: "web" on every registration.
    • Limits of this reading.
      • Repo history before 2026-09-07 (ac76425f0, this checkout's shallow root) was not read.
      • Rows that other doors wrote under the older lines cannot be measured. Under rc.2 and 1.6.x, the SDK and DCR accepted user-agent-based for public clients.
    • Callers.
      • In this repo, zero producers send either value to the door: tests, examples, packages/qa, docs and the SDK (which never builds this URL).
      • HotCRM main 1cc6861a3: zero hits for the door path, user-agent-based, application_type, oauth2/create-client and applications.register (control: case-insensitive oauth, 18 files).
      • objectui origin/main 12cde0492: zero hardcoded values (control: sys_oauth_application, 5 files).
      • The one live producer is the Console form itself. sys-oauth-application.object.ts:153-158 offers both values to every admin. Whether an admin ever picked one cannot be measured, because there is no request log.

Tests

New packages/plugins/plugin-auth/src/oauth-register-application-type.test.ts, 15 cases. It runs real better-auth (OIDC provider on) behind the plugin's real route registration, on a real Hono app, over a real ObjectQL engine on SQLite. "Stored as" is read from sys_oauth_application.type at driver level.

  • Preconditions on the provider itself:
    • type: 'native' is stripped: the loopback URI is refused with invalid_redirect_uri.
    • application_type: 'native' accepts the same URI and stores native.
  • Web + http://localhost:4001/callback: refused, 400 OAUTH_REGISTER_FAILED, invalid_redirect_uri. Nothing stored, no warning.
  • Native + http://localhost:4001/callback and + http://127.0.0.1:4001/callback: 200, stored native, no warning.
  • Native + http://app.example.com/callback: refused, 400, nothing stored. The provider's body schema (SafeUrlSchema, https except on loopback hosts) refuses it before its per-type rule.
  • Native + http://127.0.0.2:4001/callback: refused by the provider's exact-host native rule (invalid_redirect_uri).
  • Web + https and no type: stored web, no warning.
  • user-agent-based, public, spa + https: stored web, no column holds the value, no public client (no secret-less row), exactly one warning with meta { requestedType, registeredAs: 'web' }.
  • user-agent-based, public, spa + loopback: refused as web, nothing stored, exactly one warning.

Before the fix, on 78ab6474c: Tests 9 failed | 5 passed (14). The native-localhost and native-127.0.0.1 cases got the web refusal (expected 400 to be 200). The six unhonoured-type cases failed only on the warning count (expected [] to have a length of 1 but got +0), after their storage assertions had passed. The ninth failure was this file's own first assertion on the non-loopback native case. It expected invalid_redirect_uri, but the provider's body schema refuses that URI before its per-type rule, both before and after the fix. The assertion was corrected after this run.

After, at head e9f4debc1:

  • This file and half 1's oauth-register-refusal-status.test.ts: Test Files 2 passed (2), Tests 32 passed (32).
  • pnpm --filter @objectstack/plugin-auth exec vitest run, at d674628b9: Test Files 174 passed (174), Tests 3334 passed | 10 skipped (3344). The plugin-auth tree is byte-identical at e9f4debc1; the merge of main brought only the objectui pin.
  • pnpm --filter @objectstack/plugin-auth typecheck: exit 0. check:test-typecheck: OK, 23 pinned signatures held, no new debt. The new file is in the tsconfig.test.json program (--listFilesOnly, 1 hit).

Ablation (one-shot; no permanent file)

Each leg went through scripts/ablation-replace.mjs: the anchor hit exactly once, and the mutation was verified on disk (anchor 1 → 0, blob changed). The runner had its own EXIT/INT/TERM trap. Each restore was verified as blob 01004ce16 == HEAD, with git diff HEAD empty. The test imports ./auth-plugin from source, so no dist is involved.

Leg Mutation (the forbidden behaviour put back) Result
1 application_type: applicationType → type: applicationType (the old type forwarding) Tests 2 failed | 13 passed: native + localhost / 127.0.0.1, expected 400 to be 200 (the web refusal)
2 applicationType = type → applicationType = 'native' (web judged as native) 2 failed: web + localhost expected 200 to be 400; web + https stored native
3 unhonoured values forwarded as native 6 failed: all six unhonoured-type cases
4 the door rewrites the URI to one the provider accepts (http://app. → https://app., 127.0.0.2 → 127.0.0.1) 2 failed: both native negative pins, expected 200 to be 400

The first attempt at leg 3 used an anchor that the replacement still contained. The tool refused it (anchor count moved 1 -> 1), restored, and ran nothing. It was redone with a non-overlapping anchor. The reading above is from the redone run.

Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at head e9f4debc1 derived 72 commands. That list is identical to the seat's derivation at 78ab6474c. Each command was run with cmd > log 2>&1; rc=$?.

  • Result. 72/72 exit 0. This includes check:adr-0087-registration --base origin/main, check:changeset-no-major, check:empty-changeset, check:closing-keyword-parity, check:error-status-conformance, check:route-envelope, check:auth-mount-ledger, check:durability-log-level, check:issue-citations, check:nul-bytes, check:test-source-alias, check:engine-double-contract and check:type-check-debt.
  • check:dual-build-cjs-loads. It first exited 3 (PREREQUISITE NOT MET, no whole-repo dist). It passed after turbo run build --filter=!@objectstack/docs (76 tasks, 75 from cache): 112 require entry points across 69 packages load.
  • Reconciliation. --ran printed Run reconciliation — 72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN.
  • Not run locally.
    • test:repo: CI Test Core.
    • check:dev-prereqs and check:skill-frame-freshness: no packages/spec build input or frame file is touched.
    • The workspace type-check lanes and the CI-only jobs that dispatch-gates lists outside the 72: CI.

Acceptance notes

  • user-agent-based has a partial counterpart too. In the provider's rc.2 and 1.6.x lines, user-agent-based was valid only for a public client (token_endpoint_auth_method: 'none'). In 1.7.x a browser public client is application_type: 'web' plus token_endpoint_auth_method: 'none'. So the public mapping question covers both form values. MCP clients already register as public native clients through DCR (auth-manager.mcp-oauth-resource.test.ts:235).
  • SDK. oauth.applications.register (packages/client/src/index.ts:4535) does not declare application_type on its request type. It does declare token_endpoint_auth_method: 'none', and its response type OAuthApplication declares application_type. The body passes through unchanged, so a typed caller can register a native client only through a cast. Noted, not filed. Carrier: none (承接者:无).
  • For half 2b.

Generated by Claude Code

…lication_type

The sys-oauth-application/register wrapper sent the form's choice as `type`,
which @better-auth/oauth-provider's create-client body does not declare, so
every client was judged and stored as web. `web` and `native` now reach the
provider as `application_type`; any other value keeps today's outcome (web)
and logs one warning per registration.

Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y
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 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 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 e4d604efd39f6388dda50b86009d0006e50761d2 → packageMentionDocs.

Which tree this was computed on

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

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · #24773 half 2a · domain:services seat 2 · os-support-ai · session_01UmqzD97e14iws6hmVhgZ5y · 2026-10-20T23:16Z · head e9f4debc1b.

  • This is the review of record for claim 6302570331.
  • The dev's one needs_decision (whether to map public) was answered by the seat in 6303676890: no mapping, which is ruling D as written.
  • Contract review: not owed. The PR touches no published schema and no governed path, and it declares Clause-②: no with no narrowing arm. The runtime authentication change is the maintainer's ruling D.
  1. Form:
    • The first line is Part of #24773 (half 2b and the 19.0 refusal stay on the card). The seat's grep finds no closing keyword beside an issue number.
    • Clause-②: no is at line start in the body and in the plugin-auth changeset (patch), with its reason.
    • 3 files, +379/−3.
    • Path face: check-governed-merges --pr 24992 reports 0 of 3 paths governed. Ordinary queue landing.
  2. Premise and fix: ruling D (6293344532) under 「甲」.
    • The register wrapper forwards the form's type as application_type (web | native), and the provider's own redirect-URI rule decides. Native may use http on exactly localhost, 127.0.0.1 or [::1]. Web stays https on a non-loopback host.
    • Any other value (user-agent-based, public, anything else) keeps today's outcome: registered and judged as web, with a secret. It now logs one warn naming the value. No type at all stays web, with no warning.
    • Half 1's error mapping is unchanged.
    • Pins: 15 real-provider cases over a real engine. Refusals assert 400 OAUTH_REGISTER_FAILED, plus upstreamError where the provider's rule refuses. The stored column is read at driver level.
    • Before the fix, on base 78ab6474c, 9 of 14 went red.
    • Ablations, each restored to the HEAD blob 01004ce16:
      • leg 1, the old type forwarding: native loopback refused;
      • leg 2, everything forced native;
      • leg 3, unhonoured values forwarded as native;
      • leg 4, URI rewriting.
  3. Gates:
    • CI on e9f4debc1b, latest run per check name: 41 checks, 34 success, 7 skipped, 0 failed.
    • Every skip is on the roster in scripts/pm/check-expected-skips.mjs (read from the file): Packed-tarball smoke, the 18.x upgrade harness, the Dogfood Regression Gate matrix, Temporal Conformance, Console Pin Gate, Build Docs and Dogfood Verify CLI.
    • Lint & Repo Gates and TypeScript Type Check are both success.
    • Local: 72 derived, 72 run, 0 NOT-MEASURED. The plugin-auth suite: 174 files, 3334 passed. Typecheck with check:test-typecheck exits 0.
  4. Accepted deviations:
    • The ablation's "put back type: safeType" was spelled type: applicationType, the same defect.
    • Added pins: provider preconditions, a 127.0.0.2 native negative, an arbitrary value and the no-type case.
    • The non-loopback native refusal comes from the provider's body schema, not its native rule. It is pinned as such, and 127.0.0.2 pins the native rule itself.
    • Edge semantics: null is treated like absent; a non-string type warns by its JS type; the logged value is truncated to 64 characters.
    • Prose read sentence by sentence against the diff: the changeset's Now, Unchanged-with-a-warning and Unchanged bullets.
  5. Out of scope:
    • The SDK's oauth.applications.register request type does not declare application_type, though its route accepts it. Acceptance notes, carrier none.
    • The stored type select still lists user-agent-based / public, and the public column is never written by 1.7.3. Carried by half 2b (the seat record 6303676890 item 3), which is in flight.
    • The public → secretless-client mapping: the seat's call is no mapping (6303676890).
  6. Landing: ordinary queue landing. Every check on this head is green or an expected skip, so the seat flips it on this record. After the merge, the seat files the 19.0 refusal of user-agent-based / public and records the landing on the card.

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.

2 participants