Repository navigation
feat(plugin-auth): a page navigation to the mobile hand-off that has to sign in first goes to the sign-in page - #30010
Conversation
…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 <[email protected]>
…st the real Console Claude-Session: https://claude.ai/code/session_c967d7a472b0485c94711bfcb22159c8 Co-authored-by: Claude <[email protected]>
…ead of failing the hand-off Claude-Session: https://claude.ai/code/session_c967d7a472b0485c94711bfcb22159c8 Co-authored-by: Claude <[email protected]>
…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 <[email protected]>
… which the mount census can read Claude-Session: https://claude.ai/code/session_c967d7a472b0485c94711bfcb22159c8 Co-authored-by: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_c967d7a472b0485c94711bfcb22159c8 Co-authored-by: Claude <[email protected]>
📓 Docs Drift CheckThis PR changes 2 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 175 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
|
domain:server#2 · hotlong · session_c967d7a472b0485c94711bfcb22159c8 席位核对 · not yet ACCEPT · head
|
|
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 Next: when CI is green the seat queues the landing ( |
Claude-Session: https://claude.ai/code/session_c967d7a472b0485c94711bfcb22159c8 Co-authored-by: Claude <[email protected]>
|
domain:server#2 · hotlong · session_c967d7a472b0485c94711bfcb22159c8 Contract review scope widened for this PR, by the maintainer. Source: the maintainer Why: #29979 is the page half of this flow. It merged at So the review at this PR's final head also reads, as merged on The 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 <[email protected]>
|
domain:server#2 · hotlong · session_c967d7a472b0485c94711bfcb22159c8 Contract-review-claim: session_c967d7a472b0485c94711bfcb22159c8 · head 584f1e4 · 2026-11-04T15:36Z |
|
domain:server#2 · hotlong · session_c967d7a472b0485c94711bfcb22159c8 Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgment
Must-fix: none. Should-fix: the spec docblock's two imprecisions; a Implemented-by: 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 <[email protected]>
|
domain:server#2 · hotlong · session_c967d7a472b0485c94711bfcb22159c8 Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgment
The road the mark does not reach: Must-fix: none. Should-fix: the Implemented-by: VERDICT: PASS |
|
domain:server#2 · hotlong · session_c967d7a472b0485c94711bfcb22159c8 维护者速读(席位终稿)
席位核对 · ACCEPT · head
|
|
domain:server#2 · hotlong · session_c967d7a472b0485c94711bfcb22159c8 Landing authority for head This is a chat approval, not a GitHub review. Read at landing: Posted 2026-11-05T00:33Z. |
domain:server#2 · hotlong · session_c967d7a472b0485c94711bfcb22159c8
Fixes #29997
Clause-②: no
Metadata-protocol: none
维护者速读
redirect,Console 一升级改了其中一个,这些用户就卡在没有退路的页面上,App 也察觉不到。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 条全部通过。What changes
GET /api/v1/auth/one-time-token/handoff, for a valid request whose session is missing or older than five minutes:302to this deployment's Console sign-in page, withredirectnaming the same request andhandoff_refused_atcarrying the server's time of the refusal401 UNAUTHENTICATED, as beforeSec-Fetch-Site: same-origin, which is the sign-in page forwarding401, never another redirect200 text/html, or when the path would start with//401, logged once atwarnNothing else on the route moves.
Sec-Fetch-Site: cross-siteis still judged first, then the closed query set, the registered scheme and the challenge; an invalid request gets400and 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 hashandoff_refused_at, a session with acreatedAtis 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 nocreatedAtkeeps its existing branch. The links between/loginand/registercarry the mark beside a hand-offredirect, 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-Destdecides when a browser sends it:documentis a navigation, andempty(afetch) oriframeis not, whateverAcceptsays. With no Fetch Metadata,Acceptnamingtext/htmlcounts. A request with neither gets the401, 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
ASWebAuthenticationSessionor 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 HTMLAccept. 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.getConsoleUrlcomposes${canonical origin}${uiBasePath}${path}(confirmed in source), so using it asLocationwould move a browser that reached the deployment on another host.Locationis root-relative and takes only the path of that URL, which keepsuiBasePathand any base path inbaseUrlhonoured 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. AbaseUrlending in//or auiBasePathstarting with//would compose a path starting with//, which as a root-relativeLocationnames another host. Config alone reaches that and no token rides that redirect, but the route treats it as no sign-in page: the401, onewarn, and the server is not asked.redirect(constraint 4) is rebuilt from the validated parameters only:redirect_uri,code_challenge, andcode_challenge_methodif the request had it. The app'sredirect_uristays nested insideredirect, becauseredirect_uriat the top of the page's query, withclient_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
200with an HTML document.readShellConfigandanonymousFormDoorServesinpackages/cli/src/utils/console.tsalready ask the composed app the same way. A route table would not do:os servemounts a503responder on the same paths when the Console was not built. So these all keep the401:--no-ui, a Console not built, a host that redirects/_consoleto 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.
resolveHandoffSignInEntrycallsserver.fetchwithenvand no execution context, and I left it that way because it cannot matter on any host read:c.executionCtxis read nowhere in this repo, nor inobjectstack-ai/cloudatf874972e.middleware/cacheandmiddleware/method-overrideread it unguarded, and neither is imported here..mount()reads it inside atry.plugin-hono-serverand cloud'snode-server.tsserve through@hono/node-server, which callsfetch(req, { incoming, outgoing }), so the probe matches the request it came from. Cloud's Workers front a Node container and usectx.waitUntiloutside Hono. Cloud's Vercel entry callsapp.fetch(request)alone.readShellConfigalready dispatches the same way, on every Console document. If a middleware ever did read it unguarded, the probe answers500, the route keeps the401and logsit answers 500once. 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.302(none)302to the app302302to the app401(same-origin)302For scale, the loop itself, measured earlier at
fe2b8b4907with #29979 merged in, the same-origin stop removed and no page half: 228 hand-off requests in 20 s, all302.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.
SocialSignInButtonsdefaultscallbackURLtowindow.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'screatedAtis 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
createdAtare 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 latercreatedAt, which clears it. The docblocks say so.The mark across the sign-in pages.
registerUrlon/login, and on/registerbothloginUrland the bounce a closed sign-up makes, now carry the mark beside a hand-offredirect(handoffMarkQuery). Beside any otherredirect, or none, those links are byte-identical, and a test pins each.Reading of
RegisterPagewith the mark, from the code and its pins.useHandoffRedirectreads the mark from the URL when it judges a session, so/registerbehaves as/logindoes. 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 nocreatedAt, 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:
/loginheld, "Sign up" to/registerwith the mark kept and the form held, "Sign in" back to/loginwith 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 a302to the app's scheme reportsnet::ERR_ABORTED. A control with interception and no round trip reads the same. The/registerto/logindirection, run with no interception, read that second request as the302.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 realAuthManager, the real better-auth plugin and the real route registration. Page navigation with no session and with an old session gets the302; JSON, no headers,*/*, afetchasking for HTML and an iframe get401; an unregistered scheme, a bad challenge, a plain method and an unknown parameter get400with no redirect; cross-site gets400first; theredirectit names signs in, comes back and redeems. A path starting with//gets the401and onewarnand asks the server nothing.resolveHandoffSignInEntryandisPageNavigationhave their own tables.handoffRedirect-29971.test.tsandsignInHandoff-29971.test.tsx(the existing harness files for this component, so the new pins are top-leveldescribes there): a session created afterhandoff_refused_atis 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 nocreatedAtkeeps its branch; on the realLoginPageandRegisterPage, 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-offredirectand stays byte-identical beside any otherredirect, with no mark on the page, and with noredirect.401pins on the route send no navigation headers, so they stay and their titles now say so. No other pin of this route's401exists 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 the302to 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
ui/e2e:Live E2E UIserves the Console fromvite previewat 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/e2eis 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.composeStacks(a spread loses thedefineStackprovenance, anddefineStackon a built stack reports 71 cross-reference issues), and every smoke spec would then boot a composed stack.OS_TRUSTED_APP_SCHEMESon thewebServeris one line and the path a deployment uses.examples/app-showcase/playwright.config.ts(that one line), the doc comment ofexamples/app-showcase/test/showcase-smoke.live.test.ts, the page half, andLoginPage.tsxandRegisterPage.tsxonly for the mark on the links between them, per the claim updates on the card./verify-email-promptis given onlyredirectby both pages, andVerifyEmailPromptPage'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's401with 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.tsxis outside this card's surface.packages/qa/dogfood/test/principal-session-console-boot.dogfood.test.tsskips 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 on584f1e43fc, which the contract review read.mainwas not merged again: no conflict and nothing red forced it, so the merge withorigin/mainstaysf68b47cace.pnpm --filter @objectstack/plugin-auth exec vitest runpnpm --dir ui exec vitest run apps/console/src/pages/auth/__tests__/pnpm --filter @objectstack/plugin-auth run typecheck,tsc --noEmiton@objectstack/specpnpm --filter @object-ui/console run type-check--no-inline-configon the server-side files,pnpm --dir ui exec eslinton the page filesreact-hooks/set-state-in-effectthese pages and this hook already carrieddispatch-gates --commands(12 paths, 88 commands)pnpm --dir ui exec vitest run scripts/, read belowdispatch-gates --ran88 derived famil(ies) accounted for — 88 run, 0 NOT-MEASUREDcheck:meta-url-spelling,check:authz-resolver,check:changeset-fixed,check:error-code-casing,check:filter-alias-parity)pnpm --filter @objectstack/spec check:generatedpnpm console:buildat this headThe 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 inselect-affected-tests-wiring.test.tsand stop atmapfile: command not found: macOS ships bash 3.2. CI's run on Linux is the authority for this family:Test UIconcludedSUCCESSon 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)
ablation-dist-preflightconfirmed absent fromdist/#login-email, on the JSON pagehandoffNavigationTargetreturns null), Console rebuilt302to the app, which is what a Console upgrade could doablation-dist-preflightconfirmed indist/Taken at
584f1e43fc. After restoring and rebuilding, the spec read 2 passed.Unit ablations, through scripts/ablation-replace.mjs (each restored; blob equals HEAD)
refusedAtbranch deletedLoginPagehandoffMarkQuerynot limited to a hand-offredirect/login: "Sign up" without the mark/register: "Sign in" without the mark/register: closed sign-up bounce without the markisPageNavigationguard deleted//guard off//guard on the sign-in page only, not the hand-offtrue302replaced by the refusalLocationgiven the full URL instead of the pathhandoff_refused_atdeletedThe
//guard and link rows are at3e59db0af0, the others at584f1e43fc.Declared narrowing — verification ran UNLOCKED.
scripts/pm/os-verify-lock.shcould not take the shared verify lock on this host: no usableflock. The shared verify lock is declared Linux-only (flockis 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
packages/specholds no runtime logic. (Docblock only.)pnpm lintandLint UIare the authority.)main, one pull request.packages/spectouched: no generated artifact moved.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.Body written 2026-11-04T17:22Z.
Generated by Claude Code