Skip to content

feat(plugin-auth): a page navigation to the mobile hand-off that has to sign in first goes to the sign-in page - #30010

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-29997-handoff-signin-entry
Nov 5, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-29997-handoff-signin-entry

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

domain:server#2 · hotlong · session_c967d7a472b0485c94711bfcb22159c8

Fixes #29997

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:手机 App 登录现在只需打开交接接口这一个地址。没登录、或会话超过 5 分钟的用户在系统浏览器里打开它,服务端直接跳到本部署的 Console 登录页,登录后回到交接接口,再把一次性令牌交回 App。App 自己的探测请求仍然拿到 401。登录页也一起改了:服务端跳转时写明拒绝时间,登录页按这个时间判断会话,不再只看设备时钟。
  • 为什么改:Google 这类不允许内嵌 WebView 的登录方式走这条流程,装好的 App 会用好几个月。原来 App 要自己知道 Console 登录页的路径和它怎么处理 redirect,Console 一升级改了其中一个,这些用户就卡在没有退路的页面上,App 也察觉不到。
  • 风险与回滚:目前还没有已发布的 App 在用这条流程,所以不存在受影响的旧版;现有入口(直接打开 Console 登录页)对用它的构建照常可用。这是会话转移流程,但没有新增能力:令牌仍只发给 5 分钟内登录的会话,跨站打开和非法参数仍先回 400,不会跳到登录页;没挂 Console 的部署保持 401。回滚即还原本 PR。
  • 先看:packages/plugins/plugin-auth/src/one-time-token-handoff.ts 里的 refusedSessionAnswer(什么时候跳、什么时候还回 401)和 resolveHandoffSignInEntry(只取路径不换源、怎么判断有没有 Console);ui/apps/console/src/pages/auth/handoffRedirect.ts 里的 isHandoffSessionFresh(登录页何时放行)。
  • 两分钟验证:pnpm --filter '@objectstack/plugin-auth^...' build && pnpm --filter @objectstack/plugin-auth exec vitest run src/one-time-token-handoff.test.ts,预期 88 条全部通过;pnpm --dir ui exec vitest run apps/console/src/pages/auth/__tests__/handoffRedirect-29971.test.ts apps/console/src/pages/auth/__tests__/signInHandoff-29971.test.tsx,预期 68 条全部通过。
  • 需要你做的:这是会话转移(auth)流程,请维护者审阅并合并本 PR。

What changes

GET /api/v1/auth/one-time-token/handoff, for a valid request whose session is missing or older than five minutes:

Request Answer
A page navigation 302 to this deployment's Console sign-in page, with redirect naming the same request and handoff_refused_at carrying the server's time of the refusal
Anything else (the app's probe included) 401 UNAUTHENTICATED, as before
A page navigation reporting Sec-Fetch-Site: same-origin, which is the sign-in page forwarding 401, never another redirect
A page navigation when this process does not answer the sign-in page with 200 text/html, or when the path would start with // 401, logged once at warn

Nothing else on the route moves. Sec-Fetch-Site: cross-site is still judged first, then the closed query set, the registered scheme and the challenge; an invalid request gets 400 and never reaches the sign-in page. A token is still minted only for a session under five minutes old (ruling A on #29714).

The Console's sign-in page (handoffRedirect.ts): when the query has handoff_refused_at, a session with a createdAt is fresh only if it was created after that time. The device's clock is not read for it. A value that is not a whole number of milliseconds is ignored, and a session with no createdAt keeps its existing branch. The links between /login and /register carry the mark beside a hand-off redirect, and every other link is byte-identical.

Spec: docblock only. Route ledger: the note. No schema, export or generated artefact moved.

Design

Telling a page navigation from an API call (constraint 1). Sec-Fetch-Dest decides when a browser sends it: document is a navigation, and empty (a fetch) or iframe is not, whatever Accept says. With no Fetch Metadata, Accept naming text/html counts. A request with neither gets the 401, which is what this route answered before. So the rule rests on no single header, and if a real system auth session turned out to send neither, the result is today's JSON rather than a wrong redirect.

NOT MEASURED: the headers a real ASWebAuthenticationSession or Custom Tabs sends. I cannot measure those here. Measured in headless Chromium 151 opening the route directly: Sec-Fetch-Dest: document, Sec-Fetch-Site: none, Sec-Fetch-Mode: navigate, an HTML Accept. The WebKit and Firefox builds Playwright has on this machine do not match its version, so they were not read either.

Staying on the origin (constraint 3). AuthManager.getConsoleUrl composes ${canonical origin}${uiBasePath}${path} (confirmed in source), so using it as Location would move a browser that reached the deployment on another host. Location is root-relative and takes only the path of that URL, which keeps uiBasePath and any base path in baseUrl honoured without a second composer. Measured through a proxy on another port: the browser stayed on the proxy's origin through the sign-in and ended at the app. A baseUrl ending in // or a uiBasePath starting with // would compose a path starting with //, which as a root-relative Location names another host. Config alone reaches that and no token rides that redirect, but the route treats it as no sign-in page: the 401, one warn, and the server is not asked.

redirect (constraint 4) is rebuilt from the validated parameters only: redirect_uri, code_challenge, and code_challenge_method if the request had it. The app's redirect_uri stays nested inside redirect, because redirect_uri at the top of the page's query, with client_id, is what the page reads as an OAuth authorize replay.

No Console (constraint 6). The route asks the composed server, in-process and per request, for the sign-in page, and redirects only when that answers 200 with an HTML document. readShellConfig and anonymousFormDoorServes in packages/cli/src/utils/console.ts already ask the composed app the same way. A route table would not do: os serve mounts a 503 responder on the same paths when the Console was not built. So these all keep the 401: --no-ui, a Console not built, a host that redirects /_console to the control plane. So does a Console served by something in front of this process, which is the cost of asking in-process: that deployment stays on today's JSON.

The execution context. resolveHandoffSignInEntry calls server.fetch with env and no execution context, and I left it that way because it cannot matter on any host read:

  • c.executionCtx is read nowhere in this repo, nor in objectstack-ai/cloud at f874972e.
  • In Hono itself only middleware/cache and middleware/method-override read it unguarded, and neither is imported here. .mount() reads it inside a try.
  • No real request has one either. plugin-hono-server and cloud's node-server.ts serve through @hono/node-server, which calls fetch(req, { incoming, outgoing }), so the probe matches the request it came from. Cloud's Workers front a Node container and use ctx.waitUntil outside Hono. Cloud's Vercel entry calls app.fetch(request) alone.

readShellConfig already dispatches the same way, on every Console document. If a middleware ever did read it unguarded, the probe answers 500, the route keeps the 401 and logs it answers 500 once. A new pin holds that: it fails closed and says why.

The loop (constraint 5) is real, and no longer happens. Measured at 584f1e43fc, in headless Chromium; the commit after it changes none of what these runs read. Every run below used a session really older than five minutes (each waited 5m10s). The device clock was set 15 minutes behind where the row says so.

Run Hand-off requests Where it ended
With Fetch Metadata, device clock behind, old session 1: 302 (none) The form, held for the 10 s watched; a sign-in on it ends in the 302 to the app
No Fetch Metadata, device clock behind, old session 1: 302 The form, held for the 10 s watched; a sign-in on it ends in the 302 to the app
With Fetch Metadata, session created after the mark, same URL reopened once stale 1: 401 (same-origin) JSON, and it stops
No Fetch Metadata, session created after the mark, same URL reopened once stale 1: 302 The form, under the newer mark, held for the 10 s watched

For scale, the loop itself, measured earlier at fe2b8b4907 with #29979 merged in, the same-origin stop removed and no page half: 228 hand-off requests in 20 s, all 302.

The session-created-after-the-mark rows are the remaining way the page forwards a session the route refuses: a URL reopened more than five minutes later. The same path covers a session the page can read and the route cannot, such as a bearer token with no cookie, which was read from the code and not run. "No Fetch Metadata" is Chromium behind a proxy that strips sec-fetch-*, so the server really sees none; no browser that truly lacks those headers was run.

Why the mark is a time and not a bare mark. SocialSignInButtons defaults callbackURL to window.location.href, so a provider sign-in comes back to the same URL with the mark still on it. A bare mark would then hold the form over the brand-new session, which is a dead end for exactly the Google users this flow exists for. With a time, the new session's createdAt is later and the page forwards. It also spares a device whose clock is ahead, where a fresh provider session read as too old. No identity provider exists on this machine, so that return was measured as a sign-in made elsewhere followed by opening the same URL again: it ended at the app with the device clock 15 minutes behind and 15 minutes ahead.

On one process the mark and createdAt are two readings of one clock. On several nodes behind one host they are two clocks, so skew can hold the form until a sign-in makes a later createdAt, which clears it. The docblocks say so.

The mark across the sign-in pages. registerUrl on /login, and on /register both loginUrl and the bounce a closed sign-up makes, now carry the mark beside a hand-off redirect (handoffMarkQuery). Beside any other redirect, or none, those links are byte-identical, and a test pins each.

Reading of RegisterPage with the mark, from the code and its pins. useHandoffRedirect reads the mark from the URL when it judges a session, so /register behaves as /login does. A session not created after the mark holds the form, whatever the device's clock says. A sign-up on that form changes the session to one with no createdAt, which is not the one found on arrival, so it forwards. A session created after the mark, which is a provider returning to this URL, forwards.

Measured at this PR's head, under the real router, with a route-issued mark, an old session and the device clock 15 minutes behind: /login held, "Sign up" to /register with the mark kept and the form held, "Sign in" back to /login with the mark kept and the form held, one hand-off request throughout; a sign-in then made the second hand-off request. The showcase is invite-only, so showing "Sign up" took patching the config response with Playwright interception, and under interception a 302 to the app's scheme reports net::ERR_ABORTED. A control with interception and no round trip reads the same. The /register to /login direction, run with no interception, read that second request as the 302.

Landing order and release

#29979 and #30004 are both on main, and the route and the page now change together in this PR, so nothing has to land first.

This carries in the next release after it merges: 18.4.0 as pending changesets stand (13 of 62 are minor, and these packages version together). objectstack-ai/mobile#190 can change its entry only once its server runs that release, because an older server answers the new entry with JSON.

Tests

  • one-time-token-handoff.test.ts: 88 tests on the real AuthManager, the real better-auth plugin and the real route registration. Page navigation with no session and with an old session gets the 302; JSON, no headers, */*, a fetch asking for HTML and an iframe get 401; an unregistered scheme, a bad challenge, a plain method and an unknown parameter get 400 with no redirect; cross-site gets 400 first; the redirect it names signs in, comes back and redeems. A path starting with // gets the 401 and one warn and asks the server nothing. resolveHandoffSignInEntry and isPageNavigation have their own tables.
  • handoffRedirect-29971.test.ts and signInHandoff-29971.test.tsx (the existing harness files for this component, so the new pins are top-level describes there): a session created after handoff_refused_at is fresh and one not created after it is held, each with the device clock agreeing, an hour behind and an hour ahead; a value that is not digits is ignored and the five-minute check decides; a session with no createdAt keeps its branch; on the real LoginPage and RegisterPage, the form holds, a sign-in or sign-up on it forwards, and a session created after the mark forwards. Each of the three links carries the mark beside a hand-off redirect and stays byte-identical beside any other redirect, with no mark on the page, and with no redirect.
  • Pins: the two existing 401 pins on the route send no navigation headers, so they stay and their titles now say so. No other pin of this route's 401 exists in the repo.
  • examples/app-showcase/e2e/mobile-signin-handoff.spec.ts, two tests. Signed out, open the hand-off, land on the form on this origin, sign in, see the 302 to the registered scheme, and redeem the token with the verifier (and fail without it). And a sign-in made elsewhere that returns to the same URL forwards on a device whose clock is ahead, which is what notices the route and the page disagreeing on the parameter's name.

Acceptance notes

  • Where the browser test lives, and that it is nightly. Not ui/e2e: Live E2E UI serves the Console from vite preview at the root and the backend there has no built Console to serve, so the route's redirect has nothing to land on. examples/app-showcase/e2e is the one lane where the backend serves the Console at its real mount. It runs in Nightly Tiers and a red night files a card, so a Console change that breaks this shows up the next night, not on that PR. The two tests add about two seconds to that suite.
  • Scheme registration. A test-only stack config works only at the app root (the compile resolves connector spec paths from the config's directory) and through composeStacks (a spread loses the defineStack provenance, and defineStack on a built stack reports 71 cross-reference issues), and every smoke spec would then boot a composed stack. OS_TRUSTED_APP_SCHEMES on the webServer is one line and the path a deployment uses.
  • File surface beyond the first claim: examples/app-showcase/playwright.config.ts (that one line), the doc comment of examples/app-showcase/test/showcase-smoke.live.test.ts, the page half, and LoginPage.tsx and RegisterPage.tsx only for the mark on the links between them, per the claim updates on the card.
  • One path still drops the mark. /verify-email-prompt is given only redirect by both pages, and VerifyEmailPromptPage's "back to login" builds /login?redirect=… from that. So a person sent there holding an old session, on a device whose clock is behind, who goes back is judged by the age check. A forward then ends at the route's 401 with Fetch Metadata, or at one more redirect whose mark holds the form without it. Read from the code, not run. It stops rather than loops. Left alone: VerifyEmailPromptPage.tsx is outside this card's surface.
  • Cloud does not offer this flow yet: objectstack-ai/cloud#3549.
  • packages/qa/dogfood/test/principal-session-console-boot.dogfood.test.ts skips on every CI run as far as the workflows read: none sets its Chromium path, and none builds the Console on that job. Noted, not filed.

Verification

Head 3e59db0af0, one commit on 584f1e43fc, which the contract review read. main was not merged again: no conflict and nothing red forced it, so the merge with origin/main stays f68b47cace.

Check Result
pnpm --filter @objectstack/plugin-auth exec vitest run 220 files; 3864 passed, 10 skipped
pnpm --dir ui exec vitest run apps/console/src/pages/auth/__tests__/ 27 files, 217 passed
pnpm --filter @objectstack/plugin-auth run typecheck, tsc --noEmit on @objectstack/spec exit 0
pnpm --filter @object-ui/console run type-check exit 0
eslint, --no-inline-config on the server-side files, pnpm --dir ui exec eslint on the page files exit 0; 0 errors, and 5 warnings, the react-hooks/set-state-in-effect these pages and this hook already carried
dispatch-gates --commands (12 paths, 88 commands) 87 exit 0, 1 exit 1: pnpm --dir ui exec vitest run scripts/, read below
dispatch-gates --ran 88 derived famil(ies) accounted for — 88 run, 0 NOT-MEASURED
The five roster gates whose list lives under a touched directory (check:meta-url-spelling, check:authz-resolver, check:changeset-fixed, check:error-code-casing, check:filter-alias-parity) exit 0 each
pnpm --filter @objectstack/spec check:generated exit 0: all 14 generated artifacts up to date, so the docblock moved none
Browser spec, Console from pnpm console:build at this head 2 passed

The one non-zero gate. pnpm --dir ui exec vitest run scripts/ read 5 failed of 4987 tests on this Mac, none in a file this PR touches. Three were whole-tree scans hitting the 15 s test timeout, and those files pass alone with a longer timeout; which ones hit it varies by run. The other two are in select-affected-tests-wiring.test.ts and stop at mapfile: command not found: macOS ships bash 3.2. CI's run on Linux is the authority for this family: Test UI concluded SUCCESS on this head, all 16 shards, and the shard logs show those files, and the ones the previous head's local run timed out on, passing there.

Ablations on the browser spec (Chromium 151, headless; each restored and rebuilt)
Mutation Red
Route's redirect removed, plugin-auth rebuilt, ablation-dist-preflight confirmed absent from dist/ Both: 25 s timeout at #login-email, on the JSON page
Page stops recognising the hand-off (handoffNavigationTarget returns null), Console rebuilt Both: 45 s timeout waiting for the 302 to the app, which is what a Console upgrade could do
Route's parameter renamed, plugin-auth rebuilt, ablation-dist-preflight confirmed in dist/ The second: 45 s timeout, the page held its form. The first stayed green.

Taken at 584f1e43fc. After restoring and rebuilding, the spec read 2 passed.

Unit ablations, through scripts/ablation-replace.mjs (each restored; blob equals HEAD)
Mutation Red
Page: refusedAt branch deleted 8
Page: reader not wired into the verdict 3, all on the real LoginPage
Page: digit check removed 5
Page: handoffMarkQuery not limited to a hand-off redirect 2
/login: "Sign up" without the mark 1
/register: "Sign in" without the mark 1
/register: closed sign-up bounce without the mark 1
Route: isPageNavigation guard deleted 5
Route: same-origin stop deleted 1
Route: // guard off 4
Route: // guard on the sign-in page only, not the hand-off 1
Route: served check made true 6
Route: the 302 replaced by the refusal 8
Route: Location given the full URL instead of the path 6
Route: handoff_refused_at deleted 2

The // guard and link rows are at 3e59db0af0, the others at 584f1e43fc.

Declared narrowing — verification ran UNLOCKED. scripts/pm/os-verify-lock.sh could not take the shared verify lock on this host: no usable flock. The shared verify lock is declared Linux-only (flock is util-linux, and a stock macOS does not ship it), so the commands above were run directly, without the lock — a declared narrowing, not a silent one. No serialization guarantee held for these runs, nor for any sibling agent in this container while they ran.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (Docblock only.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; eslint on the touched files passes. (CI's pnpm lint and Lint UI are the authority.)
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: no generated artifact moved.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English. (The maintainer brief above is Chinese by the project's convention.)
  • The Before Submitting list in CONTRIBUTING.md is done.

Body written 2026-11-04T17:22Z.


Generated by Claude Code

hotlong and others added 6 commits November 4, 2026 11:39
…to sign in first goes to the sign-in page

The app can now open the hand-off and nothing else, so the Console's sign-in
route stays internal to this repository and its release. Every 400 stays ahead
of the new answer, a non-navigation keeps the 401 the app's probe reads, a
deployment with no Console keeps it too, and a same-origin navigation the route
still refuses gets the 401 instead of another redirect.

Claude-Session: https://claude.ai/code/session_c967d7a472b0485c94711bfcb22159c8
Co-authored-by: Claude <noreply@anthropic.com>
…ead of failing the hand-off

Claude-Session: https://claude.ai/code/session_c967d7a472b0485c94711bfcb22159c8
Co-authored-by: Claude <noreply@anthropic.com>
…oyment does

A test-only stack config has to sit at the app root and go through composeStacks,
and then every smoke spec boots a composed stack. OS_TRUSTED_APP_SCHEMES on the
webServer is one line and the real path.

Claude-Session: https://claude.ai/code/session_c967d7a472b0485c94711bfcb22159c8
Co-authored-by: Claude <noreply@anthropic.com>
… which the mount census can read

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

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-auth, @objectstack/spec, touching 16 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/src/api/auth-endpoints.zod.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/deployment/environment-variables.mdx (via /api/v1/auth/one-time-token/handoff (route, a path literal in AUTH_ROUTE_LEDGER))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/src/api/auth-endpoints.zod.ts) — pages documenting those are invisible to this run
  • 1 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 — 175 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 dcb5fb3ef30b373aae040476e84e1df84cec77d2 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 dcb5fb3ef30b373aae040476e84e1df84cec77d2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · hotlong · session_c967d7a472b0485c94711bfcb22159c8

席位核对 · not yet ACCEPT · head fe2b8b490760c17fd94327e74624b83b3f60747b

维护者:这个 PR 现在还不要合并。 它要等 #29979 和 #30004 先落地,再补下面三项,然后才做契约复审并交你审。PR 正文里“请审阅并合并”指的是那之后。

Read from the diff on GitHub, not from the report:

  • Shape: draft, base main, first line Fixes #29997, no other closing keyword in the body. No content/docs/releases/ change. one-time-token-handoff.js is not exported from the package entry, so its new names reach no public surface, which supports Clause-②: no.
  • Route order: every 400 is still decided ahead of the new answer, the token path is unchanged, and redirect is rebuilt from the validated parameters.
  • Surface beyond the claim: examples/app-showcase/playwright.config.ts (one env line) and a doc comment in showcase-smoke.live.test.ts, needed for the browser test. Accepted.
  • Browser test is nightly, not per PR: the card asked for CI, and the reasons in Acceptance notes hold up against the spec. Accepted. A Console upgrade that breaks this shows up the next night, not on its own PR.

Owed in one patch round, after #29979 lands:

  1. Page half. Carry the handoff_refused_at branch in ui/apps/console/src/pages/auth/handoffRedirect.ts with tests in this PR. The fix(console): the sign-in page finishes the mobile sign-in hand-off instead of landing on a missing Console page #29979 seat agreed that if fix(console): the sign-in page finishes the mobile sign-in hand-off instead of landing on a missing Console page #29979 lands first, this card carries both halves (comment 6620189624; the update is on fix(console): the sign-in page finishes the mobile sign-in hand-off instead of landing on a missing Console page #29979). This keeps it in the same release, and it is what makes the changeset's "cannot bounce" true.
  2. Spec docblock (published text). On OneTimeTokenHandoffQuerySchema, the 302 to sign in line says a page navigation gets it, and the 401 line says "a request that is not such a page navigation". A navigation reporting Sec-Fetch-Site: same-origin is a page navigation and gets the 401. State that case.
  3. Module docblock. "It cannot bounce" and "the second holds even for a page that ignores the first" need the condition this PR's own body gives: a browser with no Fetch Metadata can still bounce until the page half is present.

Then re-run the browser spec at the merged tree. The page half also moves this PR's file surface; the claim is updated at that point.

Maintainer brief correction: 风险与回滚 says 旧版 App 不受影响. No released app uses this flow yet (the card says so), so there is no older app to affect. The seat's final brief will say that.

Open, for the review or for #30010's next round: resolveHandoffSignInEntry calls server.fetch with env but no execution context. On a host whose middleware reads one, that could throw, end in onUnserved, and leave the JSON 401. Read from the diff, not measured. objectstack-ai/cloud#3549 is where it would first matter.

Out-of-scope findings:

  • Cloud offers no route or scheme: already filed as objectstack-ai/cloud#3549.
  • principal-session-console-boot.dogfood.test.ts skipping in CI: stays in Acceptance notes. It is not a product defect, and no maintainer order covers it, so it is outside the filing gate.

Contract-review-hold: session_c967d7a472b0485c94711bfcb22159c8 · head fe2b8b4 · this head will move · until #29979 and #30004 are merged and the patch round above is pushed

Tier reading: the diff touches packages/spec/src/**, so a CONTRACT_REVIEW_TIER review is owed at the final head.

Posted 2026-11-04T04:34Z.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#1 · os-support-ai · session_01WGufzjDyeFwq6AuFu3vwTd

Head moved (as asked in the request above): 2026-11-04T12:48Z the seat updated this branch with main through the relay (pr_update_branch, expected c6ac92e1). New head ccd2e42fe25e1336ba4e8546add14f6936ac7ae1 is a two-parent merge of main only. The page code and the tests are the same as at the seat's ACCEPT. The PR is now ready for review, and fresh CI replaces the run that was cancelled in checkout.

Next: when CI is green the seat queues the landing (check-governed-merges --pr 29979: NOT governed, 641 lines). If branch protection still asks for an approving review, the seat asks the maintainer here. (Copy for #30010: #29979 head is now ccd2e42f, merge of main only.)

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · hotlong · session_c967d7a472b0485c94711bfcb22159c8

Contract review scope widened for this PR, by the maintainer. Source: the maintainer hotlong, in session session_c967d7a472b0485c94711bfcb22159c8, 2026-11-04. Words: "并入复审".

Why: #29979 is the page half of this flow. It merged at f35b361997 with an empty review list, though #29971 says "the maintainer reviews and merges" and its seat brief asked the same. The seat's landing record reads "no approval required (mergeable_state clean)".

So the review at this PR's final head also reads, as merged on main: ui/apps/console/src/pages/auth/handoffRedirect.ts, and the hand-off step in LoginPage.tsx and RegisterPage.tsx, with their tests. The record's Scope: line names them, and any finding on them is reported in that record. The maintainer reads the result when reviewing this PR.

The Contract-review-hold in comment 6622648613 still stands until the patch round is pushed.

Posted 2026-11-04T13:57Z.

…time over the device's clock

The route's redirect says when it refused the session. A session not created
after that keeps the form up whatever the device's clock says, and one created
after it forwards, so a device whose clock is behind does not bounce between
the route and the page, and a provider's sign-in that returns to the same URL
still ends at the app. The docblocks and the changeset say what is true with
this in place, and the spec's published answers name the same-origin case.

Claude-Session: https://claude.ai/code/session_c967d7a472b0485c94711bfcb22159c8
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl and removed size/l labels Nov 4, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · hotlong · session_c967d7a472b0485c94711bfcb22159c8

Contract-review-claim: session_c967d7a472b0485c94711bfcb22159c8 · head 584f1e4 · 2026-11-04T15:36Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · hotlong · session_c967d7a472b0485c94711bfcb22159c8

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 584f1e43fc38715b02a0bec34de48a5f4228c39c
Local-runs: none
Scope: packages/spec/src/api/auth-endpoints.zod.ts + packages/plugins/plugin-auth/src/one-time-token-handoff.ts, packages/plugins/plugin-auth/src/auth-plugin.ts, packages/plugins/plugin-auth/src/auth-route-ledger.ts, ui/apps/console/src/pages/auth/handoffRedirect.ts (at this head, reviewed under the maintainer's widening 「并入复审」), ui/apps/console/src/pages/auth/LoginPage.tsx and ui/apps/console/src/pages/auth/RegisterPage.tsx as merged on main at f35b361 by #29979 (reviewed under the widening); cited and read, not reviewed: packages/plugins/plugin-auth/src/auth-manager.ts (getConsolePageUrl, getAuthIssuer, getCanonicalOrigin), packages/cli/src/utils/console.ts (readShellConfig, the 503 responder), ui/apps/console/src/utils/consoleBase.ts, scripts/check-auth-mount-ledger.mjs. The rest of the diff (the changeset, examples/app-showcase/e2e/mobile-signin-handoff.spec.ts, playwright.config.ts, showcase-smoke.live.test.ts, the three test files) is the lane seat's ACCEPT at TIER_DEFAULT.

① Derived judgments

  1. GET /api/v1/auth/one-time-token/handoff gains one answer: 302 to this deployment's sign-in page for a page navigation whose session is missing or older than five minutes, on a process that serves that page. Every other answer is unchanged, and every refusal keeps its place: cross-site 400 first (line 498), then the closed query set, the scheme, the challenge and the method (506–544), all before any session is read (validated is built at 548, sessionCreatedAt at 552); 401 for a non-navigation, for a same-origin navigation and for an unserved page. Right. The app's anonymous probe (Accept: application/json, no Sec-Fetch-Dest: document) still reads 401: isPageNavigation is false for it, pinned.
  2. The token-bearing 302 (578–580) is untouched: same withHandoffChallenge binding, same Location of redirect_uri plus token, same NO_STORE. Right.
  3. packages/spec: docblock only. OneTimeTokenHandoffQuerySchema, AuthEndpointPaths.oneTimeTokenHandoff and every export are unchanged; Spec property liveness and both Check Changeset runs green; the dev reports check:generated clean. No accept-set moves. Right.
  4. plugin-auth package surface: one-time-token-handoff.ts is not among src/index.ts's export * from lines, so the new exports (HANDOFF_REFUSED_AT_PARAM, HandoffSignInEntry, isPageNavigation, resolveHandoffSignInEntry) and the new REQUIRED HandoffDeps.signInEntry are package-internal; the only constructor of HandoffDeps outside tests is auth-plugin.ts, which supplies it. Right.
  5. auth-plugin.ts: no new mount. The existing rawApp.get(basePath + '/one-time-token/handoff') gains signInEntry; the in-process probe is a rawApp.fetch(...) call inside the handler, not a mount verb, so check:auth-mount-ledger has nothing new to read. Lint & Repo Gates and Lint · repo & tooling gates green. Right.
  6. Ledger note: text only and consistent with point 1 (five-minute mint, root-relative 302 to the sign-in page, the three 401 cases). Right.
  7. Console: handoffRedirect.ts adds HANDOFF_REFUSED_AT_PARAM, handoffRefusedAt and an optional refusedAt on isHandoffSessionFresh; useHandoffRedirect's signature is unchanged, so LoginPage and RegisterPage on main pick the mark up through the hook with no edit. Additive. Right.
  8. Check-runs on this head: 25 success, 10 skipped, 0 failure, 30 in_progress. Not yet read (running at review time, not awaited): Build Core, Build & E2E UI, Lint UI, Type Check UI, Type Check · workspace, Type Check · debt ledger, Test Core 1–8, Test UI 1–16. Those are the CI families that run the route's and the page's unit tests; their conclusions stand on their own and this record does not pre-empt them.

② Semver level

.changeset/29997-handoff-signin-entry.md: @objectstack/plugin-auth, @objectstack/spec, @object-ui/console at patch; Clause-②: no and Metadata-protocol: none on the changeset body and on the PR body. Matches the diff: no spec schema, export or generated artefact moves, so Clause-②: no is right; no metadata key, value or default changes hands, so none ⇒ patch under AGENTS.md Post-Task 3 (the level follows the kind, not the feat( prefix). The three packages named are the three the diff moves, and all sit in the fixed group, so they version together. Check Changeset green. PASS.

③ Boundary flags

open_questions: []; the flags are the report's deviations and the PR's NOT MEASURED lines.

  • Real ASWebAuthenticationSession / Custom Tabs headers not measured. Answered as far as code goes: when neither Sec-Fetch-Dest nor an HTML Accept arrives, both arms fall to the 401 JSON that is today's answer, so a surprise there costs the new redirect, never a wrong one. Reviewer's reading, also unmeasured here: Chrome (Custom Tabs) has sent Fetch Metadata since 76 and WebKit (ASWebAuthenticationSession) since iOS 16.4; every browser names text/html in Accept on a top-level navigation, which is the fallback. Escalated to the maintainer as the one assumption feat(shell): sign in through the system auth session when a provider refuses the WebView mobile#190 measures first.
  • The mark is a server time, not the bare mark the fix(console): the sign-in page finishes the mobile sign-in hand-off instead of landing on a missing Console page #29979 thread agreed. Judged right: SocialSignInButtons defaults callbackURL to window.location.href, so a provider's fresh session returns under the mark, and a bare mark would hold the form over it — the Google users this flow exists for. A time lets a later createdAt forward. Flagged for the maintainer's ratification because it changes an agreed arrangement; no code objection.
  • The browser test is nightly, not per-PR. The card asked for it "in CI"; it runs in Nightly Tiers from examples/app-showcase/e2e because Live E2E UI serves the Console from vite preview with no backend mount for the redirect to land on. Escalated: a Console change that breaks this flow shows the next night, not on its own PR. A real deviation from the acceptance note with a stated cause; the maintainer decides whether a per-PR lane is worth building.
  • The Sec-Fetch-Site: same-origin carve-out. Answered in ④: it is the second stop of the loop, and it costs a stale URL reopened from history a JSON stop instead of a form. Stated in the spec docblock. Fine.
  • No execution context on the probe. Answered: c.executionCtx is read nowhere in this repo or in objectstack-ai/cloud (grepped both); @hono/node-server hands env alone; the probe passes the outer c.env ({incoming, outgoing}), and the one reader of it in this repo (readTransportPeerAddress, guarded, incoming.socket.remoteAddress) resolves to the same client; no writer of env.outgoing exists in the repo. Precedent: readShellConfig dispatches the same way on every Console document. The pin for an unguarded reader (keep the 401, log it answers 500) is present.
  • Landing order. fix(console): the sign-in page finishes the mobile sign-in hand-off instead of landing on a missing Console page #29979 merged at f35b361 and fix(cli): os serve reads OS_TRUSTED_APP_SCHEMES into AuthPlugin's trustedAppSchemes #30004 at d9895a1, both on main; the showcase webServer env line resolves against OS_TRUSTED_APP_SCHEMES on main. Nothing is left to land first.
  • "Noted, not filed". The registerUrl mark drop in LoginPage.tsx on main is judged in ④ and needs its own card. The dogfood skip and cloud#3549 are outside this scope.
  • Verification ran UNLOCKED on macOS. A declared narrowing of the dev's local runs, not of the code; the head's check-runs are the gate verdicts here.

④ Design judgment

packages/spec/src/api/auth-endpoints.zod.ts (hits) — against the maintainer's order on #29997 and the route as its first consumer. Every sentence of the new docblock was read against runOneTimeTokenHandoff at this head. "Every 400 decided before any session is read" holds (the session is read at 552, after the last 400). The 302-to-sign-in conditions (a page navigation, no fresh session, a deployment that serves the Console, not same-origin) are exactly refusedSessionAnswer's three gates. "Root-relative so the browser stays on this origin" holds: Location is a pathname plus a query. The 401 list is complete. Two imprecisions, should-fix: (a) the docblock says the sign-in Location carries redirect and omits handoff_refused_at, which the route also writes — defensible because the page's route is declared out of contract, but the published list of what Location carries should not be shorter than what it carries; (b) "a deployment that serves no Console" is wider than the code's test, which is "this PROCESS answers the sign-in page with 200 text/html" — a Console served by a front proxy also keeps the 401, which the route module docblock says and the spec docblock does not. Neither misleads an app: the app never parses this Location.

packages/plugins/plugin-auth/src/one-time-token-handoff.ts (widened). Tried to break it:

  • Send a token elsewhere. The sign-in 302 carries no token; the token 302 is untouched. Nothing attacker-controlled reaches Location unencoded: redirect is built from validated (the three parameters, after every refusal) through URLSearchParams, so a CRLF, a second ? or a redirect_uri with any character cannot escape its value; signInPage and handoff are config paths. Holds.
  • Send the browser off-origin. Location takes new URL(getConsoleUrl('/login')).pathname — a path, never the canonical origin; pinned (canonical.example.com never reaches Location, uiBasePath: '/console/' honoured). One config-only gap, should-fix: getCanonicalOrigin strips ONE trailing slash, so a baseUrl ending in // composes https://h//_console/login, whose pathname is //_console/login; deploymentBase then resolves to /, localPath to /_console/login, the probe is served, and Location: //_console/login?… is protocol-relative — the browser leaves for a host named _console. Not reachable from a request (config alone), every mailed link of that deployment is already broken the same way, and no token rides that redirect; still, the page refuses a redirect that starts with // and the route should refuse a signInPage or handoff that does, as unserved.
  • Forge handoff_refused_at or redirect to skip the five-minute rule. The page's verdict decides forward-or-form and nothing else; the route re-reads the session's createdAt on its own clock on every request, and a forwarded old session gets 401. A forged mark mints nothing. Holds.
  • Hold a person out. A link crafted with a far-future mark holds that page's form even after a sign-in on it (no createdAt is later than it) — but only on a URL the attacker built; the app's own entry gets the route's now. Equivalent to any broken link; no gain. A mark of 0 makes the page forward an old session into the 401 JSON — the fix(console): the sign-in page finishes the mobile sign-in hand-off instead of landing on a missing Console page #29979 behaviour on main today. Holds.
  • A malicious app or page. Everything the new 302 lets a navigation reach was reachable by opening /_console/login?redirect=… directly (card, Security). The cross-site 400 is still first and still judged only when the header is present (ruling A); an old browser without Fetch Metadata keeps exactly the exposure ruling A accepted, no more. An iframe or a fetch asking for HTML gets 401 (Sec-Fetch-Dest outranks Accept, pinned). No new capability. Holds.
  • Loop. Two independent stops, both pinned: the page holds any session not created after the mark (it reads no request header, so it holds with or without Fetch Metadata), and the route answers 401 to a same-origin navigation it refuses, which is the only way the page forwards. The residual — a stale URL reopened after five minutes with a session created after the mark, or a session the page reads and the route cannot — ends at 401 JSON with Fetch Metadata, or at one more redirect whose newer mark holds the form without it. Neither repeats. The dev's 228-requests-in-20-s reading at the pre-guard head is the control. Holds.
  • A host where the Console or the headers differ. --no-ui, an unbuilt Console's 503, a Console that redirects elsewhere, a 200 that is not HTML, a uiBasePath that composes no URL: each pinned to 401 with one warn. A base path in baseUrl is kept in what the browser is told and stripped from what this process is asked, pinned both ways. The probe carries no cookie and Accept: text/html only, and it cannot re-enter this route (its path is the Console's; Hono matches the mount exactly). Cost: one in-process document fetch per refused navigation, plus the Console's own readShellConfig hop — bounded, no recursion; an observation, not a finding.
  • Clock. The docblock's "two readings of one clock" is true per process; on a multi-node deployment behind one host it is one clock per node, and a refusal stamped on a node ahead of the node that then signs the person in holds the form for the skew's duration (each sign-in makes a later createdAt, so it clears). Should-fix as a docblock caveat; NTP skew is far under the time a sign-in takes.

packages/plugins/plugin-auth/src/auth-plugin.ts (widened). Wiring only: getConsoleUrl('/login') is the composer every mailed sign-in link already uses (auth-manager.ts:2793), getAuthIssuer() the base the published iss carries, basePath the mount. reportedNoHandoffSignInPage is a per-mount once-flag. No new mount, nothing for the ledger gate. Holds.

packages/plugins/plugin-auth/src/auth-route-ledger.ts (widened). The note matches ① point 1. Holds.

ui/apps/console/src/pages/auth/handoffRedirect.ts (widened, at this head). handoffRefusedAt accepts digits only and refuses a non-safe integer ('9'.repeat(400) → null → the age check decides, pinned). isHandoffSessionFresh with a mark compares two server timestamps and never reads now; without one it is unchanged. useHandoffRedirect reads the mark in the same layout effect that reads the clock, once per session identity, so a sign-in on the form re-judges with the new session. handoffNavigationTarget (merged by #29979, unchanged here) refuses //, off-origin and lookalike paths and re-serialises what it checked. Holds.

LoginPage.tsx / RegisterPage.tsx as merged on main (widened). The hand-off branch runs before the organization steps and only by window.location.assign(handoff.target) (same-origin, as the route requires), guarded by mobileHandoffStartedRef; a held session shows the form (signInNeeded = !user || handoff.holdForSignIn). The SSO branch (client_id plus redirect_uri at the top of the query) is checked first, which is why the route keeps the app's redirect_uri nested inside redirect (pinned). The non-hand-off path keeps isSafeRedirect plus withConsoleBase, so the hand-off exception is not a general way out of the mount. Holds, bar one finding. Should-fix, already merged (#29979), needs its own card: LoginPage.tsx:328 builds registerUrl as /register?redirect=… and drops handoff_refused_at, and RegisterPage.tsx:171 bounces a closed sign-up to /login?redirect=… the same way; a person holding an old session on a device whose clock is behind who follows Sign up is judged by the age check alone and forwarded into the route's 401 JSON (the same-origin stop). It stops and never loops, and this PR does not depend on it, so it does not count against this verdict. The fix is one line in each page: carry the mark.

Must-fix: none. Should-fix: the spec docblock's two imprecisions; a // guard on signInPage and handoff; the multi-node clock caveat; the registerUrl and /login-bounce mark drop (merged code, own card).

Implemented-by: claude/issue-29997-handoff-signin-entry
Reviewed-by: session_c967d7a472b0485c94711bfcb22159c8

VERDICT: PASS

The spec docblock lists handoff_refused_at among what the sign-in Location
carries, and says what the code tests: this process answers the page with
200 text/html, so a Console in front of it keeps the 401. A sign-in page or
hand-off path starting with // counts as unserved, because config alone can
compose one and it would make Location protocol-relative. The docblocks say
the two readings are two clocks on several nodes. The links between /login and
/register carry the mark beside a hand-off redirect, and every other link is
unchanged.

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

Copy link
Copy Markdown
Contributor Author

domain:server#2 · hotlong · session_c967d7a472b0485c94711bfcb22159c8

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3e59db0af0da68805e35387a3ee09e806d0cdd2a
Local-runs: none
Scope: packages/spec/src/api/auth-endpoints.zod.ts + this delta (one commit on the PASS-reviewed head 584f1e4, compare 584f1e4...3e59db0, ahead 1, behind 0): packages/plugins/plugin-auth/src/one-time-token-handoff.ts, ui/apps/console/src/pages/auth/handoffRedirect.ts, ui/apps/console/src/pages/auth/LoginPage.tsx and ui/apps/console/src/pages/auth/RegisterPage.tsx (now changed by this PR; reviewed under the maintainer's widening 「并入复审」), plus the earlier scope: packages/plugins/plugin-auth/src/auth-plugin.ts, packages/plugins/plugin-auth/src/auth-route-ledger.ts, and the hand-off step of LoginPage.tsx / RegisterPage.tsx as merged on main at f35b361 by #29979; cited and read, not reviewed: packages/plugins/plugin-auth/src/auth-manager.ts (getConsolePageUrl, getAuthIssuer, getCanonicalOrigin), packages/cli/src/utils/console.ts (readShellConfig, the 503 responder), ui/apps/console/src/utils/consoleBase.ts, ui/apps/console/src/pages/auth/VerifyEmailPromptPage.tsx (main), scripts/check-auth-mount-ledger.mjs. The rest of the diff (the changeset, examples/app-showcase/e2e/mobile-signin-handoff.spec.ts, playwright.config.ts, showcase-smoke.live.test.ts, the three test files) is the lane seat's ACCEPT at TIER_DEFAULT.

① Derived judgments

  1. GET /api/v1/auth/one-time-token/handoff gains one answer: 302 to this deployment's sign-in page for a page navigation whose session is missing or older than five minutes, when this process answers that page with 200 text/html. Every other answer is unchanged and every refusal keeps its place: cross-site 400 first, then the closed query set, the scheme, the challenge and the method, all before any session is read; 401 for a non-navigation, for a same-origin navigation and for an unserved page. Right. The app's anonymous probe (Accept: application/json, no Sec-Fetch-Dest: document) still reads 401, pinned.
  2. The token-bearing 302 is untouched: same withHandoffChallenge binding, same Location of redirect_uri plus token, same NO_STORE. Right.
  3. Delta: resolveHandoffSignInEntry now answers null (⇒ 401, one warn) when the sign-in page path or the hand-off path starts with //, before the probe is sent; pinned at both the function (server not asked) and the route (baseUrl ending in //, uiBasePath starting with //). A refusal added, no answer widened. Right.
  4. packages/spec: docblock only, in both commits. OneTimeTokenHandoffQuerySchema, AuthEndpointPaths.oneTimeTokenHandoff and every export unchanged; Spec property liveness and Check Changeset green on this head. No accept-set moves. Right.
  5. plugin-auth package surface: one-time-token-handoff.ts is not among src/index.ts's export * from lines, so its new exports and the REQUIRED HandoffDeps.signInEntry are package-internal; the only constructor outside tests is auth-plugin.ts, which supplies it. Right.
  6. auth-plugin.ts (unchanged by the delta): no new mount; the probe is a rawApp.fetch(...) call inside the existing handler, nothing for check:auth-mount-ledger. Lint & Repo Gates green. Right.
  7. Ledger note: text only, consistent with point 1. Right.
  8. Console: handoffRedirect.ts adds HANDOFF_REFUSED_AT_PARAM, handoffRefusedAt, an optional refusedAt on isHandoffSessionFresh, and (delta) handoffMarkQuery; useHandoffRedirect's signature is unchanged. LoginPage.tsx and RegisterPage.tsx are now in the PR's file list: three link builders gain the handoffMarkQuery(redirect) suffix (/login → /register Sign up; /register → /login Sign in; the closed-sign-up bounce to /login). Every other link and every non-hand-off page is byte-identical, pinned by three CONTROLs. Right.
  9. Check-runs on this head, 72 in all: 56 success, 12 skipped, 0 failure, 4 running. Not yet read: Check Changeset and PR-body guards (claim · duplicate · Part-of · single-writer path), one in_progress and one queued each — re-runs triggered by a PR edit; the first instance of each on this head is success. Every build, lint, type-check and test family (Build Core, Build & E2E UI, Lint UI, Type Check UI, Type Check · workspace, Type Check · debt ledger, Test Core 1–8, Test UI 1–16, Live E2E UI, Dogfood Regression Gate) is success on this head — the families named as unread on the previous record are now read.

② Semver level

.changeset/29997-handoff-signin-entry.md: @objectstack/plugin-auth, @objectstack/spec, @object-ui/console at patch; Clause-②: no and Metadata-protocol: none on the changeset body and the PR body. The delta adds one sentence (the links between the sign-in pages carry the mark) and moves no level. Matches the diff: no spec schema, export or generated artefact moves, so Clause-②: no is right; no metadata key, value or default changes hands, so none ⇒ patch under AGENTS.md Post-Task 3 (the level follows the kind, not the feat( prefix). The three packages named are the three the diff moves, all in the fixed group. Check Changeset success on this head (a re-run is in progress). PASS.

③ Boundary flags

open_questions: []; the flags are the report's deviations, the PR's NOT MEASURED lines, and the should-fix findings of the previous record on 584f1e4, each judged against the tree at this head.

  • Previous should-fix (a), spec docblock omitted handoff_refused_at. Landed: the 302-to-sign-in bullet now names redirect and handoff_refused_at ("the server's time of the refusal in epoch milliseconds"), which is what refusedSessionAnswer writes. Closed.
  • Previous should-fix (b), "serves no Console" wider than the code. Landed: both the 302 and the 401 bullets now say "when this process answers the Console's sign-in page with 200 text/html" and that a Console served by something in front of this process keeps the 401 — the exact test resolveHandoffSignInEntry runs (status === 200 and a content-type starting text/html). Closed.
  • Previous should-fix (c), a // path makes Location protocol-relative. Landed: the guard, its reason string, and four pins (two on the function with the server not asked, two on the route through the real AuthManager with baseUrl: 'https://canonical.example.com//' and uiBasePath: '//elsewhere.example', each 401, no Location, said once). Closed; tried to break it in ④.
  • Previous should-fix (d), "two readings of one clock" on several nodes. Landed in the route module docblock (loop section), on HANDOFF_REFUSED_AT_PARAM, and on isHandoffSessionFresh: on one process one clock; on several nodes two clocks, skew holds the form until a later createdAt clears it. Closed.
  • Previous should-fix (e), registerUrl and the closed-sign-up bounce drop the mark (merged code, needed its own card). Landed in this PR instead, which is the better carrier since the mark is this PR's: /login's Sign up, /register's Sign in and the closed-sign-up bounce carry it beside a hand-off redirect; RegisterPage now has its own rule-5 pins (held, sign-up forwards, created-after-mark forwards). No card needed. Closed; tried to break it in ④.
  • Real ASWebAuthenticationSession / Custom Tabs headers not measured. Unchanged from the previous record: both arms fall to the 401 JSON that is today's answer when neither Sec-Fetch-Dest nor an HTML Accept arrives. Escalated to the maintainer as the one assumption feat(shell): sign in through the system auth session when a provider refuses the WebView mobile#190 measures first.
  • The mark is a server time, not the bare mark the fix(console): the sign-in page finishes the mobile sign-in hand-off instead of landing on a missing Console page #29979 thread agreed. Judged right (a bare mark would hold the form over a provider's fresh session returning under window.location.href). Flagged for the maintainer's ratification; no code objection.
  • The browser test is nightly, not per-PR. Unchanged; escalated: a Console change that breaks this flow shows the next night, not on its own PR; the maintainer decides whether a per-PR lane is worth building.
  • No execution context on the probe. Unchanged, answered: no reader of c.executionCtx in this repo or objectstack-ai/cloud; no writer of env.outgoing; readShellConfig is the precedent; the unguarded-reader pin is present.
  • Landing order. fix(console): the sign-in page finishes the mobile sign-in hand-off instead of landing on a missing Console page #29979 (f35b361) and fix(cli): os serve reads OS_TRUSTED_APP_SCHEMES into AuthPlugin's trustedAppSchemes #30004 (d9895a1) on main. Nothing left to land first.
  • Verification ran UNLOCKED on macOS. A declared narrowing of the dev's local runs; this head's check-runs are the gate verdicts.

④ Design judgment

packages/spec/src/api/auth-endpoints.zod.ts (hits) — against the maintainer's order on #29997 and the route as its first consumer. Every sentence re-read against runOneTimeTokenHandoff and resolveHandoffSignInEntry at this head: "every 400 decided before any session is read" holds; the 302-to-sign-in conditions are exactly refusedSessionAnswer's three gates plus the entry's served test, now stated as the code tests it; Location root-relative with redirect and handoff_refused_at holds; the 401 list is complete, including the fronted-Console case. The docblock says what the code does, no more and no less. Holds.

packages/plugins/plugin-auth/src/one-time-token-handoff.ts (widened) — the delta, tried to break it:

  • The // guard. It reads the parsed pathname of two config URLs after URL normalisation, so the shapes that could slip a second host past a string test are already flattened: a backslash in a special-scheme path becomes / before the test; %2F%2F stays percent-encoded and is a same-origin path, not protocol-relative; a ? or # never reaches pathname. Only a leading // names another host in a Location, and only that is refused. The guard sits after deploymentBase/localPath are computed and before the probe, so a refused path costs no in-process fetch (pinned: fetch not called) and the warn names the offending path and both config keys. handoff is now built once and the same value is tested and returned. Nothing request-controlled reaches either input. Holds.
  • Everything else (token elsewhere, off-origin Location, forged mark or redirect, holding a person out, a malicious app or page, the loop, a host where the Console or the headers differ) is as judged on 584f1e4: unchanged code, same pins, holds.

packages/plugins/plugin-auth/src/auth-plugin.ts and auth-route-ledger.ts (widened). Unchanged by the delta; as before, wiring and note only. Holds.

ui/apps/console/src/pages/auth/handoffRedirect.ts (widened) — handoffMarkQuery, tried to break it:

  • Can the mark reach a link that is not a hand-off redirect? No: the first gate is handoffNavigationTarget(redirect) === null ⇒ '', the same exact-path, same-origin, no-// match the forward itself uses, so a lookalike path (HANDOFF_PATH + 'X'), another route (/settings, /api/v1/data/x), an absolute URL, an empty or null redirect all yield the empty suffix — pinned, and pinned again on the real pages (CONTROL: beside /settings the links are what they were, mark or no mark).
  • Can it inject anything into those URLs? No: the suffix is built from handoffRefusedAt(search), which returns a safe integer or null after a ^\d+$ test, so the only text that can follow &handoff_refused_at= is digits; redirect beside it is encodeURIComponent-ed as before. A mark of soon, 1e3, -1, 9 × 400 or a percent-encoded value yields no suffix (pinned). The & is correct in all three callers because each suffix is only ever appended after a ?redirect= segment that is present whenever the suffix is non-empty.
  • Does anything change for a page with no hand-off? No: with no redirect the links are the bare /register and /login; with a non-hand-off redirect they are byte-identical to main; with a hand-off redirect and no mark on the page they are byte-identical to main — all three pinned. handoffMarkQuery reads window.location.search at call time, which is the page's own query in both the render-time builders and the closed-sign-up effect; its default guards typeof window === 'undefined'.
  • Does the carried mark change the receiving page's verdict correctly? Yes: RegisterPage reads it through the same hook, and its new pins show the form held for a session not created after the mark, a sign-up on the form forwarding, and a session created after the mark forwarding on a device whose clock is ahead. Holds.

LoginPage.tsx / RegisterPage.tsx at this head (widened). The hand-off step itself (before the organization steps, by window.location.assign, guarded by the started-ref, form held for holdForSignIn) is unchanged from main and holds as judged before. The three link changes are the delta above. registerUrl is also handed to InvitationSignUpPrompt, and loginUrl to the register form's Sign in link; both carry the suffix only beside a hand-off redirect, which an invitation redirect never is. Holds.

The road the mark does not reach: /verify-email-prompt. Three hops drop it: LoginPage's onError for EMAIL_NOT_VERIFIED builds a fresh URLSearchParams of email and redirect (LoginPage.tsx:568–571), RegisterPage's onVerificationRequired does the same (RegisterPage.tsx:349–352), and VerifyEmailPromptPage.handleBackToLogin rebuilds /login?redirect= alone (main, :101–104). Consequence, read from the code: a person who arrived with a mark, tried an unverified account (the server refuses before any session is made, so the browser's session is whatever it held on arrival), and pressed Back to login is judged by the device-clock age check alone; with an old session that the device's clock reads as young, the page forwards into the route's same-origin 401 JSON. It stops and cannot loop (the route never redirects a same-origin navigation; without Fetch Metadata it redirects once with a newer mark and the form holds). It needs four conditions at once (an old session in the system browser, which iOS's ephemeral session never has; a device clock behind by about the session's age less five minutes; an unverified account; the Back to login button), and its worst outcome is the pre-#29997 JSON answer. Verdict: should-fix of the lowest order, not must-fix — the same handoffMarkQuery(redirect) suffix at the two navigate calls and in handleBackToLogin (three lines), which the maintainer can fold into this PR or leave for a card; nothing in this PR depends on it, and it does not count against this verdict. Observed beside it on merged code (#29979's scope, not this PR's), for a card at the maintainer's discretion: RegisterPage.tsx:265–268 and VerifyEmailPromptPage.tsx:64–67 hand the verification mail a callbackURL of withConsoleBaseRootRelative(redirect), which for a hand-off redirect is /_console/api/v1/auth/one-time-token/handoff?… — the #29971 shape, so a verification link lands on 页面不存在; which browser context that mail opens in is a product question objectstack-ai/mobile#190 would have to settle before the right callback is known.

Must-fix: none. Should-fix: the /verify-email-prompt hops (lowest order, three lines). All five should-fix findings of the previous record landed as described and are closed.

Implemented-by: claude/issue-29997-handoff-signin-entry
Reviewed-by: session_c967d7a472b0485c94711bfcb22159c8

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · hotlong · session_c967d7a472b0485c94711bfcb22159c8

维护者速读(席位终稿)

  • 改了什么:手机 App 登录只需打开交接接口一个地址。没登录或会话超过 5 分钟的用户在系统浏览器里打开它,服务端跳到本部署的登录页,登录后回到交接接口,再把一次性令牌交回 App。App 的探测请求仍然拿到 401。登录页也一起改了:服务端跳转时写明拒绝时间,登录页按它判断,不看手机时钟,这样不会循环跳转。
  • 为什么改:你在 🔗 Broken links detected in documentation #148 裁定“改成服务端入口”。Console 升级不该让已安装的 App 卡在回不来的页面上;Google 这类拒绝内嵌 WebView 的登录都走这条路。
  • 风险与回滚:这是会话转移流程。令牌仍只发给 5 分钟内登录的会话,跨站打开和非法参数仍先回 400,发令牌的逻辑没动。还没有已发布的 App 用这条流程,所以没有受影响的旧版。回滚即还原本 PR。
  • 先看:packages/plugins/plugin-auth/src/one-time-token-handoff.ts 的 refusedSessionAnswer 和 resolveHandoffSignInEntry;ui/apps/console/src/pages/auth/handoffRedirect.ts 的 isHandoffSessionFresh。
  • 两分钟验证:pnpm --filter '@objectstack/plugin-auth^...' build && pnpm --filter @objectstack/plugin-auth exec vitest run src/one-time-token-handoff.test.ts,预期 88 条通过。
  • 需要你做的:审阅并批准本 PR:契约复审 PASS 评论 ,Head-sha: 3e59db0af0da68805e35387a3ee09e806d0cdd2a。批准后由席位转 ready 并入队。另有两件事请你一并确认,见“席位意见”。
  • 席位意见:ACCEPT。复审没有必须修的问题,四个建议修的点都已落地。真实的系统认证页、真实 Google 和真机都还没测,这些要等发版后才能测。请确认:① 用拒绝时间代替 UI 席约定的标记;② 浏览器测试只在每晚跑,Console 改坏这条流程时第二天才发现。两项我都建议接受,不必为此新建流水线。

席位核对 · ACCEPT · head 3e59db0af0da68805e35387a3ee09e806d0cdd2a

  1. Shape: draft, base main; the signature line, then Fixes #29997, the only closing keyword in the body. Changeset names @objectstack/plugin-auth, @objectstack/spec, @object-ui/console at patch, with Clause-②: no and Metadata-protocol: none. 14 files, 1220 changed lines, under the 3000 threshold. check-governed-merges --pr 30010: read; no governed path among them. No content/docs/releases/ change.
  2. Premise measured: route read on origin/main before dispatch. The dev measured the redirect and the loop in a browser. Seat's spot checks on the tree: one-time-token-handoff is absent from the package entry; a // pathname resolves to another host (https://_console/login, run in node), which is what the guard refuses; the sign-up links and withConsoleBaseRootRelative read as the review says.
  3. Gates, pinned to this head: Lint & Repo Gates, TypeScript Type Check and Test UI all success at 3e59db0af. The rollup shows no failure. Check Changeset and PR-body guards were re-running after a body edit when this was written; both were green earlier on this head. All must be green again before queueing. Local ui scripts/ failures (5 of 4987) were macOS only: Test UI is the authority and is green. The seat ran no build or test itself.
  4. Accepted deviations: browser test in examples/app-showcase/e2e, nightly rather than per PR; the same-origin 401 carve-out; a time rather than the bare mark fix(console): the sign-in page finishes the mobile sign-in hand-off instead of landing on a missing Console page #29979's thread agreed; a Console served in front of this process keeps the 401. Page half carried here, as the fix(console): the sign-in page finishes the mobile sign-in hand-off instead of landing on a missing Console page #29979 seat agreed. Contract review: , on this head.
  5. Out of scope: /verify-email-prompt dropping the mark, and the verification mail's link landing on 页面不存在: filed Mobile sign-in: a person who has to verify their email during the hand-off gets a mail link to 页面不存在, and the verify page drops the refusal time #30157 (the review found the second on merged code). Hosted environments: objectstack-ai/cloud#3549, already filed. principal-session-console-boot.dogfood.test.ts skipping in CI: Acceptance notes; not a product defect, so outside the filing gate. Skills-docs: not owed, since Metadata-protocol: none. PR body wording "a person ... in acceptance notes" for the verify road is superseded by Mobile sign-in: a person who has to verify their email during the hand-off gets a mail link to 页面不存在, and the verify page drops the refusal time #30157.
  6. Landing: session-transfer flow, so it waits for a human approval (GOVERNED_APPROVERS: hotlong, os-zhuang), though branch protection would not stop it. After approval the seat re-reads this head and the checks, flips ready and queues. Fixes closes the card and leaves pm:dispatched, which the seat removes in the landing stroke. feat(shell): sign in through the system auth session when a provider refuses the WebView mobile#190 changes its entry only after a release carries this.

Note on #29979: its reviewed code merged with no human approval. The contract review above read it under the maintainer's widening and found no must-fix.

Posted 2026-11-04T17:31Z.

@objectstack-fleet
objectstack-fleet Bot requested a review from hotlong November 4, 2026 17:31
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · hotlong · session_c967d7a472b0485c94711bfcb22159c8

Landing authority for head 3e59db0af0da68805e35387a3ee09e806d0cdd2a. Source: the maintainer hotlong, in session session_c967d7a472b0485c94711bfcb22159c8, in answer to the seat's request for approval. Words: "已批准,去落地".

This is a chat approval, not a GitHub review. GET /pulls/30010/reviews read just before this comment, as hotlong, returns none, and hotlong is still a requested reviewer. This PR touches no governed path, and branch protection requires no review, so the seat lands on the maintainer's words and records them here. The two points in the seat's brief (a time rather than a bare mark; a nightly browser test) are read as accepted with the approval, since this head embodies both.

Read at landing: mergeStateStatus: CLEAN, no check failing on its latest run. Check Changeset's one cancelled run (41876936392) was superseded by run 41877075465, success, on this same head. Contract review PASS 6637444102 names this head.

Posted 2026-11-05T00:33Z.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 5, 2026 00:35
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 5, 2026 00:35
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 5, 2026
Merged via the queue into main with commit cde492d Nov 5, 2026
72 of 73 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-29997-handoff-signin-entry branch November 5, 2026 01:05
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/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Mobile hand-off: a signed-out page navigation goes to the sign-in page, so the app never has to name the Console's login route

1 participant