You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
OAuth app registration rejects http://localhost and http://127.0.0.1 redirect URIs as "non-loopback", with HTTP 500 #24773
Filing gate ③ a task the maintainer set: objectstack-ai/hotcrm#2188, the maintainer's browser pass of 2026-10-20. Each defect gets its own card, by the maintainer's ruling 「按照按照具体的issue单独提交卡片,不受数量限制。」 (recorded on objectstack-ai/hotcrm#2188, 6283467378). This is finding F-22 of track 2 (Setup as the system administrator), by the cloud tester session_01VuXyJvhGKJy8qeUvhKyxzv. It was measured on HotCRM mainf22dfa52 with @objectstack/* 18.0.0-next.5, in Chromium at 1440×900 on a locally booted, seeded install. The evidence is on the branch claude/qa-setup-pass-2026-10-20 at ed4f40e42, which is never merged. Filed by the repo:hotcrm seat 2 (os-support-ai, session session_01KqkTQNLcxVc588eZpJmp1r). ⛔ Not a claim. Grade: triage's.
Reader (who acts): objectstack triage routes it.
The finding, as measured
Persona / URL: dev admin, OAuth Applications → Register, type Web.
Expected: the field's help, "Must use https:// in production" (loopback http allowed).
Actual:POST /api/v1/auth/sys-oauth-application/register → 500, toast "web clients require https redirect URIs on non-loopback hosts: http://localhost:4001/callback" (same for 127.0.0.1). https://qa.example.com/callback registers.
Landing (seat review):plugin-auth, the OAuth application registration validation.
Dedupe
Method: semantic search_issues on objectstack-ai/objectstack and objectstack-ai/objectui, open and closed, using the finding's own dedupe words and a second phrasing; plus a grep of every issue title (objectstack #1–#24751, objectui #7–#13153) listed through the repo-scoped issues?state=all endpoint, because the semantic search can miss title words (objectstack#14743).
Claim: PM loop round 1 · 2026-10-20T08:53Z
Session: session_01UmqzD97e14iws6hmVhgZ5y
Account: os-support-ai (the seat's linked user as GET /user answers it; always the card's assignee)
Branch: claude/issue-24773-oauth-register-refusal
Worktree: objectstack-issue-24773
Domain: domain:services
Seat: domain:services#2 (seat post #21118)
File surface: packages/plugins/plugin-auth/src/auth-plugin.ts (the sys-oauth-application/register wrapper only), its tests, one new .changeset/24773-*.md. ⛔ The redirect-URI acceptance rule is not widened in this dispatch: whether http://localhost / 127.0.0.1 is accepted for a web client is measured and reported, not changed (an authentication-flow boundary, human floor). ⛔ Not packages/spec/**, ⛔ no new error code (stop on breach; explain in the report)
Container & model: M, mode:subagent, model: opus (TIER_DEFAULT; dispatch-gates --tier on this surface: no path-derived mandate, floor sonnet, default opus, ceiling fable)
Clause-②: no
Responsibility: plugin-auth's sys-oauth-application/register wrapper answers a vendor validation refusal as HTTP 500 (createOAuthClient throws with no status) | the wrapper's own OAUTH_REGISTER_FAILED envelope already carries the vendor's discriminator | every admin registering an OAuth application with a refused redirect URI; measured on a HotCRM install 2026-10-20 (card)
Thread-read: none
Serial constraints cleared: no open PR touches plugin-auth/src/auth-plugin.ts (the file lists of all 18 open PRs read at this claim). Seat 1's #24739 (PR #24795) landed as 25e16ab00; PR #24791 (the session-revoke door in the same file) is no longer open.
os-dev-report
{
"issue": 24773,
"status": "done",
"branch": "claude/issue-24773-oauth-register-refusal",
"pr": "#24853",
"session": "session_01UmqzD97e14iws6hmVhgZ5y — the parent PM session id (mode:subagent); this run is identified by its branch",
"premise_still_valid": true,
"summary": "Half 1 is done. POST /api/v1/auth/sys-oauth-application/register answered better-auth's refusals as 500 because better-call's APIError carries its status as the NUMBER statusCode (400) and the NAME status ('BAD_REQUEST'), and the wrapper read only a numeric status (M1 confirmed). The body-schema ValidationError, with numeric status 400, already answered 400 from the same door. The wrapper now reads the status through @objectstack/types resolveThrownHttpError, the repo's one rule for a thrown error's status (numeric status, then statusCode, else the 500 fallback). So invalid_redirect_uri answers 400, a request with no session answers 401 (same defect at the same line, disclosed in the PR), a storage fault stays 500, and the code stays OAUTH_REGISTER_FAILED with details.upstreamError. No new error code, no acceptance change. Half 2 was measured only. M2's premise is falsified: the vendor's web predicate (@better-auth/oauth-provider 1.7.3 validateClientRedirectUri) is !https || loopback ⇒ refuse. Its text means https AND a non-loopback host, so every loopback form is refused for web in both schemes (localhost, 127.0.0.1, [::1], with or without a port, https://localhost too). No config governs it. The native rule allows http on exact localhost/127.0.0.1/[::1]. The measured blocker for the dev flow is a separate wrapper defect: the Console's Application Type select is sent as type, which the vendor body schema strips (it declares application_type), so every registration is judged and stored as web. Options are in open_questions. The card's own reading that loopback detection misclassifies localhost is not what happens; its 500 half is real and fixed.",
"tests": "New packages/plugins/plugin-auth/src/oauth-register-refusal-status.test.ts runs real better-auth (OIDC provider on) behind the real route registration on a real Hono app. It has 7 cases: a precondition that the fixture refusal is a named-status APIError (status 'BAD_REQUEST', statusCode 400); web + http://localhost:4001/callback → 400 OAUTH_REGISTER_FAILED invalid_redirect_uri with no row written; web + http://127.0.0.1:4001/callback → 400 likewise; no session → 401; body-schema refusal → 400 (preserved); storage fault → 500; https → 200 with one row. Before the fix, a probe on the same stack measured every loopback case at 500 and no-session at 500. Ablation on head 5dc8add, applied with scripts/ablation-replace.mjs, which verified the mutation on disk (anchor 1→0, replacement 0→1, blob 03e026dea→a6bb1a7f3 / →16aa6cab0). The restore was verified as blob == HEAD with git diff HEAD empty, and the shell had its own EXIT/INT/TERM trap. Leg 1, old numeric-status read: 'Tests 3 failed | 14 passed (17)', failing with 'expected 500 to be 400' x2 and 'expected 500 to be 401'. Leg 2, fallback 400: 'Tests 1 failed | 16 passed (17)', failing with 'expected 400 to be 500' on the storage fault. Both went red in the predicted direction. Final head 622e30a (merge of main 5cb6e6e; both files byte-identical to 5dc8add): pnpm --filter @objectstack/plugin-auth exec vitest run gave 'Test Files 169 passed (169) / Tests 3241 passed | 10 skipped (3251)' (os-verify-lock VERDICT command-exit 0). pnpm --filter @objectstack/plugin-auth typecheck exited 0 ('check:test-typecheck: OK'). pnpm --filter @objectstack/spec check:generated exited 0 after the post-merge rebuild. The 17 tests in the new file include the 10 that the createMemoryEngine import re-registers, which is the existing precedent shared by 41 files.",
"gates": "Local union derived by node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 622e30a: 72 commands, each run with cmd > log 2>&1; rc=$?. Result: 72/72 exit 0, including check:error-status-conformance, check:route-envelope, check:auth-mount-ledger, check:durability-log-level, check:issue-citations, check:nul-bytes, check:dual-build-cjs-loads (112 require entry points load), check:type-check-debt and check:test-source-alias. The --ran reconciliation printed 'Run reconciliation — 72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN' ('a DERIVED zero — all 72 recorded an exit code'). An earlier pass at 5dc8add also had 71/72 exit 0, with check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET because the whole-repo dist was missing. The post-merge closure build cleared that prerequisite, and the final head reads green. The records are in scratchpad issue-24773/gates-results.tsv and gates/NNN.log. Not run locally: test:repo (CI Test Core) and the local-only families check:dev-prereqs / check:skill-frame-freshness (no spec build input or frame file touched). CI on PR #24853 at 622e30a, read right after pr_create: 'No other open PR may claim the same issue' success; Check Changeset, Lint (all lanes), Type Check (all lanes), Governed Surface Queue Guard, 'Part-of PR must not also close its card' and 'The card this PR closes must claim this branch' were in_progress; the two opt-in jobs were skipped. Test Core and Build Core were not yet listed. in_progress is the honest value; CI convergence is the PM's.",
"mcp_calls": "0",
"api_writes": "2 relay strokes (scripts/pm/fleet-write/dispatch.mjs, each one POST /repos/objectstack-ai/objectstack/dispatches): (1) pr_create, run 39409267597, which executed POST /repos/objectstack-ai/objectstack/pulls (created #24853, draft; read-back 6676 bytes sent and stored, identical) and POST /repos//issues/24853/assignees (os-support-ai, listed); (2) comment, POST /repos//issues/24773/comments, which is this report. Labels: none. skip-changeset does not apply because plugin-auth publishes and a patch changeset is included. git push x5 (branch probe, 3 commits, merge) is not REST. Reads were plain gh api GETs on #24773, its comments, PR #24853 and its check-runs.",
"open_questions": [
{
"question": "Half 2: may a client registered through the Console door POST /api/v1/auth/sys-oauth-application/register use an http://localhost / http://127.0.0.1 redirect URI, and how should the form's Application Type select reach the provider? This is an authentication-flow acceptance rule on the human floor. Measured facts, @better-auth/oauth-provider 1.7.3: (1) web is refused unless https AND non-loopback, in every environment, with no option; (2) native allows http only on exact localhost / 127.0.0.1 / [::1], any port; (3) the door forwards the select as type, which the vendor body schema strips, so every registration is judged and stored as application_type web, and type 'native' + http://127.0.0.1:4001/callback is refused with the web rule; (4) the SDK's oauth.applications.register (/oauth2/create-client) already carries application_type web|native; (5) the field help says 'Must use https:// in production.'",
"options": [
"A — Keep the vendor rule and correct only the words: the field help in platform-objects states the per-type rule. The type select stays dropped. Cost: one small platform-objects PR (other lane). Business need: not met; no Console path registers a loopback client, and only the SDK path with application_type native remains. Long-term: leaves a declared-but-unenforced form field (Prime Directive #10). AI-safety: an author or AI declaring type 'native' keeps silently getting web. Startup focus: adds nothing, but keeps a dead field.",
"B — Accept http loopback for web outside production. The vendor predicate takes no option, so this means forking the vendor's registration validation: pre-validating and registering another way, or writing the client row ourselves. Cost: medium-large, re-paid on every better-auth bump, plus a production/non-production posture switch. Business need: serves local testing of a browser web app as a web client, which no measured consumer beyond this card asks for. Long-term: a workaround around a security validator. AI-safety: the same metadata gets a different verdict per deployment. Startup focus: adds surface (a switch and a fork).",
"C — Accept http loopback for web always, as the card read the vendor's text. Premise falsified: the text and the predicate both say https AND non-loopback for web. Cost: the same fork as B, with a permanent relaxation of the web-client boundary. All four axes are as for B but worse on the security posture.",
"D — Honour the declared type: the door forwards the select as the vendor's application_type (web | native). Native gets the vendor's RFC 8252 loopback rule; web keeps https + non-loopback. 'user-agent-based', which has no vendor counterpart, is refused loudly (400, replacing today's silent coercion to web) and retired from the select. The field help states the per-type rule. Cost: small; about 10 lines plus pins in the plugin-auth wrapper, and the select options and help text in platform-objects (other lane). Acceptance changes only for registrations that declare native, which today are judged as web."
],
"recommendation": "D (with A's help-text correction folded in), on the four axes. Business need, measured: the form's type param is written by every Console registration (platform-objects sys-oauth-application.object.ts:153-157) and read by nothing; examples/ registers no OAuth client; the measured need is the maintainer's dev-admin pass (hotcrm#2188 F-22), which a native-typed registration meets through the vendor's own loopback path. Caveat: a developer testing a browser web app on localhost would register it as Native under D. If the maintainer rules that need real and not served by Native, that points to B, at its fork cost. Long-term: D is contract-first; it enforces the declared field and keeps the vendor validator as the single authority with no fork, whereas B/C fork a security validator. AI-safety: D makes the declared type real and refuses an unknown one loudly; A keeps exactly the declared-but-not-honoured shape the axis forbids, and B makes acceptance deployment-dependent. Startup focus: D adds no capability and retires one dead option (user-agent-based). No new gate is recommended; none was named by the maintainer."
}
],
"out_of_scope_findings": [
"class: a · reach: public door POST /api/v1/auth/sys-oauth-application/register with {type:'native', redirectURLs:'http://127.0.0.1:4001/callback'} answers the web refusal 'web clients require https redirect URIs on non-loopback hosts' (400 after #24853, 500 before), and {type:'native'} with an https URI stores application_type 'web'. The Console's Application Type select never reaches the provider · evidence: auth-plugin.ts register wrapper forwards type: safeType (and silently coerces an unknown type to web); @better-auth/oauth-provider 1.7.3 dist authorize-9whjxVLJ.mjs createOAuthClient body declares application_type and no type; measured on the real stack (probe on base c3a8e07 before any edit, issue-24773/probe-before.log); called directly with application_type 'native', the vendor accepts http://localhost:4001 and http://127.0.0.1:4001 · Seam: spec:sys_oauth_application create action param type → runtime:plugin-auth sys-oauth-application/register wrapper → vendor /oauth2/create-client (strips) · fold into the #24773 half-2 decision card, because fixing it changes acceptance for native registrations (human floor), not a standalone card · dedupe words: oauth application type native dropped, application_type stripped, register native localhost, user-agent-based",
"class: a (misleading copy) · reach: the Console Register OAuth Application form (sys_oauth_application create action, packages/platform-objects/src/identity/sys-oauth-application.object.ts:152) shows 'One URL per line. Must use https:// in production.', while a web client is refused for any loopback host in every environment and for http on any host. The maintainer's QA pass read loopback http as allowed from it (card: 'Expected: the field's help ... (loopback http allowed)') · fold into the #24773 half-2 decision card; the text follows whichever rule is decided · dedupe words: oauth redirect help text https production, redirectURLs helpText",
"carrier: none (承接者:无) · noted, not filed, in the PR's Acceptance notes: on a server fault the register door answers with the thrown error's raw message (measured: the test's 'storage fault (test)') and logs nothing on that inner branch. This is pre-existing, unchanged, with no measured leak of sensitive data."
],
"line_budget": "229 changed lines (+227 / -2) across 3 files, per dispatch-gates at 622e30a against the 3000 human-merge threshold: under. Source: auth-plugin.ts +12 / -2 (import + comment + one line); test +203; changeset +12. No governed surface is touched, and skills/** is untouched, so no ratchet ledger applies.",
"deviations": [
"The PM's suggested route mapped the vendor refusal to 400. The landed route reads the refusal's own declared status through @objectstack/types resolveThrownHttpError (the repo's shared rule), which gives 400 for invalid_redirect_uri and also 401 for a missing session (UNAUTHORIZED). That is the same defect at the same line; it is disclosed in the PR and the changeset, and pinned.",
"The first commit hand-read statusCode inline; a later commit (5dc8add) switched to resolveThrownHttpError once that rule was found. The ablation was re-run on the final code, and those are the cited legs. In the first (superseded) ablation, my own supplementary echo was mis-quoted and printed '0 marker'. The tool's own disk verification (replacement 0→1, blob changed) and the observed 500s show that the mutation landed. The final-code legs' echo read correctly (1 marker).",
"I stopped two of my own background runs, by their recorded process-group ids 910 and 1586, after the source changed mid-run, and re-ran both on the final tree. No other process was touched.",
"Merged origin/main (5cb6e6e) before opening the PR, per AGENTS §10. The post-merge closure build used the filters '@objectstack/plugin-auth...' and '^...', which also built downstream dependents. That was wider than needed, but it is what satisfied check:dual-build-cjs-loads' whole-repo prerequisite.",
"test:repo was not run locally (CI Test Core). check:dev-prereqs and check:skill-frame-freshness were not run because neither trigger applies.",
"The report status is 'done' for the dispatched scope (half 1 fixed, half 2 measured and reported). The card stays open: the PR says 'Part of #24773', with no closing keyword."
],
"files_changed": [
"packages/plugins/plugin-auth/src/auth-plugin.ts",
"packages/plugins/plugin-auth/src/oauth-register-refusal-status.test.ts",
".changeset/24773-oauth-register-refusal-status.md"
]
}
Honouring the declared type is a fix, and ships in 18.x.
Refusing user-agent-based / public (400) is a narrowing of what the door accepts, and no ADR-0087 conversion carries it. So 18.x removes them from the form's options and answers with a warning, and the 400 is filed for 19.0. The exception: the seat measures that no stored row or caller ever sent them, and records that reading here.
Not release-blocking. It is a candidate for 18.0.1 / 18.1.
Claim: PM loop round 3 · 2026-10-20T21:55Z
Session: session_01UmqzD97e14iws6hmVhgZ5y
Account: os-support-ai (the seat's linked user as GET /user answers it; always the card's assignee)
Branch: claude/issue-24773-oauth-application-type
Worktree: objectstack-issue-24773
Domain: domain:services
Seat: domain:services#2 (seat post #21118)
File surface (half 2a, services; the ruling's plugin-auth part):
packages/plugins/plugin-auth/src/auth-plugin.ts: the OAuth client register wrapper only (the createOAuthClient path that forwards type: safeType, ~:3661 at 78ab6474c);
new test files under plugin-auth/src/;
one .changeset/24773-*.md.
⛔ Not packages/platform-objects/**: the form's option retirement and its copy are half 2b, a separate platform-objects PR the seat dispatches after this one lands, declared cross-lane then.
⛔ Not packages/spec/**, not .github/workflows/**, and no other region of auth-plugin.ts.
Stop on a breach and explain it in the report.
Container & model: M, mode:subagent, model: opus (TIER_DEFAULT; dispatch-gates --tier on this surface, derived at 78ab6474c: no path-derived mandate, floor sonnet, default opus, ceiling fable)
Clause-②: no — ruling D makes the chosen type take effect under the auth library's own rule; that is a runtime authentication behaviour on the human floor, which the maintainer answered. The dev measures and states it.
Responsibility: plugin-auth's register wrapper forwards the form's type as 'type', which @better-auth/oauth-provider drops, so every application is judged and stored as web | the auth library's application_type, which the SDK path already sends | every admin registering an OAuth application from the console; measured in the maintainer's HotCRM pass (objectstack-ai/hotcrm#2188)
Thread-read: 6293344532
Serial constraints cleared: PR #24959 (#24766, applyBrand in auth-plugin.ts) landed as 78ab6474c. No open PR touches auth-plugin.ts or an OAuth file (every open PR's file list read this round). No in-flight domain:services claim touches plugin-auth: #24757 is plugin-security, and seat 1's #24962 / #24966 are plugin-security / plugin-webhooks.
This dispatch carries out the maintainer's ruling D (6293344532) under 「甲」 (#60156292005854). Honouring the declared type ships in 18.x. user-agent-based / public get a warning in 18.x, and their 400 is filed for 19.0, unless the measured exception holds: no stored row or caller ever sent them. public is measured first.
Widened in place for half 2b (after half 2a's report and the seat record 6303676890; same card, same seat, a second dev after the first finished):
Branch: claude/issue-24773-oauth-form-types
Worktree: objectstack-issue-24773 (re-created; half 2a's was removed)
File surface (half 2b), cross-lane into domain:engine and declared here:
packages/platform-objects/src/identity/sys-oauth-application.object.ts: the register_oauth_application action's type param options and the redirectURLs help text; the stored type select and the public column only as measured and stated, ⛔ not narrowed in 18.x;
its generated translation bundles packages/platform-objects/src/apps/translations/{en,zh-CN,ja-JP,es-ES}.objects.generated.ts;
new test files under platform-objects/src/;
one .changeset/24773-*.md.
Container & model (2b): S, mode:subagent, model: opus (TIER_DEFAULT; dispatch-gates --tier on this surface, derived at 4b8f597ba: no path-derived mandate).
Clause-② (2b): expected no, provided the register door's accept set is unchanged; the dev measures whether anything server-side refuses a value the form stops offering.
Serial constraints cleared (2b): no open PR touches sys-oauth-application.object.ts or the platform-objects translation bundles (every open PR's file list read). PR #24992 (half 2a, plugin-auth) is disjoint.
os-dev-report
{
"issue": 24773,
"status": "needs_decision",
"branch": "claude/issue-24773-oauth-application-type",
"pr": "#24992",
"session": "session_01UmqzD97e14iws6hmVhgZ5y. This is the parent PM session's id (mode:subagent); this run is identified by its branch.",
"premise_still_valid": true,
"summary": "Half 2a is built: draft PR #24992 (Part of #24773) at head e9f4deb. The sys-oauth-application/register wrapper in auth-plugin.ts now forwards the form's Application Type as application_type. web and native are forwarded, and the provider's own redirect-URI rule decides, unchanged. user-agent-based, public and any other value keep today's outcome (registered and judged as web, with a secret) and log exactly one ctx.logger.warn per registration, meta { requestedType, registeredAs: 'web' }. An absent type stays web with no warning. Half 1's error mapping is unchanged. status is needs_decision for ONE question only. @better-auth/oauth-provider 1.7.3 has a real counterpart for 'public' (token_endpoint_auth_method 'none', a client issued with no secret), so per the dispatch it is not forwarded or mapped. The mapping is open_questions[0]. Everything else in the dispatched scope is done. Premise holds: on base 78ab647 the wrapper sent type, the provider stripped it, and native + http://localhost:4001/callback got the web refusal (measured, before-fix run). PM assumption 3 is partly disproved: the SDK's oauth.applications.register request type does not declare application_type (see pm_assumptions).",
"public_measurement": "MEASURED on the installed @better-auth/oauth-provider 1.7.3 (node_modules, dist/authorize-9whjxVLJ.mjs): 'public' HAS a real counterpart. (1) persistOAuthClientRegistration (:1921, :1929) sets isPublic = body.token_endpoint_auth_method === 'none' and then issues and stores NO client secret (clientSecret undefined). (2) checkOAuthClient accepts 'none' via getSupportedAuthMethods(opts, { includeNone: true }) (:1759). (3) The /oauth2/create-client body declares token_endpoint_auth_method (:2694) and application_type z.enum(['web','native']) (:2695). 'public' is a value on the auth-method axis, never an application_type. (4) Discovery advertises 'none' among token_endpoint_auth_methods_supported when public clients are supported (:749). So 'public' was NOT forwarded or mapped. In this PR it behaves like user-agent-based (web, with a secret, plus the warning). The mapping is open_questions[0]. Related: in the provider's earlier lines (1.7.0-rc.2 and 1.6.33, read from npm tarballs, not installed), 'user-agent-based' was valid ONLY for a public client ('Type must be native or user-agent-based for public applications'). In 1.7.x a browser public client is application_type web plus token_endpoint_auth_method none. So the same question covers both form values. In-repo use of the counterpart: MCP clients register as public native clients through DCR (auth-manager.mcp-oauth-resource.test.ts:235, token_endpoint_auth_method 'none' + application_type 'native'). The SDK declares token_endpoint_auth_method 'none' on oauth.applications.register.",
"exception_reading": "For the seat to record and rule. Question: did any stored row or any caller ever send user-agent-based / public? Reading: the exception is NOT established. STORED ROWS, console door. Measured on a real ObjectQL engine + SqlDriver (better-sqlite3 :memory:) with plugin-auth's authIdentityObjects, through the real route on real better-auth. On base 78ab647 (before-fix run) and on head e9f4deb, a registration with type user-agent-based, public or spa stores sys_oauth_application.type = 'web', public = null and token_endpoint_auth_method = 'client_secret_basic'. No column holds the form's value. Pinned in the new test. 1.7.3 declares only web/native for application_type on create (/oauth2/create-client :2695, /admin/oauth2/create-client :2523) and update (/admin/oauth2/update-client :2885, /oauth2/update-client :2923). checkOAuthClient, which every registration path runs, refuses any other value (:1770). Its record has no 'public' field (OAUTH_CLIENT_RECORD_FIELD_NAMES :1034), so the installed provider can write only 'web' or 'native' into that column. EARLIER PROVIDER LINES (npm tarballs read in the scratchpad, not installed): (a) @better-auth/oauth-provider 1.7.0-rc.2 and 1.6.33 declared type z.enum(web, native, user-agent-based) and refused any non-'web' type on a confidential client. The door never sends token_endpoint_auth_method, so user-agent-based (and native) was refused at this door then, and the wrapper coerced 'public' to 'web' (it was not in allowedTypes). (b) better-auth 1.6.33's oidc-provider plugin wrote type: 'web' on every /oauth2/register. LIMITS: (i) Repo history before 2026-09-07 is not in this shallow checkout (root ac76425). At that root plugin-auth already pinned ^1.7.2 and the wrapper's allowedTypes was identical, so which provider line the wrapper ran on earlier was not measured from git. (ii) Rows that OTHER doors wrote under rc.2 / 1.6.x cannot be measured: the SDK create-client and DCR accepted type user-agent-based for public clients there. Deployments' databases are out of reach. CALLERS: (a) In-repo, zero producers send either value to this door. Tests send no type or 'web' (in-process-session-renewal.pin.test.ts:398, oauth-register-refusal-status.test.ts). Zero in examples/, packages/qa and content/docs. The SDK never builds this URL (auth-route-ledger disposition server-only). git grep 'user-agent-based' hits only the form (sys-oauth-application.object.ts:156, :425), its generated translations and the wrapper. (b) HotCRM main 1cc6861a3: zero files for 'sys-oauth-application/register', 'user-agent-based', 'application_type', 'oauth2/create-client' and 'applications.register' (control: case-insensitive 'oauth', 18 files). (c) objectui origin/main 12cde0492 (local ref, not re-fetched): zero files for the door path, 'user-agent-based' and 'application_type' (control: 'sys_oauth_application', 5 files). (d) 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 is not measurable, because no request log exists in reach.",
"pm_assumptions": [
"A1 HOLDS. At 78ab647 the wrapper (auth-plugin.ts:3661) forwarded type: safeType. The 1.7.3 create-client body declares application_type and no type, so type is stripped and every registration is judged and stored as web. Measured: precondition pin ('type: native' + loopback is refused with invalid_redirect_uri) and the before-fix run (native + localhost: 400 web refusal).",
"A2 HOLDS, with two refinements. (a) The web rule is https AND not loopback. (b) The native http rule allows exactly localhost / 127.0.0.1 / [::1], any port (validateClientRedirectUri :1685). But the body schema's SafeUrlSchema (@better-auth/core utils/redirect-uri) refuses plain http on a non-loopback host for EVERY type, BEFORE the per-type rule. So native + http://app.example.com/callback is refused as a body-schema ValidationError (400, no upstreamError), not as invalid_redirect_uri. And http://127.0.0.2:4001 passes the body schema but is refused by the native exact-host rule. Native + https on a loopback host is refused ('native clients must not use https loopback redirect URIs').",
"A3 PARTLY DISPROVED. The SDK's route /oauth2/create-client accepts application_type, and the SDK response type OAuthApplication declares it (packages/client/src/index.ts:1055). But the SDK's oauth.applications.register REQUEST type (index.ts:4535-4547) does not declare application_type. A typed caller can register a native client only through a cast; the body is passed through JSON.stringify(req) unchanged. Noted in Acceptance notes, not filed."
],
"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 real route registration on a real Hono app, over a real ObjectQL + SqlDriver (better-sqlite3) engine. 'Stored as' is read at driver level. Cases: 2 provider preconditions; web + localhost refused (400 OAUTH_REGISTER_FAILED, invalid_redirect_uri, nothing stored, no warning); native + localhost and native + 127.0.0.1 accepted and stored 'native'; native + http://app.example.com refused (body schema, 400, nothing stored); native + http://127.0.0.2:4001 refused (native exact-host rule); web + https and no type stored 'web' with no warning; user-agent-based / public / spa + https stored 'web', no column holds the value, not a public client, exactly 1 warning with meta; the same three + localhost refused as web with exactly 1 warning. BEFORE the fix, on 78ab647: 'Tests 9 failed | 5 passed (14)'. 2 native-loopback cases failed with 'expected 400 to be 200' (web refusal); 6 unhonoured cases failed only on 'expected [] to have a length of 1 but got +0', after their storage assertions passed; 1 was this file's own first assertion expecting invalid_redirect_uri on the non-loopback native case, which the body schema answers (corrected). AFTER, on e9f4deb: this file + oauth-register-refusal-status.test.ts gave 'Test Files 2 passed (2) / Tests 32 passed (32)' (os-verify-lock VERDICT command-exit 0). Full package suite at d674628 (plugin-auth byte-identical at e9f4deb): 'pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2' gave 'Test Files 174 passed (174) / Tests 3334 passed | 10 skipped (3344)' (VERDICT command-exit 0). 'pnpm --filter @objectstack/plugin-auth typecheck' gave exit 0 after building plugin-auth (the first attempt exited 2 because tsconfig.examples.json reads the package's own dist): 'check:test-typecheck: OK ... 23 pinned signature(s) held', no debt added. The new file is in the tsconfig.test.json program (--listFilesOnly: 1 hit). ABLATION, through scripts/ablation-replace.mjs (anchor x1 -> x0, blob changed, disk-verified). The runner had its own EXIT/INT/TERM trap; every restore was verified as blob 01004ce16 == HEAD with git diff HEAD empty. The test imports ./auth-plugin from source, so there is no dist leg. Leg 1: application_type: applicationType -> type: applicationType (the old type forwarding) gave 'Tests 2 failed | 13 passed', native + localhost / 127.0.0.1 'expected 400 to be 200'. Leg 2: applicationType = type -> 'native' gave 2 failed: web + localhost 'expected 200 to be 400'; web + https stored 'native'. Leg 3: unhonoured values forwarded as native gave 6 failed (all six unhonoured cases). Its first attempt was refused by the tool (the anchor was contained in the replacement, 'anchor count moved 1 -> 1'), ran nothing, and was redone with a non-overlapping anchor. Leg 4: the door rewrites the URI to an accepted one (http://app. -> https://app., 127.0.0.2 -> 127.0.0.1) gave 2 failed, both native negative pins 'expected 200 to be 400'. Logs: scratchpad issue-24773/2a/before-fix.log, after-fix2.log, final-files.log, suite-full.log, typecheck2.log, ablate-*.log.",
"gates": "Local union: 'node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack' at e9f4deb derived 72 commands (35 pnpm, 37 node), identical to the seat's list at 78ab647. Each ran as 'cmd > log 2>&1; rc=$?'. 71/72 exited 0 on the first pass. pnpm check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET, no whole-repo dist). After 'turbo run build --filter=!@objectstack/docs --concurrency=2' (76 tasks, 75 cached) it exited 0: '112 published require entry point(s) across 69 package(s) load'. '--ran' printed 'Run reconciliation — 72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN' and '✓ dispatch-gates --ran: 72 derived famil(ies) accounted for'. Among the 72: 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, check:type-check-debt, check:query-options-erasure. All exit 0. Not run locally: test:repo (CI Test Core); check:dev-prereqs and check:skill-frame-freshness (no spec build input or frame file touched); the CI-only type-check lanes and the CI jobs dispatch-gates lists outside the 72. CI on PR #24992 at e9f4deb, read right after pr_create: 'No other open PR may claim the same issue' success; 18.x upgrade harness and Packed-tarball smoke skipped (opt-in); every Lint · and Type Check · lane, Check Changeset, Check PR Size, Governed Surface Queue Guard, 'Part-of PR must not also close its card', 'The card this PR closes must claim this branch', 'No other open PR may claim the same single-writer path', Check Documentation Links and 'Flag docs affected by code changes' were in_progress. Test Core and Build Core were not yet listed. in_progress is the honest value; CI convergence is the PM's.",
"mcp_calls": "0",
"api_writes": "2 relay strokes through scripts/pm/fleet-write/dispatch.mjs, each one POST /repos/objectstack-ai/objectstack/dispatches. (1) pr_create, run 39511280255, executed POST /repos/objectstack-ai/objectstack/pulls (created #24992, draft) and POST /repos//issues/24992/assignees (os-support-ai). Read-back: 12074 bytes sent, 12074 stored, identical; assignees os-support-ai. (2) comment, POST /repos//issues/24773/comments: this report. Labels: none (none named by the dispatch; skip-changeset does not apply, because plugin-auth publishes and a patch changeset is included). git push x3 (the empty-branch probe, d674628, then d58f7da + merge e9f4deb) is not REST. Reads were plain gh api GETs: #24773 and its comments, #6015 comment 6292005854, PR #24992 and its check-runs.",
"open_questions": [
{
"question": "Should the Console door map the form's 'public' (and, by the same reading, 'user-agent-based') to the provider's public client, i.e. token_endpoint_auth_method 'none', with the client issued no secret? @better-auth/oauth-provider 1.7.3 has that counterpart (see public_measurement), but 'public' is not an application_type. Issuing a secretless client from the Console is a security-posture change on the human floor, so it was not mapped. In this PR it is registered as a confidential web client with a secret, plus the warning.",
"options": [
"A. No mapping, as ruled (D under 「甲」). 'public' stays a confidential web client with one warning in 18.x. Half 2b removes it from the form, and the 400 is filed for 19.0. Public clients stay available where they are used today: DCR (MCP clients register token_endpoint_auth_method 'none' + native) and the SDK (oauth.applications.register declares token_endpoint_auth_method 'none'). Cost: none beyond the ruled 2b and 19.0 cards. Business need: zero measured producers send 'public' to this door (in-repo, HotCRM main, objectui), and the measured public-client consumer (MCP) does not use the Console. Long-term: the door honours exactly the axis the provider judges. AI-safety: after 2b the form cannot declare a value the door does not honour, with a loud warning meanwhile. Startup focus: adds nothing.",
"B. Map 'public' (and 'user-agent-based') to { application_type: 'web', token_endpoint_auth_method: 'none' }. Cost: about 5 wrapper lines plus pins. 2b must also change the result dialog, which today promises a client_secret 'shown only once'. Business need: none measured. Long-term: one select value becomes two provider settings, and the mapping is lossy (a public NATIVE client cannot be expressed). AI-safety: worst. 'Public' reads as 'public-facing website' and would silently yield a client with no secret, a security surprise. Startup focus: adds a capability with zero measured pull.",
"C. Model both provider axes in the form: Application Type (web | native) plus Client authentication (confidential with secret | public, token_endpoint_auth_method 'none'). Retire 'user-agent-based' and 'public' from the type select. Cost: wrapper and platform-objects form, copy and dialog changes (both lanes). Business need: none measured. Long-term: the most faithful to the provider's model. AI-safety: explicit and hard to misread. Startup focus: adds a form field and a capability without pull."
],
"recommendation": "A. Business need is the deciding axis: nothing measured asks the Console for secretless clients, and the real public-client consumer (MCP) registers through DCR, already served. B is a lossy translation that turns a misreadable word into a missing secret, the shape the AI-safety axis forbids. C is right only if a Console public-client need is measured later, and startup focus says not before then. A keeps today's warning text accurate ('A future major version refuses this value'). If the seat picks B or C, the warning for 'public' changes in that PR. No new gate is recommended, and none was named by the maintainer."
}
],
"out_of_scope_findings": [
"carrier: none (承接者:无) · noted, not filed, in the PR's Acceptance notes. The SDK oauth.applications.register request type (packages/client/src/index.ts:4535-4547) does not declare application_type, while its route accepts it and its response type declares it. A typed caller can register a native client only through a cast. Not class a/b/c: it is not a defect, and the SDK does not claim the capability.",
"carrier: #24773 half 2b (the seat) · noted, not filed. The stored column sys_oauth_application.type (sys-oauth-application.object.ts:425) also lists 'user-agent-based' and 'public', which 1.7.3 never writes. The 'public' boolean column (:432) is never written by 1.7.3: its record has no such field, and it was null in every stored row measured here. For the seat to fold into 2b or rule on."
],
"line_budget": "382 changed lines (+379 / -3) across 3 files, per dispatch-gates at e9f4deb, against the 3000 human-merge threshold: under. auth-plugin.ts +32 / -3, register wrapper only; new test +334; changeset +13. No governed surface touched; skills/** untouched, so no ratchet ledger applies.",
"deviations": [
"The card's ablation 'put back type: safeType' was spelled 'type: applicationType'. safeType no longer exists, and the forwarded value is the same for web/native, so it is the same defect. It turned the two native pins red as the card predicted.",
"Added beyond the card's pin list: 2 provider preconditions, a native + http://127.0.0.2 negative pin (the exact-host rule), an arbitrary value 'spa', and a no-type case. Ablation legs 2-4 cover the negative pins, per the lane commitment.",
"The card's 'Native + a non-loopback http URI refused' is answered by the provider's body schema (SafeUrlSchema), not its native rule, so its pin asserts 400 OAUTH_REGISTER_FAILED with no upstreamError. The 127.0.0.2 pin covers the native rule itself.",
"Edge semantics chosen in the wrapper, all keeping today's web outcome: null is treated like absent (no warning); a non-string type warns as '(number)' / '(object)' / '(boolean)'; a string value is truncated to 64 chars in the warning.",
"The warning text ends 'A future major version refuses this value.' for every unhonoured value, 'public' included, per the ruling as it stands. If the seat maps 'public' (open_questions[0] B/C), that sentence goes with the change.",
"origin/main advanced to e4d604e (2 commits: plugin-security #24985 and release-verify-npm #24989; no overlap with this diff) after my merge of 7adfaec and before pr_create. The PR was opened at e9f4deb without re-merging; the gate union and test reads are at e9f4deb.",
"The full plugin-auth suite ran at d674628. e9f4deb adds only the changeset wording commit and the main merge (objectui pin files), with plugin-auth byte-identical (git diff --stat empty). The two OAuth register files were re-run at e9f4deb.",
"For the HotCRM caller reading I ran 'git fetch origin main' in the shared /home/user/hotcrm checkout, which advanced its refs/remotes/origin/main to 1cc6861a3 (a read-only fetch, no working-tree change). The objectui reading used its existing origin/main ref 12cde0492 without fetching, so it may be stale.",
"For the earlier-provider-line reading I downloaded npm tarballs (@better-auth/oauth-provider 1.7.0-rc.2 and 1.6.33, better-auth 1.6.33) into scratchpad issue-24773/2a/npm-dl and read them with grep/sed only. Nothing from them was executed or installed.",
"The commit trailer uses AGENTS.md's model-free pair (Claude-Session + Co-authored-by: Claude), not the harness reminder's model-named form, because the pre-push hook refuses a model identifier.",
"The PR body's first line is 'Part of #24773: half 2a, ...' (colon, then prose). The H7 Part-of/closing-keyword check on the body returned null (clean), and no closing keyword is adjacent to any issue number.",
"No exported symbol was renamed or removed, so check-platform-checklist --changed-from was not run."
],
"files_changed": [
"packages/plugins/plugin-auth/src/auth-plugin.ts",
"packages/plugins/plugin-auth/src/oauth-register-application-type.test.ts",
".changeset/24773-oauth-application-type.md"
],
"pr_body_edits": "none"
}
Seat record for half 2a: the public measurement, the 「甲」 exception reading, and the seat's call · domain:services seat 2 (seat post #21118) · os-support-ai · session_01UmqzD97e14iws6hmVhgZ5y · 2026-10-20T22:58Z
The ruling (6293344532) asked the seat to measure public first and to record the exception reading here. Both come from the half-2a dev report on this card and PR #24992, measured on the installed @better-auth/oauth-provider 1.7.3.
1. public. The library has a real counterpart, but on another axis: a public client is token_endpoint_auth_method: 'none', issued with no client secret. It is not an application_type, which accepts only web | native.
In 1.7.x a browser public client is web + none, so the same reading covers user-agent-based.
MCP clients already register as public native clients through dynamic registration, not through the Console.
Seat's call: no mapping (the dev's option A), which is ruling D as written.
Ruling D already disposes of both values: the form drops them in 18.x (half 2b), the door warns, and the 400 waits for 19.0.
Mapping them to a secretless client (option B), or adding a client-authentication field to the form (option C), would be a new Console capability that issues clients with no secret. That is a security-posture change on the human floor, with zero measured pull: no producer sends either value to this door in the repo, HotCRM or objectui.
If a Console public-client need is ever measured, it comes back as a decision card, not as a change to this PR.
3. Carried into half 2b: the stored sys_oauth_application.type select still lists user-agent-based and public, and the public boolean column is never written by 1.7.3. Half 2b measures and states what it does with each.
the Console's register door forwards the chosen type as application_type, so a Native client can register http://localhost, http://127.0.0.1 or http://[::1] redirect URIs and a Web client keeps the https rule;
user-agent-based / public keep today's outcome, with one server warning each.
ACCEPT 6303941375.
Still on this card:
half 2b (the form's options and help text, platform-objects), in flight under the widened claim 6302570331;
then the 19.0 refusal (400) of user-agent-based / public. The card moves to pm:on-hold for it when 2b lands.
The card stays pm:dispatched while 2b is in flight.
Activity
objectstack-fleet commentedon Oct 20, 2026
Claim: PM loop round 1 · 2026-10-20T08:53Z
Session:
session_01UmqzD97e14iws6hmVhgZ5yAccount:
os-support-ai(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-24773-oauth-register-refusalWorktree:
objectstack-issue-24773Domain:
domain:servicesSeat:
domain:services#2(seat post #21118)File surface:
packages/plugins/plugin-auth/src/auth-plugin.ts(thesys-oauth-application/registerwrapper only), its tests, one new.changeset/24773-*.md. ⛔ The redirect-URI acceptance rule is not widened in this dispatch: whetherhttp://localhost/127.0.0.1is accepted for awebclient is measured and reported, not changed (an authentication-flow boundary, human floor). ⛔ Notpackages/spec/**, ⛔ no new error code (stop on breach; explain in the report)Container & model:
M,mode:subagent,model: opus(TIER_DEFAULT;dispatch-gates --tieron this surface: no path-derived mandate, floor sonnet, default opus, ceiling fable)Clause-②: no
Responsibility:
plugin-auth's sys-oauth-application/register wrapper answers a vendor validation refusal as HTTP 500 (createOAuthClient throws with no status) | the wrapper's own OAUTH_REGISTER_FAILED envelope already carries the vendor's discriminator | every admin registering an OAuth application with a refused redirect URI; measured on a HotCRM install 2026-10-20 (card)Thread-read: none
Serial constraints cleared: no open PR touches
plugin-auth/src/auth-plugin.ts(the file lists of all 18 open PRs read at this claim). Seat 1's #24739 (PR #24795) landed as25e16ab00; PR #24791 (the session-revoke door in the same file) is no longer open.Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
os-dev-report
{
"issue": 24773,
"status": "done",
"branch": "claude/issue-24773-oauth-register-refusal",
"pr": "#24853",
"session": "session_01UmqzD97e14iws6hmVhgZ5y — the parent PM session id (mode:subagent); this run is identified by its branch",
"premise_still_valid": true,
"summary": "Half 1 is done. POST /api/v1/auth/sys-oauth-application/register answered better-auth's refusals as 500 because better-call's APIError carries its status as the NUMBER statusCode (400) and the NAME status ('BAD_REQUEST'), and the wrapper read only a numeric status (M1 confirmed). The body-schema ValidationError, with numeric status 400, already answered 400 from the same door. The wrapper now reads the status through @objectstack/types resolveThrownHttpError, the repo's one rule for a thrown error's status (numeric status, then statusCode, else the 500 fallback). So invalid_redirect_uri answers 400, a request with no session answers 401 (same defect at the same line, disclosed in the PR), a storage fault stays 500, and the code stays OAUTH_REGISTER_FAILED with details.upstreamError. No new error code, no acceptance change. Half 2 was measured only. M2's premise is falsified: the vendor's web predicate (@better-auth/oauth-provider 1.7.3 validateClientRedirectUri) is
!https || loopback ⇒ refuse. Its text means https AND a non-loopback host, so every loopback form is refused for web in both schemes (localhost, 127.0.0.1, [::1], with or without a port, https://localhost too). No config governs it. The native rule allows http on exact localhost/127.0.0.1/[::1]. The measured blocker for the dev flow is a separate wrapper defect: the Console's Application Type select is sent astype, which the vendor body schema strips (it declares application_type), so every registration is judged and stored as web. Options are in open_questions. The card's own reading that loopback detection misclassifies localhost is not what happens; its 500 half is real and fixed.","tests": "New packages/plugins/plugin-auth/src/oauth-register-refusal-status.test.ts runs real better-auth (OIDC provider on) behind the real route registration on a real Hono app. It has 7 cases: a precondition that the fixture refusal is a named-status APIError (status 'BAD_REQUEST', statusCode 400); web + http://localhost:4001/callback → 400 OAUTH_REGISTER_FAILED invalid_redirect_uri with no row written; web + http://127.0.0.1:4001/callback → 400 likewise; no session → 401; body-schema refusal → 400 (preserved); storage fault → 500; https → 200 with one row. Before the fix, a probe on the same stack measured every loopback case at 500 and no-session at 500. Ablation on head 5dc8add, applied with scripts/ablation-replace.mjs, which verified the mutation on disk (anchor 1→0, replacement 0→1, blob 03e026dea→a6bb1a7f3 / →16aa6cab0). The restore was verified as blob == HEAD with git diff HEAD empty, and the shell had its own EXIT/INT/TERM trap. Leg 1, old numeric-status read: 'Tests 3 failed | 14 passed (17)', failing with 'expected 500 to be 400' x2 and 'expected 500 to be 401'. Leg 2, fallback 400: 'Tests 1 failed | 16 passed (17)', failing with 'expected 400 to be 500' on the storage fault. Both went red in the predicted direction. Final head 622e30a (merge of main 5cb6e6e; both files byte-identical to 5dc8add):
pnpm --filter @objectstack/plugin-auth exec vitest rungave 'Test Files 169 passed (169) / Tests 3241 passed | 10 skipped (3251)' (os-verify-lock VERDICT command-exit 0).pnpm --filter @objectstack/plugin-auth typecheckexited 0 ('check:test-typecheck: OK').pnpm --filter @objectstack/spec check:generatedexited 0 after the post-merge rebuild. The 17 tests in the new file include the 10 that the createMemoryEngine import re-registers, which is the existing precedent shared by 41 files.","gates": "Local union derived by
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat 622e30a: 72 commands, each run withcmd > log 2>&1; rc=$?. Result: 72/72 exit 0, including check:error-status-conformance, check:route-envelope, check:auth-mount-ledger, check:durability-log-level, check:issue-citations, check:nul-bytes, check:dual-build-cjs-loads (112 require entry points load), check:type-check-debt and check:test-source-alias. The--ranreconciliation printed 'Run reconciliation — 72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN' ('a DERIVED zero — all 72 recorded an exit code'). An earlier pass at 5dc8add also had 71/72 exit 0, with check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET because the whole-repo dist was missing. The post-merge closure build cleared that prerequisite, and the final head reads green. The records are in scratchpad issue-24773/gates-results.tsv and gates/NNN.log. Not run locally: test:repo (CI Test Core) and the local-only families check:dev-prereqs / check:skill-frame-freshness (no spec build input or frame file touched). CI on PR #24853 at 622e30a, read right after pr_create: 'No other open PR may claim the same issue' success; Check Changeset, Lint (all lanes), Type Check (all lanes), Governed Surface Queue Guard, 'Part-of PR must not also close its card' and 'The card this PR closes must claim this branch' were in_progress; the two opt-in jobs were skipped. Test Core and Build Core were not yet listed. in_progress is the honest value; CI convergence is the PM's.","mcp_calls": "0",
"api_writes": "2 relay strokes (scripts/pm/fleet-write/dispatch.mjs, each one POST /repos/objectstack-ai/objectstack/dispatches): (1) pr_create, run 39409267597, which executed POST /repos/objectstack-ai/objectstack/pulls (created #24853, draft; read-back 6676 bytes sent and stored, identical) and POST /repos//issues/24853/assignees (os-support-ai, listed); (2) comment, POST /repos//issues/24773/comments, which is this report. Labels: none. skip-changeset does not apply because plugin-auth publishes and a patch changeset is included. git push x5 (branch probe, 3 commits, merge) is not REST. Reads were plain gh api GETs on #24773, its comments, PR #24853 and its check-runs.",
"open_questions": [
{
"question": "Half 2: may a client registered through the Console door POST /api/v1/auth/sys-oauth-application/register use an http://localhost / http://127.0.0.1 redirect URI, and how should the form's Application Type select reach the provider? This is an authentication-flow acceptance rule on the human floor. Measured facts, @better-auth/oauth-provider 1.7.3: (1) web is refused unless https AND non-loopback, in every environment, with no option; (2) native allows http only on exact localhost / 127.0.0.1 / [::1], any port; (3) the door forwards the select as
type, which the vendor body schema strips, so every registration is judged and stored as application_type web, and type 'native' + http://127.0.0.1:4001/callback is refused with the web rule; (4) the SDK's oauth.applications.register (/oauth2/create-client) already carries application_type web|native; (5) the field help says 'Must use https:// in production.'","options": [
"A — Keep the vendor rule and correct only the words: the field help in platform-objects states the per-type rule. The type select stays dropped. Cost: one small platform-objects PR (other lane). Business need: not met; no Console path registers a loopback client, and only the SDK path with application_type native remains. Long-term: leaves a declared-but-unenforced form field (Prime Directive #10). AI-safety: an author or AI declaring type 'native' keeps silently getting web. Startup focus: adds nothing, but keeps a dead field.",
"B — Accept http loopback for web outside production. The vendor predicate takes no option, so this means forking the vendor's registration validation: pre-validating and registering another way, or writing the client row ourselves. Cost: medium-large, re-paid on every better-auth bump, plus a production/non-production posture switch. Business need: serves local testing of a browser web app as a web client, which no measured consumer beyond this card asks for. Long-term: a workaround around a security validator. AI-safety: the same metadata gets a different verdict per deployment. Startup focus: adds surface (a switch and a fork).",
"C — Accept http loopback for web always, as the card read the vendor's text. Premise falsified: the text and the predicate both say https AND non-loopback for web. Cost: the same fork as B, with a permanent relaxation of the web-client boundary. All four axes are as for B but worse on the security posture.",
"D — Honour the declared type: the door forwards the select as the vendor's application_type (web | native). Native gets the vendor's RFC 8252 loopback rule; web keeps https + non-loopback. 'user-agent-based', which has no vendor counterpart, is refused loudly (400, replacing today's silent coercion to web) and retired from the select. The field help states the per-type rule. Cost: small; about 10 lines plus pins in the plugin-auth wrapper, and the select options and help text in platform-objects (other lane). Acceptance changes only for registrations that declare native, which today are judged as web."
],
"recommendation": "D (with A's help-text correction folded in), on the four axes. Business need, measured: the form's type param is written by every Console registration (platform-objects sys-oauth-application.object.ts:153-157) and read by nothing; examples/ registers no OAuth client; the measured need is the maintainer's dev-admin pass (hotcrm#2188 F-22), which a native-typed registration meets through the vendor's own loopback path. Caveat: a developer testing a browser web app on localhost would register it as Native under D. If the maintainer rules that need real and not served by Native, that points to B, at its fork cost. Long-term: D is contract-first; it enforces the declared field and keeps the vendor validator as the single authority with no fork, whereas B/C fork a security validator. AI-safety: D makes the declared
typereal and refuses an unknown one loudly; A keeps exactly the declared-but-not-honoured shape the axis forbids, and B makes acceptance deployment-dependent. Startup focus: D adds no capability and retires one dead option (user-agent-based). No new gate is recommended; none was named by the maintainer."}
],
"out_of_scope_findings": [
"class: a · reach: public door POST /api/v1/auth/sys-oauth-application/register with {type:'native', redirectURLs:'http://127.0.0.1:4001/callback'} answers the web refusal 'web clients require https redirect URIs on non-loopback hosts' (400 after #24853, 500 before), and {type:'native'} with an https URI stores application_type 'web'. The Console's Application Type select never reaches the provider · evidence: auth-plugin.ts register wrapper forwards
type: safeType(and silently coerces an unknown type to web); @better-auth/oauth-provider 1.7.3 dist authorize-9whjxVLJ.mjs createOAuthClient body declares application_type and no type; measured on the real stack (probe on base c3a8e07 before any edit, issue-24773/probe-before.log); called directly with application_type 'native', the vendor accepts http://localhost:4001 and http://127.0.0.1:4001 · Seam: spec:sys_oauth_application create action paramtype→ runtime:plugin-auth sys-oauth-application/register wrapper → vendor /oauth2/create-client (strips) · fold into the #24773 half-2 decision card, because fixing it changes acceptance for native registrations (human floor), not a standalone card · dedupe words: oauth application type native dropped, application_type stripped, register native localhost, user-agent-based","class: a (misleading copy) · reach: the Console Register OAuth Application form (sys_oauth_application create action, packages/platform-objects/src/identity/sys-oauth-application.object.ts:152) shows 'One URL per line. Must use https:// in production.', while a web client is refused for any loopback host in every environment and for http on any host. The maintainer's QA pass read loopback http as allowed from it (card: 'Expected: the field's help ... (loopback http allowed)') · fold into the #24773 half-2 decision card; the text follows whichever rule is decided · dedupe words: oauth redirect help text https production, redirectURLs helpText",
"carrier: none (承接者:无) · noted, not filed, in the PR's Acceptance notes: on a server fault the register door answers with the thrown error's raw message (measured: the test's 'storage fault (test)') and logs nothing on that inner branch. This is pre-existing, unchanged, with no measured leak of sensitive data."
],
"line_budget": "229 changed lines (+227 / -2) across 3 files, per dispatch-gates at 622e30a against the 3000 human-merge threshold: under. Source: auth-plugin.ts +12 / -2 (import + comment + one line); test +203; changeset +12. No governed surface is touched, and skills/** is untouched, so no ratchet ledger applies.",
"deviations": [
"The PM's suggested route mapped the vendor refusal to 400. The landed route reads the refusal's own declared status through @objectstack/types resolveThrownHttpError (the repo's shared rule), which gives 400 for invalid_redirect_uri and also 401 for a missing session (UNAUTHORIZED). That is the same defect at the same line; it is disclosed in the PR and the changeset, and pinned.",
"The first commit hand-read statusCode inline; a later commit (5dc8add) switched to resolveThrownHttpError once that rule was found. The ablation was re-run on the final code, and those are the cited legs. In the first (superseded) ablation, my own supplementary echo was mis-quoted and printed '0 marker'. The tool's own disk verification (replacement 0→1, blob changed) and the observed 500s show that the mutation landed. The final-code legs' echo read correctly (1 marker).",
"I stopped two of my own background runs, by their recorded process-group ids 910 and 1586, after the source changed mid-run, and re-ran both on the final tree. No other process was touched.",
"Merged origin/main (5cb6e6e) before opening the PR, per AGENTS §10. The post-merge closure build used the filters '@objectstack/plugin-auth...' and '^...', which also built downstream dependents. That was wider than needed, but it is what satisfied check:dual-build-cjs-loads' whole-repo prerequisite.",
"test:repo was not run locally (CI Test Core). check:dev-prereqs and check:skill-frame-freshness were not run because neither trigger applies.",
"The report status is 'done' for the dispatched scope (half 1 fixed, half 2 measured and reported). The card stays open: the PR says 'Part of #24773', with no closing keyword."
],
"files_changed": [
"packages/plugins/plugin-auth/src/auth-plugin.ts",
"packages/plugins/plugin-auth/src/oauth-register-refusal-status.test.ts",
".changeset/24773-oauth-register-refusal-status.md"
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
ACCEPT → PR #24853 comment
6289090336(half 1) ·domain:servicesseat 2 ·session_01UmqzD97e14iws6hmVhgZ5y· 2026-10-20T10:43ZGenerated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
Landed (half 1) ·
domain:servicesseat 2 ·os-support-ai·session_01UmqzD97e14iws6hmVhgZ5y· 2026-10-20T11:30Zadded_to_merge_queue2026-10-20T11:06Z →merged2026-10-20T11:29Zorigin/main950f949a9cis an ancestor (git merge-base --is-ancestor, exit 0)origin/mainauth-plugin.tsreadsresolveThrownHttpError(err, 500)in the register wrapper (1 hit)Part of #24773. Half 2 is the decision below.ACCEPT
6289090336is on the PR.[Decision] 在控制台注册 OAuth 应用时,「应用类型」该不该真正生效?本机回环地址(localhost)的回调该按哪种类型的规则判?
domain:servicesseat 2 (seat post #21118) ·os-support-ai·session_01UmqzD97e14iws6hmVhgZ5y。依据:本卡开发报告6289049273(实测于@better-auth/oauth-provider1.7.3)。前半(拒绝答 500)已由 PR #24853 落地。Release:
session_01UmqzD97e14iws6hmVhgZ5y· 因:前半已落地,余下后半是放宽认证入口接受面的人工地板决定 · 去向:needs-user-decision,本笔清 assignee。一句话问题: 管理员在控制台注册一个本地开发用的 OAuth 应用,回调填
http://localhost:4001/callback,无论「应用类型」选什么都被拒;因为表单里的「应用类型」从来没有传到认证库,所有应用都被当成 Web 应用判。背景(实测)
localhost/127.0.0.1/[::1],任意端口)。type字段发出,认证库的请求结构只认application_type,于是type被丢弃:选 Native 也按 Web 判、按 Web 存。表单的四个选项里user-agent-based与public在认证库没有对应值,也被静默当成 Web。oauth.applications.register)本就带application_type,只有控制台这扇门丢了它。Governing text
@better-auth/oauth-provider1.7.3 的validateClientRedirectUri:Web = https 且非回环;Native = 允许 http 本机回环。packages/platform-objects/src/identity/sys-oauth-application.object.ts:注册动作声明了type参数(四个选项)与回调说明文案。Prior rulings read见下方四棱块,零命中。是否改协议: 不改 spec。方案 D 改的是认证入口对 Native 应用的接受面(按认证库自身规则),属于人工地板。
前提(复核命令)
type转发:git grep -n "type: safeType" origin/main -- packages/plugins/plugin-auth/src/auth-plugin.ts(对照词createOAuthClient必中)。git grep -n "user-agent-based" origin/main -- packages/platform-objects/src/identity/sys-oauth-application.object.ts。选项
application_type传给认证库:Native 用认证库的回环规则,Web 维持 https 且非回环;认证库没有的类型(user-agent-based、public)响亮拒绝 400 并从选项中退役;说明文案按类型写清业务含义直译: A = 「告示写清楚,但门还是只认一种钥匙」;B/C = 「为了本地方便,自己另配一把绕过门锁的钥匙」;D = 「表单上让你选的钥匙类型,门终于真的认了」。
推荐:D(并入 A 的文案修正)
public类型在认证库里是否另有对应(例如免密钥的公开客户端)未实测,D 的派发会先测再定其去留。os-decision-facets
type,却无人读取。Prior rulings read: redirect_uri,loopback,application_type,oauth-provider → 0 hits; none; thread: none; repo: objectstack-ai/objectstack
(Cross-checked by
git grep -n -iE 'redirect_?uri|loopback|application_type' origin/main -- AGENTS.md docs/adr packages/spec/src: AGENTS.md 0, docs/adr 0 (controloauth→ 18 ADR files), packages/spec/src 10 hits, all on storage loopback and connector auth, none on OAuth application registration.)裁后执行
plugin-auth包装层把类型以application_type转发,未知类型拒 400(沿用OAUTH_REGISTER_FAILED),钉子覆盖 Web 拒本机、Native 收本机、未知类型拒绝,并做消融;先实测public在认证库的对应。选项退役与文案修正另起 platform-objects 小 PR(engine 车道,席位先跨车道声明)。Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
Maintainer ruling: D (「决策箱 三张同意」, the triage seat's chat, on triage's table that listed #24773 → D). Triage seat ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-20T14:33Zneeds-user-decision→pm:queue. The services seat dispatches by this card's 裁后执行 for D (6289866018):application_type;publicis measured first;platform-objectsPR, with a cross-lane declaration.6292005854):user-agent-based/public(400) is a narrowing of what the door accepts, and no ADR-0087 conversion carries it. So 18.x removes them from the form's options and answers with a warning, and the 400 is filed for 19.0. The exception: the seat measures that no stored row or caller ever sent them, and records that reading here.Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
Claim: PM loop round 3 · 2026-10-20T21:55Z
Session:
session_01UmqzD97e14iws6hmVhgZ5yAccount:
os-support-ai(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-24773-oauth-application-typeWorktree:
objectstack-issue-24773Domain:
domain:servicesSeat:
domain:services#2(seat post #21118)File surface (half 2a, services; the ruling's
plugin-authpart):packages/plugins/plugin-auth/src/auth-plugin.ts: the OAuth client register wrapper only (thecreateOAuthClientpath that forwardstype: safeType, ~:3661at78ab6474c);plugin-auth/src/;.changeset/24773-*.md.⛔ Not
packages/platform-objects/**: the form's option retirement and its copy are half 2b, a separateplatform-objectsPR the seat dispatches after this one lands, declared cross-lane then.⛔ Not
packages/spec/**, not.github/workflows/**, and no other region ofauth-plugin.ts.Stop on a breach and explain it in the report.
Container & model:
M,mode:subagent,model: opus(TIER_DEFAULT;dispatch-gates --tieron this surface, derived at78ab6474c: no path-derived mandate, floor sonnet, default opus, ceiling fable)Clause-②: no — ruling D makes the chosen type take effect under the auth library's own rule; that is a runtime authentication behaviour on the human floor, which the maintainer answered. The dev measures and states it.
Responsibility:
plugin-auth's register wrapper forwards the form's type as 'type', which @better-auth/oauth-provider drops, so every application is judged and stored as web | the auth library's application_type, which the SDK path already sends | every admin registering an OAuth application from the console; measured in the maintainer's HotCRM pass (objectstack-ai/hotcrm#2188)Thread-read: 6293344532
Serial constraints cleared: PR #24959 (#24766,
applyBrandinauth-plugin.ts) landed as78ab6474c. No open PR touchesauth-plugin.tsor an OAuth file (every open PR's file list read this round). No in-flightdomain:servicesclaim touchesplugin-auth: #24757 isplugin-security, and seat 1's #24962 / #24966 areplugin-security/plugin-webhooks.This dispatch carries out the maintainer's ruling D (
6293344532) under 「甲」 (#60156292005854). Honouring the declared type ships in 18.x.user-agent-based/publicget a warning in 18.x, and their 400 is filed for 19.0, unless the measured exception holds: no stored row or caller ever sent them.publicis measured first.Widened in place for half 2b (after half 2a's report and the seat record
6303676890; same card, same seat, a second dev after the first finished):Branch:
claude/issue-24773-oauth-form-typesWorktree:
objectstack-issue-24773(re-created; half 2a's was removed)File surface (half 2b), cross-lane into
domain:engineand declared here:packages/platform-objects/src/identity/sys-oauth-application.object.ts: theregister_oauth_applicationaction'stypeparam options and theredirectURLshelp text; the storedtypeselect and thepubliccolumn only as measured and stated, ⛔ not narrowed in 18.x;packages/platform-objects/src/apps/translations/{en,zh-CN,ja-JP,es-ES}.objects.generated.ts;platform-objects/src/;.changeset/24773-*.md.Container & model (2b):
S,mode:subagent,model: opus(TIER_DEFAULT;dispatch-gates --tieron this surface, derived at4b8f597ba: no path-derived mandate).Clause-② (2b): expected
no, provided the register door's accept set is unchanged; the dev measures whether anything server-side refuses a value the form stops offering.Serial constraints cleared (2b): no open PR touches
sys-oauth-application.object.tsor theplatform-objectstranslation bundles (every open PR's file list read). PR #24992 (half 2a,plugin-auth) is disjoint.objectstack-fleet commentedon Oct 20, 2026
os-dev-report
{
"issue": 24773,
"status": "needs_decision",
"branch": "claude/issue-24773-oauth-application-type",
"pr": "#24992",
"session": "session_01UmqzD97e14iws6hmVhgZ5y. This is the parent PM session's id (mode:subagent); this run is identified by its branch.",
"premise_still_valid": true,
"summary": "Half 2a is built: draft PR #24992 (Part of #24773) at head e9f4deb. The sys-oauth-application/register wrapper in auth-plugin.ts now forwards the form's Application Type as application_type. web and native are forwarded, and the provider's own redirect-URI rule decides, unchanged. user-agent-based, public and any other value keep today's outcome (registered and judged as web, with a secret) and log exactly one ctx.logger.warn per registration, meta { requestedType, registeredAs: 'web' }. An absent type stays web with no warning. Half 1's error mapping is unchanged. status is needs_decision for ONE question only. @better-auth/oauth-provider 1.7.3 has a real counterpart for 'public' (token_endpoint_auth_method 'none', a client issued with no secret), so per the dispatch it is not forwarded or mapped. The mapping is open_questions[0]. Everything else in the dispatched scope is done. Premise holds: on base 78ab647 the wrapper sent type, the provider stripped it, and native + http://localhost:4001/callback got the web refusal (measured, before-fix run). PM assumption 3 is partly disproved: the SDK's oauth.applications.register request type does not declare application_type (see pm_assumptions).",
"public_measurement": "MEASURED on the installed @better-auth/oauth-provider 1.7.3 (node_modules, dist/authorize-9whjxVLJ.mjs): 'public' HAS a real counterpart. (1) persistOAuthClientRegistration (:1921, :1929) sets isPublic = body.token_endpoint_auth_method === 'none' and then issues and stores NO client secret (clientSecret undefined). (2) checkOAuthClient accepts 'none' via getSupportedAuthMethods(opts, { includeNone: true }) (:1759). (3) The /oauth2/create-client body declares token_endpoint_auth_method (:2694) and application_type z.enum(['web','native']) (:2695). 'public' is a value on the auth-method axis, never an application_type. (4) Discovery advertises 'none' among token_endpoint_auth_methods_supported when public clients are supported (:749). So 'public' was NOT forwarded or mapped. In this PR it behaves like user-agent-based (web, with a secret, plus the warning). The mapping is open_questions[0]. Related: in the provider's earlier lines (1.7.0-rc.2 and 1.6.33, read from npm tarballs, not installed), 'user-agent-based' was valid ONLY for a public client ('Type must be native or user-agent-based for public applications'). In 1.7.x a browser public client is application_type web plus token_endpoint_auth_method none. So the same question covers both form values. In-repo use of the counterpart: MCP clients register as public native clients through DCR (auth-manager.mcp-oauth-resource.test.ts:235, token_endpoint_auth_method 'none' + application_type 'native'). The SDK declares token_endpoint_auth_method 'none' on oauth.applications.register.",
"exception_reading": "For the seat to record and rule. Question: did any stored row or any caller ever send user-agent-based / public? Reading: the exception is NOT established. STORED ROWS, console door. Measured on a real ObjectQL engine + SqlDriver (better-sqlite3 :memory:) with plugin-auth's authIdentityObjects, through the real route on real better-auth. On base 78ab647 (before-fix run) and on head e9f4deb, a registration with type user-agent-based, public or spa stores sys_oauth_application.type = 'web', public = null and token_endpoint_auth_method = 'client_secret_basic'. No column holds the form's value. Pinned in the new test. 1.7.3 declares only web/native for application_type on create (/oauth2/create-client :2695, /admin/oauth2/create-client :2523) and update (/admin/oauth2/update-client :2885, /oauth2/update-client :2923). checkOAuthClient, which every registration path runs, refuses any other value (:1770). Its record has no 'public' field (OAUTH_CLIENT_RECORD_FIELD_NAMES :1034), so the installed provider can write only 'web' or 'native' into that column. EARLIER PROVIDER LINES (npm tarballs read in the scratchpad, not installed): (a) @better-auth/oauth-provider 1.7.0-rc.2 and 1.6.33 declared type z.enum(web, native, user-agent-based) and refused any non-'web' type on a confidential client. The door never sends token_endpoint_auth_method, so user-agent-based (and native) was refused at this door then, and the wrapper coerced 'public' to 'web' (it was not in allowedTypes). (b) better-auth 1.6.33's oidc-provider plugin wrote type: 'web' on every /oauth2/register. LIMITS: (i) Repo history before 2026-09-07 is not in this shallow checkout (root ac76425). At that root plugin-auth already pinned ^1.7.2 and the wrapper's allowedTypes was identical, so which provider line the wrapper ran on earlier was not measured from git. (ii) Rows that OTHER doors wrote under rc.2 / 1.6.x cannot be measured: the SDK create-client and DCR accepted type user-agent-based for public clients there. Deployments' databases are out of reach. CALLERS: (a) In-repo, zero producers send either value to this door. Tests send no type or 'web' (in-process-session-renewal.pin.test.ts:398, oauth-register-refusal-status.test.ts). Zero in examples/, packages/qa and content/docs. The SDK never builds this URL (auth-route-ledger disposition server-only). git grep 'user-agent-based' hits only the form (sys-oauth-application.object.ts:156, :425), its generated translations and the wrapper. (b) HotCRM main 1cc6861a3: zero files for 'sys-oauth-application/register', 'user-agent-based', 'application_type', 'oauth2/create-client' and 'applications.register' (control: case-insensitive 'oauth', 18 files). (c) objectui origin/main 12cde0492 (local ref, not re-fetched): zero files for the door path, 'user-agent-based' and 'application_type' (control: 'sys_oauth_application', 5 files). (d) 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 is not measurable, because no request log exists in reach.",
"pm_assumptions": [
"A1 HOLDS. At 78ab647 the wrapper (auth-plugin.ts:3661) forwarded type: safeType. The 1.7.3 create-client body declares application_type and no type, so type is stripped and every registration is judged and stored as web. Measured: precondition pin ('type: native' + loopback is refused with invalid_redirect_uri) and the before-fix run (native + localhost: 400 web refusal).",
"A2 HOLDS, with two refinements. (a) The web rule is https AND not loopback. (b) The native http rule allows exactly localhost / 127.0.0.1 / [::1], any port (validateClientRedirectUri :1685). But the body schema's SafeUrlSchema (@better-auth/core utils/redirect-uri) refuses plain http on a non-loopback host for EVERY type, BEFORE the per-type rule. So native + http://app.example.com/callback is refused as a body-schema ValidationError (400, no upstreamError), not as invalid_redirect_uri. And http://127.0.0.2:4001 passes the body schema but is refused by the native exact-host rule. Native + https on a loopback host is refused ('native clients must not use https loopback redirect URIs').",
"A3 PARTLY DISPROVED. The SDK's route /oauth2/create-client accepts application_type, and the SDK response type OAuthApplication declares it (packages/client/src/index.ts:1055). But the SDK's oauth.applications.register REQUEST type (index.ts:4535-4547) does not declare application_type. A typed caller can register a native client only through a cast; the body is passed through JSON.stringify(req) unchanged. Noted in Acceptance notes, not filed."
],
"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 real route registration on a real Hono app, over a real ObjectQL + SqlDriver (better-sqlite3) engine. 'Stored as' is read at driver level. Cases: 2 provider preconditions; web + localhost refused (400 OAUTH_REGISTER_FAILED, invalid_redirect_uri, nothing stored, no warning); native + localhost and native + 127.0.0.1 accepted and stored 'native'; native + http://app.example.com refused (body schema, 400, nothing stored); native + http://127.0.0.2:4001 refused (native exact-host rule); web + https and no type stored 'web' with no warning; user-agent-based / public / spa + https stored 'web', no column holds the value, not a public client, exactly 1 warning with meta; the same three + localhost refused as web with exactly 1 warning. BEFORE the fix, on 78ab647: 'Tests 9 failed | 5 passed (14)'. 2 native-loopback cases failed with 'expected 400 to be 200' (web refusal); 6 unhonoured cases failed only on 'expected [] to have a length of 1 but got +0', after their storage assertions passed; 1 was this file's own first assertion expecting invalid_redirect_uri on the non-loopback native case, which the body schema answers (corrected). AFTER, on e9f4deb: this file + oauth-register-refusal-status.test.ts gave 'Test Files 2 passed (2) / Tests 32 passed (32)' (os-verify-lock VERDICT command-exit 0). Full package suite at d674628 (plugin-auth byte-identical at e9f4deb): 'pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2' gave 'Test Files 174 passed (174) / Tests 3334 passed | 10 skipped (3344)' (VERDICT command-exit 0). 'pnpm --filter @objectstack/plugin-auth typecheck' gave exit 0 after building plugin-auth (the first attempt exited 2 because tsconfig.examples.json reads the package's own dist): 'check:test-typecheck: OK ... 23 pinned signature(s) held', no debt added. The new file is in the tsconfig.test.json program (--listFilesOnly: 1 hit). ABLATION, through scripts/ablation-replace.mjs (anchor x1 -> x0, blob changed, disk-verified). The runner had its own EXIT/INT/TERM trap; every restore was verified as blob 01004ce16 == HEAD with git diff HEAD empty. The test imports ./auth-plugin from source, so there is no dist leg. Leg 1: application_type: applicationType -> type: applicationType (the old type forwarding) gave 'Tests 2 failed | 13 passed', native + localhost / 127.0.0.1 'expected 400 to be 200'. Leg 2: applicationType = type -> 'native' gave 2 failed: web + localhost 'expected 200 to be 400'; web + https stored 'native'. Leg 3: unhonoured values forwarded as native gave 6 failed (all six unhonoured cases). Its first attempt was refused by the tool (the anchor was contained in the replacement, 'anchor count moved 1 -> 1'), ran nothing, and was redone with a non-overlapping anchor. Leg 4: the door rewrites the URI to an accepted one (http://app. -> https://app., 127.0.0.2 -> 127.0.0.1) gave 2 failed, both native negative pins 'expected 200 to be 400'. Logs: scratchpad issue-24773/2a/before-fix.log, after-fix2.log, final-files.log, suite-full.log, typecheck2.log, ablate-*.log.",
"gates": "Local union: 'node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack' at e9f4deb derived 72 commands (35 pnpm, 37 node), identical to the seat's list at 78ab647. Each ran as 'cmd > log 2>&1; rc=$?'. 71/72 exited 0 on the first pass. pnpm check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET, no whole-repo dist). After 'turbo run build --filter=!@objectstack/docs --concurrency=2' (76 tasks, 75 cached) it exited 0: '112 published require entry point(s) across 69 package(s) load'. '--ran' printed 'Run reconciliation — 72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN' and '✓ dispatch-gates --ran: 72 derived famil(ies) accounted for'. Among the 72: 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, check:type-check-debt, check:query-options-erasure. All exit 0. Not run locally: test:repo (CI Test Core); check:dev-prereqs and check:skill-frame-freshness (no spec build input or frame file touched); the CI-only type-check lanes and the CI jobs dispatch-gates lists outside the 72. CI on PR #24992 at e9f4deb, read right after pr_create: 'No other open PR may claim the same issue' success; 18.x upgrade harness and Packed-tarball smoke skipped (opt-in); every Lint · and Type Check · lane, Check Changeset, Check PR Size, Governed Surface Queue Guard, 'Part-of PR must not also close its card', 'The card this PR closes must claim this branch', 'No other open PR may claim the same single-writer path', Check Documentation Links and 'Flag docs affected by code changes' were in_progress. Test Core and Build Core were not yet listed. in_progress is the honest value; CI convergence is the PM's.",
"mcp_calls": "0",
"api_writes": "2 relay strokes through scripts/pm/fleet-write/dispatch.mjs, each one POST /repos/objectstack-ai/objectstack/dispatches. (1) pr_create, run 39511280255, executed POST /repos/objectstack-ai/objectstack/pulls (created #24992, draft) and POST /repos//issues/24992/assignees (os-support-ai). Read-back: 12074 bytes sent, 12074 stored, identical; assignees os-support-ai. (2) comment, POST /repos//issues/24773/comments: this report. Labels: none (none named by the dispatch; skip-changeset does not apply, because plugin-auth publishes and a patch changeset is included). git push x3 (the empty-branch probe, d674628, then d58f7da + merge e9f4deb) is not REST. Reads were plain gh api GETs: #24773 and its comments, #6015 comment 6292005854, PR #24992 and its check-runs.",
"open_questions": [
{
"question": "Should the Console door map the form's 'public' (and, by the same reading, 'user-agent-based') to the provider's public client, i.e. token_endpoint_auth_method 'none', with the client issued no secret? @better-auth/oauth-provider 1.7.3 has that counterpart (see public_measurement), but 'public' is not an application_type. Issuing a secretless client from the Console is a security-posture change on the human floor, so it was not mapped. In this PR it is registered as a confidential web client with a secret, plus the warning.",
"options": [
"A. No mapping, as ruled (D under 「甲」). 'public' stays a confidential web client with one warning in 18.x. Half 2b removes it from the form, and the 400 is filed for 19.0. Public clients stay available where they are used today: DCR (MCP clients register token_endpoint_auth_method 'none' + native) and the SDK (oauth.applications.register declares token_endpoint_auth_method 'none'). Cost: none beyond the ruled 2b and 19.0 cards. Business need: zero measured producers send 'public' to this door (in-repo, HotCRM main, objectui), and the measured public-client consumer (MCP) does not use the Console. Long-term: the door honours exactly the axis the provider judges. AI-safety: after 2b the form cannot declare a value the door does not honour, with a loud warning meanwhile. Startup focus: adds nothing.",
"B. Map 'public' (and 'user-agent-based') to { application_type: 'web', token_endpoint_auth_method: 'none' }. Cost: about 5 wrapper lines plus pins. 2b must also change the result dialog, which today promises a client_secret 'shown only once'. Business need: none measured. Long-term: one select value becomes two provider settings, and the mapping is lossy (a public NATIVE client cannot be expressed). AI-safety: worst. 'Public' reads as 'public-facing website' and would silently yield a client with no secret, a security surprise. Startup focus: adds a capability with zero measured pull.",
"C. Model both provider axes in the form: Application Type (web | native) plus Client authentication (confidential with secret | public, token_endpoint_auth_method 'none'). Retire 'user-agent-based' and 'public' from the type select. Cost: wrapper and platform-objects form, copy and dialog changes (both lanes). Business need: none measured. Long-term: the most faithful to the provider's model. AI-safety: explicit and hard to misread. Startup focus: adds a form field and a capability without pull."
],
"recommendation": "A. Business need is the deciding axis: nothing measured asks the Console for secretless clients, and the real public-client consumer (MCP) registers through DCR, already served. B is a lossy translation that turns a misreadable word into a missing secret, the shape the AI-safety axis forbids. C is right only if a Console public-client need is measured later, and startup focus says not before then. A keeps today's warning text accurate ('A future major version refuses this value'). If the seat picks B or C, the warning for 'public' changes in that PR. No new gate is recommended, and none was named by the maintainer."
}
],
"out_of_scope_findings": [
"carrier: none (承接者:无) · noted, not filed, in the PR's Acceptance notes. The SDK oauth.applications.register request type (packages/client/src/index.ts:4535-4547) does not declare application_type, while its route accepts it and its response type declares it. A typed caller can register a native client only through a cast. Not class a/b/c: it is not a defect, and the SDK does not claim the capability.",
"carrier: #24773 half 2b (the seat) · noted, not filed. The stored column sys_oauth_application.type (sys-oauth-application.object.ts:425) also lists 'user-agent-based' and 'public', which 1.7.3 never writes. The 'public' boolean column (:432) is never written by 1.7.3: its record has no such field, and it was null in every stored row measured here. For the seat to fold into 2b or rule on."
],
"line_budget": "382 changed lines (+379 / -3) across 3 files, per dispatch-gates at e9f4deb, against the 3000 human-merge threshold: under. auth-plugin.ts +32 / -3, register wrapper only; new test +334; changeset +13. No governed surface touched; skills/** untouched, so no ratchet ledger applies.",
"deviations": [
"The card's ablation 'put back type: safeType' was spelled 'type: applicationType'. safeType no longer exists, and the forwarded value is the same for web/native, so it is the same defect. It turned the two native pins red as the card predicted.",
"Added beyond the card's pin list: 2 provider preconditions, a native + http://127.0.0.2 negative pin (the exact-host rule), an arbitrary value 'spa', and a no-type case. Ablation legs 2-4 cover the negative pins, per the lane commitment.",
"The card's 'Native + a non-loopback http URI refused' is answered by the provider's body schema (SafeUrlSchema), not its native rule, so its pin asserts 400 OAUTH_REGISTER_FAILED with no upstreamError. The 127.0.0.2 pin covers the native rule itself.",
"Edge semantics chosen in the wrapper, all keeping today's web outcome: null is treated like absent (no warning); a non-string type warns as '(number)' / '(object)' / '(boolean)'; a string value is truncated to 64 chars in the warning.",
"The warning text ends 'A future major version refuses this value.' for every unhonoured value, 'public' included, per the ruling as it stands. If the seat maps 'public' (open_questions[0] B/C), that sentence goes with the change.",
"origin/main advanced to e4d604e (2 commits: plugin-security #24985 and release-verify-npm #24989; no overlap with this diff) after my merge of 7adfaec and before pr_create. The PR was opened at e9f4deb without re-merging; the gate union and test reads are at e9f4deb.",
"The full plugin-auth suite ran at d674628. e9f4deb adds only the changeset wording commit and the main merge (objectui pin files), with plugin-auth byte-identical (git diff --stat empty). The two OAuth register files were re-run at e9f4deb.",
"For the HotCRM caller reading I ran 'git fetch origin main' in the shared /home/user/hotcrm checkout, which advanced its refs/remotes/origin/main to 1cc6861a3 (a read-only fetch, no working-tree change). The objectui reading used its existing origin/main ref 12cde0492 without fetching, so it may be stale.",
"For the earlier-provider-line reading I downloaded npm tarballs (@better-auth/oauth-provider 1.7.0-rc.2 and 1.6.33, better-auth 1.6.33) into scratchpad issue-24773/2a/npm-dl and read them with grep/sed only. Nothing from them was executed or installed.",
"The commit trailer uses AGENTS.md's model-free pair (Claude-Session + Co-authored-by: Claude), not the harness reminder's model-named form, because the pre-push hook refuses a model identifier.",
"The PR body's first line is 'Part of #24773: half 2a, ...' (colon, then prose). The H7 Part-of/closing-keyword check on the body returned null (clean), and no closing keyword is adjacent to any issue number.",
"No exported symbol was renamed or removed, so check-platform-checklist --changed-from was not run."
],
"files_changed": [
"packages/plugins/plugin-auth/src/auth-plugin.ts",
"packages/plugins/plugin-auth/src/oauth-register-application-type.test.ts",
".changeset/24773-oauth-application-type.md"
],
"pr_body_edits": "none"
}
Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
Seat record for half 2a: the
publicmeasurement, the 「甲」 exception reading, and the seat's call ·domain:servicesseat 2 (seat post #21118) ·os-support-ai·session_01UmqzD97e14iws6hmVhgZ5y· 2026-10-20T22:58ZThe ruling (
6293344532) asked the seat to measurepublicfirst and to record the exception reading here. Both come from the half-2a dev report on this card and PR #24992, measured on the installed@better-auth/oauth-provider1.7.3.1.
public. The library has a real counterpart, but on another axis: a public client istoken_endpoint_auth_method: 'none', issued with no client secret. It is not anapplication_type, which accepts onlyweb | native.web+none, so the same reading coversuser-agent-based.Seat's call: no mapping (the dev's option A), which is ruling D as written.
2. The 「甲」 exception reading: not established.
web/native. A registration naminguser-agent-basedorpublicis storedweb, as a confidential client with a secret. That is measured on a real engine and pinned in PR fix(plugin-auth): the register form's Application Type reaches the OAuth provider as application_type #24992.warnper registration, today's outcome kept), half 2b removes the two options from the form, and the 400 is filed for 19.0 when half 2a lands.3. Carried into half 2b: the stored
sys_oauth_application.typeselect still listsuser-agent-basedandpublic, and thepublicboolean column is never written by 1.7.3. Half 2b measures and states what it does with each.objectstack-fleet commentedon Oct 20, 2026
ACCEPT → PR #24992 comment 6303941375 (half 2a) ·
domain:servicesseat 2 ·session_01UmqzD97e14iws6hmVhgZ5y· 2026-10-20T23:17Z · heade9f4debc1b, ordinary queue landing.objectstack-fleet commentedon Oct 20, 2026
Landed (half 2a) ·
domain:servicesseat 2 ·session_01UmqzD97e14iws6hmVhgZ5y· 2026-10-20T23:47Zfbb42d900.git merge-base --is-ancestor fbb42d900 origin/mainholds. The PR wasPart of #24773, so the card stays open.application_type, so a Native client can registerhttp://localhost,http://127.0.0.1orhttp://[::1]redirect URIs and a Web client keeps the https rule;user-agent-based/publickeep today's outcome, with one server warning each.6303941375.platform-objects), in flight under the widened claim6302570331;user-agent-based/public. The card moves topm:on-holdfor it when 2b lands.pm:dispatchedwhile 2b is in flight.