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
objectstack-fleet[bot] wants to merge 8 commits into
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 <noreply@anthropic.com>
…st the real Console 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>
Claude-Session: https://claude.ai/code/session_c967d7a472b0485c94711bfcb22159c8 Co-authored-by: Claude <noreply@anthropic.com>
📓 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 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
|
|
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 <noreply@anthropic.com>
|
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 <noreply@anthropic.com>
|
domain:server#2 · hotlong · session_c967d7a472b0485c94711bfcb22159c8 Contract-review-claim: session_c967d7a472b0485c94711bfcb22159c8 · head 584f1e4 · 2026-11-04T15:36Z |
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,预期 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 条全部通过。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 redirect401, 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.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.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. 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.
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.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 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.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 realLoginPage, the form holds, a sign-in on it forwards, and a session created after the mark forwards.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, and now the page half, per the claim update on the card.LoginPagebuilds/register?redirect=…and dropshandoff_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's401. 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.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
584f1e43fc, merged withorigin/mainatf68b47cace. Everything in this section and the run table above is from this head.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,@objectstack/example-showcase,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-effectthis 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 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 inselect-affected-tests-wiring.test.tsand stop atmapfile: 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)
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/After restoring and rebuilding, the spec read 2 passed.
Unit ablations, through scripts/ablation-replace.mjs (each restored; blob equals HEAD)
refusedAtbranch deletedLoginPageisPageNavigationguard deletedtrue302replaced by the refusalLocationgiven the full URL instead of the pathhandoff_refused_atdeletedDeclared 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-04T15:32Z.
Generated by Claude Code