Skip to content

feat(plugin-auth): mobile sign-in hand-off over better-auth's one-time-token plugin, bound to the shell's PKCE challenge - #29766

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-p4ozlw
Nov 3, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-p4ozlw

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Warren · project thread
Fixes #29714

Clause-②: yes (widening)
Metadata-protocol: none

Clause-② is yes (widening) because three auth routes become reachable that were 404 on main: the ObjectStack hand-off and the vendor plugin's generate and verify. Nothing an author writes changes, so Metadata-protocol: none. The spec half (route paths, the request shapes, and the trustedAppSchemes key) is #29736, and this PR stays draft until #29736 lands.

维护者速读

  • 改了什么:plugin-auth 开启 better-auth 自带的 one-time-token 插件,加一个 GET /one-time-token/handoff 路由:系统浏览器里登录后,把会话以一次性、一分钟有效的令牌交给已注册的 app scheme,App 在 /one-time-token/verify 兑换。
  • 为什么改:Google 等身份提供方拒绝在内嵌 WebView 里登录,移动端用这类账号目前登录不了(裁决 B:复用插件,不手写兑换端点)。
  • 风险与回滚:这是认证边界。令牌存储键绑定了壳生成的 PKCE challenge,截获回调的其他 App 兑换不了,也烧不掉正主的令牌;生成路由对客户端请求一律拒绝。只在部署配置了 trustedAppSchemes 时才挂载插件,回滚即删掉该配置或回退本 PR。an intercepted callback cannot be redeemed」证明截获的令牌被拒且正主随后仍能兑换,「ruling A
  • 先看:one-time-token-handoff.ts(顶部注释是拦截论证,oneTimeTokenStoreHasher);auth-manager-plugin-list.ts 新增的插件块;auth-plugin.ts 的 handoff 挂载。
  • 两分钟验证:pnpm --filter @objectstack/plugin-auth exec vitest run src/one-time-token-handoff.test.ts,预期 39/39 通过,其中「an intercepted callback cannot be redeemed」证明截获的令牌被拒且正主随后仍能兑换,「ruling A」一组证明超过五分钟的会话和跨站导航被拒。
  • 需要你做的:遗留风险一已按你的裁决 A(6597001931)实现;请在座位审完后审合本 PR。

What changes

The flow (the mobile card designs the shell half; this PR is the server half):

  1. The shell makes an RFC 7636 code_verifier and keeps it.
  2. In the system auth session (ASWebAuthenticationSession, Custom Tabs), after a sign-in there, the shell opens GET /api/v1/auth/one-time-token/handoff?redirect_uri=SCHEME://PATH&code_challenge=C&code_challenge_method=S256, where C = BASE64URL(SHA-256(code_verifier)).
  3. The route checks that the scheme is registered, mints a token from the signed-in session through the vendor plugin's own generate endpoint (called in-process), and answers 302 to redirect_uri with token=T added (Cache-Control: no-store, Referrer-Policy: no-referrer; the shell's own query, such as state, is kept).
  4. The shell, or the Console it hands both values to, redeems at the vendor route POST /api/v1/auth/one-time-token/verify with { "token": "T.code_verifier" } and receives the vendor { session, user } and the session cookie.

The plugin, oneTimeToken({ expiresIn: 1, disableClientRequest: true, storeToken: { type: 'custom-hasher', hash } }), added through addOptionalPlugin ahead of the custom session, mounted only when trustedAppSchemes registers at least one scheme.

  • Hashed storage. The stored identifier is one-time-token: plus a SHA-256 key; neither the token nor the challenge is in the row.
  • Expiry: 1 minute (the vendor default is 3). The token travels by a redirect the operating system delivers at once and is redeemed by the app's next request, so a minute is generous even on a slow device, and every second beyond that is only time for a stolen token to be tried.
  • Generation is server-side only. disableClientRequest refuses every HTTP request to /one-time-token/generate (400 with a session, 401 without). The hand-off calls auth.api.generateOneTimeToken({ headers }), which carries no request, so it passes.

How an intercepted callback cannot be redeemed. The vendor plugin by itself is not bound to the attempt that started the sign-in: whoever holds T redeems it, and on a phone another app can register the same custom scheme and receive the callback. The binding uses only the plugin's published storeToken option:

  • At generation, the hand-off runs the vendor generate inside an AsyncLocalStorage scope that holds C, and the custom hasher stores the session under SHA-256(T ":" C).
  • At redemption, there is no scope, so the hasher reads the body's token as T.code_verifier (the vendor alphabet for T has no ., so the first . is the separator) and looks up SHA-256(T ":" S256(code_verifier)).
  • An interceptor holds T alone. T, T., T with a guessed verifier, or T with the challenge itself all hash to a key no row has, so the vendor answers its own 400 Invalid token. Because the lookup used the wrong key, nothing is consumed and the shell that started the hand-off still redeems. The verifier never leaves the device; only its hash travels, in step 2.
  • A token minted server-side outside the hand-off's scope is stored under a key no redemption can reach, so it fails closed.

The registered-scheme list is the spec's AuthConfig.trustedAppSchemes (#29736, beside trustedOrigins); there is no local declaration. Each entry is checked by the spec's own TrustedAppSchemeSchema, imported into appSchemeRefusal and never copied, because the auth manager also receives plain objects that never passed AuthConfigSchema: lowercase reverse-domain private-use schemes with no : suffix and no empty or trailing label (RFC 8252 §7.1, for example com.example.app; com..app and com.example. are refused). http, https, javascript, data, file, blob, about, vbscript, ws and wss get a named "web or script scheme" refusal first; none of them contains a dot, so the schema refuses each anyway, and that check exists only for the clearer message. A bad entry fails AuthManager construction and applyConfigPatch, naming the entry; it is never dropped. A redirect_uri whose scheme is not registered (matched case-insensitively) answers 400 VALIDATION_ERROR with no Location, so the route is never an open redirect. The route paths ONE_TIME_TOKEN_HANDOFF_PATH and ONE_TIME_TOKEN_VERIFY_PATH derive from AuthEndpointPaths.oneTimeTokenHandoff / oneTimeTokenVerify.

Correction to the earlier body: it said the local scheme check applied "the same rules" as the spec. It did not: the local regex ^[a-z][a-z0-9+.-]*$ plus "contains a dot" accepted com..app and com.example., which the spec refuses. The import closes that gap, and the two entries are now pinned.

The hand-off route's other answers. It accepts a closed query set (redirect_uri, code_challenge, code_challenge_method, each at most once). An unknown or repeated name, a missing or relative redirect_uri, a missing or malformed challenge, or a method other than S256 answers 400 VALIDATION_ERROR before anything is minted. A signed-out caller gets 401 UNAUTHENTICATED. The mount is unconditional: with no scheme registered, every target is refused with a 400 that says how to register one.

The hand-off carries only a fresh sign-in (ruling A, 6597001931). Two refusals run before any token is minted:

  1. A session whose vendor session.createdAt is more than five minutes old (ONE_TIME_TOKEN_HANDOFF_SESSION_MAX_AGE_SECONDS = 300) answers 401 UNAUTHENTICATED "Sign in again…". 401 because the remedy is a sign-in, the same as having no session.
  2. A request carrying Sec-Fetch-Site: cross-site answers 400 VALIDATION_ERROR. The header is checked only when present; none, same-origin, same-site and an absent header pass.

Ledger rows. In auth-route-ledger.ts (family one-time-token, requires: 'oneTimeToken'):

  • GET /one-time-token/handoff: objectstack, server-only.
  • POST /one-time-token/verify: better-auth, public.
  • GET /one-time-token/generate: better-auth, server-only.

The two vendor routes are added to BETTER_AUTH_MOUNTED_SURFACE. The conformance suite registers one app scheme so the maximal surface carries them, and its objectstack-mount pin gains the hand-off. check:auth-mount-ledger accounts for all 24 mounts. No route-envelope row applies: check:route-envelope audits *-routes.ts modules and dispatcher domains, and the hand-off is a raw auth mount like its siblings, answering the shared { success: false, error: { code, message } } envelope on every refusal.

Why

Some identity providers (Google among them) refuse embedded WebViews, so a mobile user who signs in with one cannot sign in to the app today (objectstack-ai/mobile#176 detects the refusal). The maintainer ruled option B on objectstack-ai/mobile#148 (comment 6593861138, 「同意」): reuse better-auth's one-time-token plugin, with no hand-written exchange endpoint. Verify is the vendor's route, unchanged.

Tests

packages/plugins/plugin-auth/src/one-time-token-handoff.test.ts (39 cases) runs the real AuthManager (better-auth 1.7.12 and its plugin) over the package's shared in-memory engine, with the hand-off through the real registerAuthRoutes raw-app registration, the way sys-session-revoke.test.ts does.

  • Round trip. The hand-off answers 302 to the registered scheme with state kept, no-store and no-referrer. The redeem returns Alice's user and a session cookie. A replay answers 400 Invalid token.
  • Interception refusal. T, T., T. plus a guessed verifier, and T. plus the challenge each answer 400 Invalid token with no cookie. The real T.code_verifier then answers 200.
  • Storage. The new sys_verification row's identifier holds neither T nor C, and equals the key the redemption computes.
  • Expiry. A redeem one minute and one second later answers 400.
  • Generate. A client request answers 400 Client requests are disabled with a session and 401 without. A server-side mint outside the hand-off is unredeemable.
  • Redirect target. com.attacker.app://, https:// and javascript: answer 400 VALIDATION_ERROR with no Location. A mixed-case registered scheme matches. With no scheme registered, verify is 404 and the hand-off refuses every target. A signed-out caller gets 401 UNAUTHENTICATED.
  • Request validation. Seven malformed queries each answer 400 VALIDATION_ERROR, and generate is never called.
  • Ruling A. A session one second past the 300-second window answers 401 UNAUTHENTICATED, with no Location and no new verification row; one five seconds inside it answers 302. Sec-Fetch-Site: cross-site answers 400 VALIDATION_ERROR; none, same-origin, same-site and no header answer 302.
  • trustedAppSchemes. Twelve bad entries (the earlier ten plus com..app and com.example., which the spec's schema refuses) are refused by the assertion, by construction and by applyConfigPatch.

Acceptance notes

  • Residual risk 1 (an app starts its own hand-off) is closed by ruling A (6597001931). The silent case, an app opening the hand-off in a system browser that has held the user's session for days, now answers 401 because the session is older than five minutes. What remains is a narrow window: an app that opens the hand-off within five minutes of the user's own sign-in in that browser.
  • The web trigger is covered by refusal 2. The session cookie is SameSite=Lax, so any web page could start the hand-off with a top-level GET navigation, and on Android an app squatting the scheme would then need no user action at all. Browsers that send Fetch Metadata mark that navigation Sec-Fetch-Site: cross-site, and the hand-off refuses it. A browser that sends no Fetch Metadata is not covered by this refusal and still meets refusal 1. Option B (an interstitial that POSTs) is the only design that closes the trigger in every browser, and it was not chosen.
  • Shell consequence: posted on plugin-auth: enable better-auth's one-time-token plugin so the mobile shell can hand a system-browser sign-in back to its WebView #29714 for the mobile filer as 6597073964; not restated here.
  • The redeemed session is the system browser's session, not a new one. The vendor plugin stores the session token and verify returns that same session. Signing out in the app therefore also signs out the system browser, and the ADR-0069 auth gate (password change, MFA enrolment) that holds the browser session holds the app too. Nothing is widened by this, but the mobile card should know it.
  • No sign-in audit event is written for a redemption. The after-hooks key on /sign-in/email and its siblings, and no session row is created, so sys_session and the sign-in counters do not move. Noted, not filed.
  • Signed-out hand-off answers 401 JSON, not a redirect to the Console login. No measured Console route takes a return URL the hand-off could name, and advertising one would be the declared-not-enforced shape. The shell is expected to complete a sign-in in the auth session before opening the hand-off.
  • Order conflict, reported per os-dev: the dispatch order asks for pnpm lint. os-dev's local-verification section assigns repo-wide lint to CI. I ran eslint on every touched file (exit 0) and also ran the full pnpm lint; its result is under Verification.

Verification

Measured before and after (better-auth 1.7.12, plugin-auth on main 87956dc, then this branch)
  • Before: GET /one-time-token/generate anonymous answers 404, and signed in also 404. POST /one-time-token/verify answers 404. auth.api.generateOneTimeToken is undefined, so the plugin is not mounted.
  • After, with a scheme registered: generate anonymous answers 401 UNAUTHORIZED. Signed in, it answers 400 Client requests are disabled. Verify with a bogus token answers 400 Invalid token. The hand-off round trip, interception refusal, replay and expiry are as pinned above.
  • The vendor dist read: dist/plugins/one-time-token/index.mjs has expiresIn ?? 3 minutes and storeToken of plain, hashed, or { type: 'custom-hasher', hash }. disableClientRequest refuses when c.request is set. Generate is GET behind sessionMiddleware, and verify is a POST with body { token } that calls consumeVerificationValue on one-time-token: plus the hash.
Tests, typecheck, ablation (head 01e2f33, main merged at 7cbd845 with #29804)
  • Measured on dead38d4; 01e2f33 is the same tree with the commit message reworded (git diff dead38d4 01e2f33e empty), so every figure below holds for the head.
  • Whole package: pnpm --filter @objectstack/plugin-auth test, Test Files 217 passed, Tests 3747 passed, 10 skipped. New file alone: 39 passed.
  • pnpm --filter @objectstack/plugin-auth... build then pnpm --filter @objectstack/plugin-auth typecheck (src, examples and the test layer), under the lock: VERDICT command-exit 0.
  • Ablations, each committed first, through scripts/ablation-replace.mjs, restored with an empty git diff HEAD:
    • the hasher keyed on T alone (the vendor's hashed, no binding): the interception pin and the outside-scope pin went red;
    • the freshness refusal removed: only its stale-session pin went red (1 failed, 36 passed at 4b3e1fd);
    • the cross-site refusal removed: only its cross-site pin went red (1 failed, 36 passed at 4b3e1fd);
    • appSchemeRefusal put back on the old local rule (^[a-z][a-z0-9+.-]*$ plus a dot): exactly refuses "com..app" and refuses "com.example." went red (2 failed, 37 passed), blob ad5f8e97 to 68fa61b9 and restored to the HEAD blob.
Gates (head 01e2f33)
  • node scripts/pm/dispatch-gates.mjs --commands derived the same 64 families as on 766bb72 (sorted lists identical). All 64 ran with exit 0, including pnpm check:dual-build-cjs-loads (115 require entry points across 70 packages load), which was NOT MEASURED on 766bb72. --ran reconciliation exit 0: "64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED".
  • node scripts/check-plugin-teardown-shape.mjs --self-test: exit 0 (its fixture commit fetched as before).
  • Full pnpm lint (repo-wide eslint): exit 0.

Human Minimum


Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01JQwxbZkE4E6sE72j23Afwx

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Nov 3, 2026
@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 43 documentable anchor(s).

23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json bb2c04e3ca0158beff0cec7a1ffa211b452082d2.

⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

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

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

Which tree this was computed on

This run read content/docs from a0db5771135f9aae66f6561b375ba6616a36539d — the merge of head 01e2f33e629b4823858b7ef28b8afe7c2692964c into base bb2c04e3ca0158beff0cec7a1ffa211b452082d2, which is what actions/checkout gives a pull_request run. Not the PR head.

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

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a0db5771135f9aae66f6561b375ba6616a36539d && git checkout a0db5771135f9aae66f6561b375ba6616a36539d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bb2c04e3ca0158beff0cec7a1ffa211b452082d2 01e2f33e629b4823858b7ef28b8afe7c2692964c && git checkout -B drift-repro bb2c04e3ca0158beff0cec7a1ffa211b452082d2 && git merge --no-ff 01e2f33e629b4823858b7ef28b8afe7c2692964c

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

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

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

…-manager plugin inventories

Claude-Session: https://claude.ai/code/session_01JQwxbZkE4E6sE72j23Afwx
Co-authored-by: Claude <noreply@anthropic.com>
…in five minutes, and refuses a cross-site navigation

Claude-Session: https://claude.ai/code/session_01JQwxbZkE4E6sE72j23Afwx
Co-authored-by: Claude <noreply@anthropic.com>
…oute names from the spec

The spec slice (#29736) landed AuthConfig.trustedAppSchemes, TrustedAppSchemeSchema
and AuthEndpointPaths.oneTimeTokenHandoff/oneTimeTokenVerify. appSchemeRefusal now
runs the imported schema instead of a local copy of its rule (the local copy
accepted an empty or trailing label: com..app, com.example.), the temporary
AuthManagerOptions.trustedAppSchemes declaration is gone, the two path constants
derive from the spec, and the changeset states ruling A and the stricter rule.

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

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-warren · session_01JQwxbXKRzgnVvjzxaYxLUf

维护者速读

  • 改了什么:plugin-auth 开启 better-auth 自带的 one-time-token 插件,加 GET /api/v1/auth/one-time-token/handoff:系统浏览器里登录后把会话交给移动 App;令牌存储键绑定 App 生成的 PKCE challenge,截获回调的其他 App 兑换不了。按裁决 A,只接受五分钟内登录的会话,并拒绝 Sec-Fetch-Site: cross-site 的导航。
  • 为什么改:plugin-auth: enable better-auth's one-time-token plugin so the mobile shell can hand a system-browser sign-in back to its WebView #29714:Google 等身份提供方拒绝内嵌 WebView,移动端用这类账号登录不了。
  • 风险与回滚:这是认证边界,新路由只对已注册的 App scheme 生效(trustedAppSchemes 为空时不可达);回滚即 revert。
  • 先看:packages/plugins/plugin-auth/src/one-time-token-handoff.ts 顶部注释。
  • 两分钟验证:pnpm --filter @objectstack/plugin-auth exec vitest run src/one-time-token-handoff.test.ts,39/39。
  • 需要你做的:无,席位落地(PR 正文里“请审合”一句以本条为准)。
  • 席位意见:裁决 A 已按记录实现并有消融证明;scheme 规则改为直接用 spec 的 TrustedAppSchemeSchema。

席位核对

ACCEPT · domain:server#2 · head 01e2f33e629b4823858b7ef28b8afe7c2692964c · 2026-11-03T07:58Z · seat review of record at TIER_DEFAULT (declaration face: Clause-②: yes (widening), no packages/spec path; the spec half #29736 had its own contract review, PASS 6596452314 + delta 6596937905); not governed (check-governed-merges --pr 29766 0 of 10 paths)

  1. Shape: draft, base main, Fixes #29714, Clause-②: yes (widening) (three auth routes reachable that were 404 on main), Metadata-protocol: none; @objectstack/plugin-auth changeset. 10 files, +879/−0. Rebased onto main with feat(spec): declare the mobile sign-in hand-off surface (one-time-token hand-off and verify, registered app schemes) #29804 merged (7cbd845b).
  2. Ruling: A (6597001931). Seat read on this head: the cross-site refusal runs first (sec-fetch-site trimmed and case-folded, refused only when it equals cross-site, 400 VALIDATION_ERROR); the freshness refusal reads the vendor session.createdAt and refuses past ONE_TIME_TOKEN_HANDOFF_SESSION_MAX_AGE_SECONDS = 300 with 401 UNAUTHENTICATED before any token is minted. appSchemeRefusal now runs the spec's TrustedAppSchemeSchema (the local looser regex is gone; com..app and com.example. pinned); route paths come from AuthEndpointPaths.
  3. Gates on this head: check-runs pinned to 01e2f33e: 52 total, 37 success, 15 skipped, 0 failed. Dev (measured on dead38d4, same tree): plugin-auth 217 files, 3747 passed, 10 skipped; build and typecheck 0 under the lock; 64/64 derived families exit 0 with --ran 0 NOT-MEASURED; pnpm lint 0. Ablations: binding, freshness, cross-site and the scheme rule each turn exactly their own pins red.
  4. Prose checked against the diff: PR body amended by the seat to this round (six blocks from the dev): the scheme paragraph with a correction (the earlier body claimed parity the local rule did not have), ruling A, Tests, Acceptance notes, Verification.
  5. Out of scope / Acceptance notes: residual risk 1 closed by A; the web trigger covered by the cross-site refusal in browsers that send Fetch Metadata; a browser that sends none still meets the freshness refusal. Shell consequence for the mobile app posted on plugin-auth: enable better-auth's one-time-token plugin so the mobile shell can hand a system-browser sign-in back to its WebView #29714 as 6597073964 (the mobile repo is outside this lane). security_findings: none.
  6. Landing: not governed, under 3000 lines ⇒ the seat lands it (pr_ready + automerge_enable). At MERGED: landing record on plugin-auth: enable better-auth's one-time-token plugin so the mobile shell can hand a system-browser sign-in back to its WebView #29714, pm:dispatched removed, card thread released.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 3, 2026 07:58
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 3, 2026 07:58
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 3, 2026
Merged via the queue into main with commit 2b15e30 Nov 3, 2026
53 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-p4ozlw branch November 3, 2026 08:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin-auth: enable better-auth's one-time-token plugin so the mobile shell can hand a system-browser sign-in back to its WebView

1 participant