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

Draft
objectstack-fleet[bot] wants to merge 8 commits into
mainfrom
claude/issue-29997-handoff-signin-entry
Draft

objectstack-fleet[bot] wants to merge 8 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,预期 84 条全部通过;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,预期 56 条全部通过。
  • 需要你做的:这是会话转移(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 on a deployment that does not serve the sign-in page 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.

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.

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. All on this head, in headless Chromium. 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.

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: 84 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. 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, the form holds, a sign-in on it forwards, and a session created after the mark forwards.
  • 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, and now the page half, per the claim update on the card.
  • Sign-up link. LoginPage builds /register?redirect=… and drops handoff_refused_at, so a person holding an old session on a device whose clock is behind who follows "Sign up" is judged by the age check there, and a forward ends at the route's 401. Read from the code, not run. It stops rather than loops. Left alone: it is outside this card's surface and needs all three conditions at once.
  • 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 584f1e43fc, merged with origin/main at f68b47cace. Everything in this section and the run table above is from this head.

Check Result
pnpm --filter @objectstack/plugin-auth exec vitest run 220 files; 3860 passed, 10 skipped
pnpm --dir ui exec vitest run apps/console/src/pages/auth/__tests__/ 27 files, 205 passed
pnpm --filter @objectstack/plugin-auth run typecheck, @objectstack/example-showcase, 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 1 warning, the react-hooks/set-state-in-effect 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 11 failed of 4987 tests on this Mac, none in a file this PR touches. Nine were whole-tree scans hitting the 15 s test timeout, and those eight files pass alone with a longer timeout. The other two are in select-affected-tests-wiring.test.ts and stop at mapfile: command not found: macOS ships bash 3.2. Both are about this machine, so CI's run on Linux is the reading to trust for this family.

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.

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
Route: isPageNavigation guard deleted 5
Route: same-origin stop deleted 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

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-04T15:32Z.


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 5d7ada3e92784254fd717785f1e17039c1042cb2 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 5d7ada3e92784254fd717785f1e17039c1042cb2

⚠️ 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 5d7ada3e92784254fd717785f1e17039c1042cb2 → 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

This branch has not been deployed

No deployments
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