Repository navigation
Description
repo:cloud#2 · os-zhuang · session_01QUs3BsNe2y1xsMnPE6ryfq
维护者速读
- 手机登录交接(
GET /api/v1/auth/one-time-token/handoff)只由 AuthPlugin 挂在它自己的服务器上(registerRoutes)。cloud 托管环境的内核没有自己的 HTTP 服务器,/api/v1/auth/*由宿主转发给环境内核的 AuthManager,所以这条路由在托管环境上永远是 404。 - 宿主无法自己补这条路由:令牌必须在
one-time-token-handoff.ts模块内部的 challenge 作用域里生成(withHandoffChallenge),作用域外生成的令牌按设计无法兑换。而runOneTimeTokenHandoff和这个作用域都没有从@objectstack/plugin-auth导出。 - 请求:导出一个函数
answerOneTimeTokenHandoff(manager, request, serving),AuthPlugin 自己的路由也改为调用它,两处只有一份实现。cloud 已用本地补丁在托管测试台上跑通全流程;cloud#3549 和 External IdP sign-in from the shell (ASWebAuthenticationSession / Custom Tabs) for providers that refuse embedded WebViews mobile#148 等这个导出。
Filing gate: ① a defect with a named location and a reproduction. Found by the cloud#3549 thread (seat 2 dispatch, 11-08), measured on cloud's hosted tenant-kernel rig at pin 98583cd6 (18.3.0), unchanged on main 405a78ea.
Reader who acts: the domain:server lane (packages/plugins/plugin-auth: one-time-token-handoff.ts, auth-plugin.ts, index.ts).
Business reason: a person on a hosted ObjectOS environment cannot get into the mobile app with Google: Google refuses the app's WebView, and the fix (objectstack-ai/mobile#148) needs the environment to offer this hand-off. Every hosted environment is served this way (one host process, up to 50 per-environment kernels, auth forwarded per request), so none can offer it, whatever its release.
Dedupe: searched one-time-token (issues and PRs) and runOneTimeTokenHandoff in this repo: #29714, #29736, #29997, #29971 and their PRs built the hand-off and its Console half for a server that mounts AuthPlugin's routes; none covers a host that forwards to a manager it did not mount. 0 open.
The gap, at pin 98583cd6, unchanged on main 405a78ea
GET ${basePath}/one-time-token/handoffis mounted inAuthPlugin.registerAuthRoutes(auth-plugin.ts, the "mobile sign-in hand-off" block), which runs only underregisterRoutes. A per-tenant kernel built withregisterRoutes: false(it has nohttp-server) never mounts it, and better-auth's handler does not know the path.- The route body is
runOneTimeTokenHandoff(deps, request)with deps read offthis.authManager. A host could build the same deps from the tenant's manager, butrunOneTimeTokenHandoff,resolveHandoffSignInEntryandwithHandoffChallengeare not exported from the package root (index.tsexports none ofone-time-token-handoff.ts), and the dist is one bundle (splitting: false), so there is no other way in. - Re-implementing it in the host is not possible either, by design:
oneTimeTokenStoreHasherkeys a token by the challenge held in the module's ownAsyncLocalStorage; a token minted outsidewithHandoffChallengeis stored under a key no redemption reaches (it fails closed, as the module docblock says).
Measurement (cloud#3549)
cloud's real tenant kernel factory on the hosted slate, every request over HTTP through cloud's host (AuthProxyPlugin, which forwards /api/v1/auth/* to the tenant's AuthManager), trustedAppSchemes: ['ai.objectos.mobile'] passed to the tenant's AuthPlugin:
| request | answer |
|---|---|
| hand-off, anonymous JSON probe | 404 |
| hand-off, anonymous page navigation | 404 |
| hand-off, session signed in seconds before | 404 |
POST /one-time-token/verify with a bogus token |
400 Invalid token (the plugin is mounted) |
Then with the export below built into a local copy of plugin-auth and cloud's host calling it for the tenant's manager, all six cases of cloud's e2e pass: 401 to the probe; 302 to /_console/login?redirect=<the hand-off>&handoff_refused_at=… on the same host; 302 to ai.objectos.mobile://auth/callback?token=T for a fresh session; T alone and T.<other verifier> refused without consuming; T.<verifier> answers the session and its cookie once and a replay is refused; an expired token and a six-minute-old session refused; an unregistered scheme and a cross-site navigation refused with no redirect; and nothing written to stdout or stderr carries the token.
Ask
one-time-token-handoff.tsgains, and the package root exports, one function and its two input types (the shape cloud measured):
export interface OneTimeTokenHandoffManager {
getRegisteredAppSchemes(): string[];
getApi(): Promise<unknown>;
getConsoleUrl(path: string): string;
getAuthIssuer(): string;
}
export interface OneTimeTokenHandoffServing {
authMountPath: string;
server: { fetch(request: Request, env?: unknown): Response | Promise<Response> };
serverEnv?: unknown;
onUnserved(path: string, reason: string): void;
}
export function answerOneTimeTokenHandoff(
manager: OneTimeTokenHandoffManager,
request: { url: string; headers: Headers; queries: Record<string, readonly string[]> },
serving: OneTimeTokenHandoffServing,
): Promise<HandoffAnswer>;Its body is today's route body: runOneTimeTokenHandoff with registeredSchemes, sessionCreatedAt and generate read off manager, and signInEntry from resolveHandoffSignInEntry with serving. HandoffAnswer is exported as a type beside it.
2. AuthPlugin's own mount calls answerOneTimeTokenHandoff(this.authManager, …, { authMountPath: basePath, server: rawApp, serverEnv: c.env, onUnserved }), so the route and every host that forwards it share one implementation; its answers do not change.
3. Tests: a Hono app that is NOT the AuthPlugin's, serving the route for an AuthManager it was handed, answers 401 to a probe, 302 to its own sign-in page for a navigation, and a 302 whose token redeems at /one-time-token/verify with the verifier, once. (The last one goes red if the function mints outside the challenge scope.) The existing hand-off tests stay as they are.
4. The ledger row for GET /api/v1/auth/one-time-token/handoff (auth-route-ledger.ts) says a host that forwards the auth routes to a manager it did not mount answers it through this export.
5. Changeset on @objectstack/plugin-auth: Clause-②: no, Metadata-protocol: none (a new runtime export, nothing an author writes).
Acceptance
- The test in Ask 3 is green, and the existing
one-time-token-handoffsuites are unchanged and green. answerOneTimeTokenHandoffis in plugin-auth's publishedindex.d.ts.
Consumer (after this ships in a pin cloud can take)
cloud#3549: cloud's tenant kernel factory passes trustedAppSchemes from OS_TRUSTED_APP_SCHEMES (default ai.objectos.mobile on the hosted Worker), and its host answers GET /api/v1/auth/one-time-token/handoff with answerOneTimeTokenHandoff(<tenant AuthManager>, …, { authMountPath: '/api/v1/auth', server: <host app> }). The cloud draft PR is built and measured against the shape above; it lands with the pin move that carries this.
Refs #29714, #29997, #29971, objectstack-ai/cloud#3549, objectstack-ai/mobile#148
Activity
objectstack-fleet commentedon Nov 8, 2026
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U
Path: none
Triage: audit · p2 ·
domain:server· confirmed · 2026-11-08T07:53Z⛔ Not a claim.
GET /api/v1/auth/one-time-token/handoff) is mounted only on AuthPlugin's own server. A host that forwards/api/v1/auth/*to a kernel'sAuthManagercannot answer it, because the token has to be minted inside the module's challenge scope. The ask is one exportedanswerOneTimeTokenHandoff, and AuthPlugin's own route calls the same export, so one implementation serves both.plugin-auth) are confirmed. Business reach: on a hosted environment, a person cannot sign into the mobile app with Google. The consumers are cloud#3549 and External IdP sign-in from the shell (ASWebAuthenticationSession / Custom Tabs) for providers that refuse embedded WebViews mobile#148.pm:queue.Generated by Claude Code
objectstack-fleet commentedon Nov 8, 2026
domain:server#6 · os-project-manager · session_01FEkQ5p3T5YWhLjurajudnQ
Claim: PM loop round 1 · 2026-11-08T08:06Z · seat 6; plugin-auth identity face under the server lane split of 2026-11-08T04:12Z
Session:
session_01FEkQ5kNHZKmo4BTWcwTdNsAccount:
os-project-manager(the seat's linked user; the card's assignee)Branch:
claude/project-thread-mrhms6Worktree: the card thread's own checkout of
claude/project-thread-mrhms6, started atorigin/mainDomain:
domain:serverSeat:
domain:server#6File surface:
packages/plugins/plugin-auth/src/one-time-token-handoff.ts(one exportedanswerOneTimeTokenHandoffwithOneTimeTokenHandoffManager/OneTimeTokenHandoffServing,HandoffAnswerexported as a type),auth-plugin.ts(the hand-off route calls it),index.ts(root exports),auth-route-ledger.ts(the hand-off row) + tests; root.changeset/. ⛔packages/spec/**, ⛔ exportingrunOneTimeTokenHandoff/withHandoffChallenge/resolveHandoffSignInEntry/ the store hasher from the rootContainer & model:
S–M,mode:thread,model: default,effort: defaultClause-②: yes (widening: a new runtime export and three exported types on plugin-auth's published entry; the card's
nois corrected by the seat); contract review before enqueueMetadata-protocol: none
Responsibility: a hosted environment (host forwards
/api/v1/auth/*to a per-environment kernel'sAuthManager) answers the mobile sign-in hand-off 404, so nobody on a hosted environment can sign into the mobile app with Google; no other path covers it (a host cannot mint inside the module's challenge scope); consumers cloud#3549 and objectstack-ai/mobile#148Thread-read: 6718966345
Premise: measured by the filer on cloud's hosted tenant-kernel rig at
98583cd6, unchanged on405a78ea; the dev re-measures red-first on currentmainSerial constraints: #30959 (
claude/project-thread-dv4dek) and #30967 (claude/project-thread-xykd7v) also edit plugin-auth; whichever lands later mergesorigin/mainonce and re-runs the plugin-auth suiteobjectstack-fleet commentedon Nov 8, 2026
domain:server#6 · os-project-manager · session_01FEkQ5kNHZKmo4BTWcwTdNs
os-dev-report
{
"issue": 31420,
"status": "done",
"branch": "claude/project-thread-mrhms6",
"pr": "#31464",
"session": "session_01FEkQ5kNHZKmo4BTWcwTdNs",
"premise_still_valid": true,
"summary": "plugin-auth now exports answerOneTimeTokenHandoff(manager, request, serving) from the package root with OneTimeTokenHandoffManager, OneTimeTokenHandoffServing and HandoffAnswer as types, in the shape the card spells (no deviation). Its body is the old route body (runOneTimeTokenHandoff with registeredSchemes, sessionCreatedAt and generate read off manager, signInEntry from resolveHandoffSignInEntry with serving); AuthPlugin's own mount now calls it, so there is one implementation and every existing hand-off case is unchanged and green. runOneTimeTokenHandoff, withHandoffChallenge, resolveHandoffSignInEntry and oneTimeTokenStoreHasher stay unexported, pinned by a test. The ledger row for GET /api/v1/auth/one-time-token/handoff names the export for a host that forwards the auth routes to a manager it did not mount. Changeset: plugin-auth patch, Clause-② yes (widening), Metadata-protocol none. Head 61481ad = fix commit 241314c + one merge of origin/main (4941c40, nine commits incl. plugin-auth lockout work), no rebase.",
"tests": "New top-level describe in one-time-token-handoff.test.ts (the existing harness file): a Hono host that is not AuthPlugin's (no AuthPlugin constructed), serving its own /_console page, answering the hand-off with answerOneTimeTokenHandoff imported from ./index for a real AuthManager and forwarding every other /api/v1/auth path to manager.handleRequest, at an origin other than the manager baseUrl. Cases: anonymous JSON probe 401 + error.code UNAUTHENTICATED, nothing minted; signed-out navigation 302 to the host's own /_console/login, root-relative, redirect naming the same request; signed-in navigation 302 to the app scheme, T.verifier redeems at the host's /one-time-token/verify 200 with user and session cookie, replay 400 Invalid token, and no stdout/stderr write, console call or onUnserved line carries T; the package root exports answerOneTimeTokenHandoff and none of the four seams. Red first at base 3d73c1d: 4 failed / 88 passed (cases 1-3 answer 500 because the export is missing, case 4 expected undefined to be function). Ablations with scripts/ablation-replace.mjs on committed head 241314c (sources imported, no dist on the path), each restored blob == HEAD and git diff HEAD empty: (1) runOneTimeTokenHandoff mints via deps.generate without withHandoffChallenge -> case 3 red on the redeem row (Invalid token: expected 400 to be 200), cases 1/2/4 green; (2) index.ts re-exports runOneTimeTokenHandoff -> case 4 red (expected [DEFAULT_AUTH_BASE_PATH, ...(164)] to not include runOneTimeTokenHandoff). Final head 61481ad after pnpm install --frozen-lockfile and a rebuild of @objectstack/plugin-auth... (VERDICT command-exit 0): pnpm --filter @objectstack/plugin-auth test VERDICT command-exit 0, Test Files 240 passed (240), Tests 4271 passed | 10 skipped; pnpm --filter @objectstack/plugin-auth typecheck VERDICT command-exit 0 (test layer at recorded debt, none new); dist/index.d.ts and dist/index.d.mts carry answerOneTimeTokenHandoff and the three types, require(dist/index.js) answers function for it and false for each seam.",
"gates": "Local, head 61481ad: dispatch-gates --commands derived 75 families; all 75 ran, each exit 0 captured before any pipe; --ran: "75 derived famil(ies) accounted for — 75 run, 0 NOT-MEASURED" (record at pm-orders/server/31420-gates-ran.txt). eslint packages/plugins/plugin-auth --no-inline-config exit 0. check-governed-merges --branch HEAD exit 0: 0 of 6 paths hit the register, NOT governed. First pass on 241314c (before the merge): 74/75 exit 0, check:plugin-teardown-shape --self-test exit 3 on the shallow clone then exit 0 after fetching its fixture commit, check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET; both exit 0 on the final head. CI: PR #31464 check-runs in_progress at hand-back; test:repo, Test Core shards and dogfood are CI's.",
"dogfood_battery": "n/a — no
!in the PR title and no (narrowing) arm","mcp_calls": "0 — no MCP GitHub calls (the card and comments were read over public REST, the PR read back the same way)",
"api_writes": "3 — git push (two pushes of the harness branch: 241314c, then the merge 61481ad); relay pr_create (run 42467287433, POST /repos/objectstack-ai/objectstack/pulls, no labels, no assignees, read-back identical 10942 bytes); this os-dev-report comment via post-stamped (POST /repos//issues/31420/comments)",
"open_questions": [],
"security_findings": "none",
"out_of_scope_findings": [],
"deviations": [
"None to the card's shape. Order vs os-dev.md rule 2 on pr_create assignees: followed the order (no assignees). The order names no labels and a changeset ships, so pr_create carried no labels key."
],
"files_changed": [
".changeset/31420-one-time-token-handoff-export.md",
"packages/plugins/plugin-auth/src/auth-plugin.ts",
"packages/plugins/plugin-auth/src/auth-route-ledger.ts",
"packages/plugins/plugin-auth/src/index.ts",
"packages/plugins/plugin-auth/src/one-time-token-handoff.test.ts",
"packages/plugins/plugin-auth/src/one-time-token-handoff.ts"
],
"line_budget": "6 files, +263 / -35 (298 changed lines vs merge base), under the 3000 human-merge threshold"
}
Generated by Claude Code
objectstack-fleet commentedon Nov 8, 2026
domain:server#6 · os-project-manager · session_01FEkQ5p3T5YWhLjurajudnQ
落地 · 2026-11-08T10:31Z
c6c24262(ACCEPT 6720045536; contract review 6720037083 PASS at TIER_DEFAULT, declaration face)Fixes #31420State change:
pm:dispatchedremoved; nothing left on this card.@objectstack/plugin-authnow exportsanswerOneTimeTokenHandoffwithOneTimeTokenHandoffManager,OneTimeTokenHandoffServingandHandoffAnswer, in the shape cloud#3549 measured, so a host that forwards/api/v1/auth/*to a kernel's AuthManager answers the mobile sign-in hand-off. The minting seams stay unexported. The cloud draft built against this shape can now pin the release that carries it.