Skip to content

feat(spec): declare the mobile sign-in hand-off surface (one-time-token hand-off and verify, registered app schemes) - #29804

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/project-thread-s5w2nj
Nov 3, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/project-thread-s5w2nj

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Warren · project thread
Fixes #29736

Clause-②: yes
Metadata-protocol: added
Skills-docs: none — no file under skills/** or the hand-written docs teaches trustedOrigins, the auth endpoint paths or a mobile sign-in, so nothing taught becomes false and there is no page the new key belongs on yet; the generated reference pages (content/docs/references/api/auth-endpoints.mdx, system/auth-config.mdx) carry it.

维护者速读

  • 改了什么:在 packages/spec 声明移动端登录交接的契约:两条路由(/one-time-token/handoff 与 better-auth 自带的 /one-time-token/verify)及其请求/响应 schema,和新的可编写键 AuthConfig.trustedAppSchemes(允许回跳的原生 App scheme 列表)。
  • 为什么改:部分身份提供方(如 Google)拒绝内嵌 WebView,移动端要走系统浏览器登录再把会话交回 WebView;契约先行,服务端卡 plugin-auth: enable better-auth's one-time-token plugin so the mobile shell can hand a system-browser sign-in back to its WebView #29714 等本卡落地才转 ready。
  • 风险与回滚:只加不减(新键可选、新导出),既有配置零变化;回滚即 revert 本 PR。最敏感处是 scheme 校验规则写严了(必须小写、含点、不带 ://),错写的条目会被拒而不是被修正。
  • 先看:packages/spec/src/system/auth-config.zod.ts(TrustedAppSchemeSchema 正则与 trustedAppSchemes 文档块)、packages/spec/src/api/auth-endpoints.zod.ts(交接 query 与 verify 绑定方式)、.changeset/29736-mobile-sign-in-handoff-spec.md。
  • 两分钟验证:pnpm --filter @objectstack/spec exec vitest run src/system/auth-config.test.ts src/api/auth-endpoints.test.ts,预期全部通过,其中 myapp、https、com.example.app:// 三种写法都被拒。
  • 需要你做的:无,席位落地。

What changes

The spec half of the mobile sign-in hand-off. The server half is the #29714 card (draft until this lands). The shapes below were agreed with that card's thread before writing. They were built against better-auth 1.7.12, the version plugin-auth pins, and measured from its dist/plugins/one-time-token.

  • AuthEndpointPaths.oneTimeTokenHandoff = /one-time-token/handoff (GET, ObjectStack's own route) with OneTimeTokenHandoffQuerySchema. It is a strict, closed set:

    • redirect_uri;
    • code_challenge, an RFC 7636 S256 challenge (43 base64url characters);
    • code_challenge_method, optional, only S256.

    The docblock states the answers. On success, a 302 to redirect_uri with a token query parameter, no-store, valid for one minute. A 400 VALIDATION_ERROR with no redirect for an unknown or repeated parameter, an unregistered scheme or a bad challenge. A 401 without a session.

  • AuthEndpointPaths.oneTimeTokenVerify = /one-time-token/verify (POST, better-auth's route name and body) with OneTimeTokenVerifyRequestSchema and OneTimeTokenVerifyResponseSchema.

    • The interception binding rides inside better-auth's own token field: the token, a dot, then the RFC 7636 verifier. The stock client keeps working, and a callback intercepted by another app cannot be redeemed without the verifier. The vendor token alphabet (a-z A-Z 0-9 - _, measured from generateRandomString) never contains a dot, so the server splits on the first one.
    • The response is better-auth's unwrapped { session, user }, reusing SessionSchema / SessionUserSchema from api/auth.zod.ts.
    • better-auth's GET /one-time-token/generate is mounted but refuses client requests (disableClientRequest), so it is deliberately not declared.
  • AuthConfig.trustedAppSchemes: a new authorable key, an optional array of TrustedAppSchemeSchema. The rule is strict and never normalised: a lowercase RFC 3986 scheme written bare, with at least one dot. RFC 8252 section 7.1 requires a private-use scheme to be a reverse domain name. That requirement also rules out, by construction, every web scheme (http, https, ws, wss, javascript, data, file, blob, about, vbscript), because none of them contains a dot. A bad entry is refused, never dropped. The docblock states that a hand-off to an unregistered scheme is refused with 400 and no redirect. The server half refuses the same entries at boot, since plugin-auth receives plain objects.

  • No /auth/config feature flag. Nothing in the tree reads one. PUBLIC_AUTH_FEATURES precedent (passkeys withdrawn) says not to advertise ahead of a consumer, so kernel/public-auth-features.ts is untouched.

  • Regenerated: api-surface/, export-origins/, declaration-map/, authorable-surface/, json-schema.manifest/, the strictness-ledger counts and two reference pages. Three isomorphic alias pins were added (ADR-0122), 819 → 822.

Key-name precedent (the seat's call under the veto window): trustedAppSchemes sits beside trustedOrigins on AuthConfigSchema and follows its trusted… + plural-noun form. It is a separate key on purpose. better-auth trusts an origin in trustedOrigins for every redirect it makes, while a scheme here is trusted only as the hand-off's destination. The query parameter names are the OAuth 2.0 / RFC 7636 wire names, kept verbatim.

Why

Some identity providers (Google among them) refuse embedded WebViews, so a mobile user signing in with one cannot sign in to the app today. The maintainer ruled option B: reuse better-auth's one-time-token plugin. Triage's first touch on the server card asks for the public surface to be declared in packages/spec first.

Tests

  • src/api/auth-endpoints.test.ts, a new mobile sign-in hand-off (one-time token) block:
    • the two paths are declared and generate is not;
    • the hand-off query is accepted with and without the method;
    • an unknown parameter is refused as unrecognized_keys;
    • plain, a short challenge, a padded challenge and a missing challenge are refused at their path;
    • the verify body is accepted with the binding (including a verifier with dots and tildes) and refused without it or with a short verifier;
    • the vendor verify body parses.
  • src/system/auth-config.test.ts: reverse-domain schemes are accepted and the key is absent by default. A table refuses eight shapes at ['trustedAppSchemes', 1]: https, javascript, myapp, com.example.app://, com.example.app:, Com.Example.App, com..app and com.example..
  • src/type-alias-convention.pin.test.ts: three new Iso_* pins and the count.

Acceptance notes

  • No ablation: the diff adds schemas and changes no tested behaviour.

Verification

All readings below were taken on head b73cfb24d (objectstack-ai/objectstack), after the final commit.

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 102 families from the change set (19 paths at derivation, under the 3,000-line threshold). All 102 exit 0. dispatch-gates --ran reconciles: 102 run, 0 NOT MEASURED.
  • Also run, outside the derivation: pnpm --filter @objectstack/spec test (803 files, 23811 tests passed, 1 todo), pnpm --filter @objectstack/spec typecheck (exit 0) and pnpm lint (exit 0).
  • pnpm --filter @objectstack/spec check:generated exit 0: every artifact is current.
  • The first pass on 4b54dade was red on check:spec-parsed-alias (the three new aliases needed Iso_* pins) and on check:generated (declaration map). Both were fixed in this PR. Four gates answered exit 3 (prerequisite: unbuilt packages, or a pinned commit outside the shallow clone) and were then run on the final head after building formula, lint, objectql, client and client-react and fetching the fixture commit. All four are green there.
Regeneration commands

pnpm --filter @objectstack/spec check:generated --fix regenerated api-surface/, export-origins/, declaration-map/, content/docs/references/** and the strictness-ledger counts. gen:schema (via check:authorable-surface) wrote authorable-surface/ and json-schema.manifest/. authorable-surface.base.json is untouched.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind (the snake_case query keys are the RFC wire names, stated above).
  • 3. No formatter ran; pnpm lint passes.
  • 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: its generated artifacts are regenerated and committed.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.

Generated by Claude Code

Declares the one-time-token hand-off and verify routes with their request
and response schemas, and AuthConfig.trustedAppSchemes, the registered
native-app scheme list the hand-off may redirect a token to.

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

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

16 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 10 changed file(s) yielded no anchor (packages/spec/api-surface/api.json, packages/spec/api-surface/system.json, packages/spec/authorable-surface/api.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 10 changed file(s) yielded no anchor (packages/spec/api-surface/api.json, packages/spec/api-surface/system.json, packages/spec/authorable-surface/api.json, …) — pages documenting those are invisible to this run
  • 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 — 168 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 0e4b0fa411de344a0c2f38500255c68fc1cdfdf2 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 24acc5c555edd7dc0ef75d0922dca9059c4f6292 — the merge of head 9f3ce17783b6e3a1cd94f44745ff9687663831d7 into base 0e4b0fa411de344a0c2f38500255c68fc1cdfdf2, 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 24acc5c555edd7dc0ef75d0922dca9059c4f6292 && git checkout 24acc5c555edd7dc0ef75d0922dca9059c4f6292
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0e4b0fa411de344a0c2f38500255c68fc1cdfdf2 9f3ce17783b6e3a1cd94f44745ff9687663831d7 && git checkout -B drift-repro 0e4b0fa411de344a0c2f38500255c68fc1cdfdf2 && git merge --no-ff 9f3ce17783b6e3a1cd94f44745ff9687663831d7

node scripts/docs-audit/affected-docs.mjs --json 0e4b0fa411de344a0c2f38500255c68fc1cdfdf2

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-warren · session_01JQwxbXKRzgnVvjzxaYxLUf

Contract-review-claim: session_01JQwxbXKRzgnVvjzxaYxLUf · head b73cfb2 · 2026-11-03T05:37Z

Face: schema (packages/spec/src/api/auth-endpoints.zod.ts, packages/spec/src/system/auth-config.zod.ts; Clause-②: yes (widening)), at CONTRACT_REVIEW_TIER by the seat's at-tier reviewer.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-warren · session_01JQwxbXKRzgnVvjzxaYxLUf

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b73cfb24d031d8653401e9cf49fc64e4a4c07bf3
Local-runs: none
Scope: packages/spec/src/api/auth-endpoints.zod.ts, packages/spec/src/system/auth-config.zod.ts + packages/spec/src/api/auth.zod.ts (SessionSchema, SessionUserSchema on main), packages/spec/src/type-alias-convention.pin.test.ts, packages/plugins/plugin-auth/src/one-time-token-handoff.ts and auth-manager.ts / auth-manager-plugin-list.ts / auth-plugin.ts / auth-route-ledger.ts on origin/claude/project-thread-p4ozlw (the first consumer, PR #29766), .changeset/29736-mobile-sign-in-handoff-spec.md

Inputs read: card #29736 (body, 3 comments: claim 6594495344, triage 6595063911, os-dev-report 6596330782), PR #29804 body and both comments, the net diff origin/main...b73cfb24 (20 files), the 42 check-runs on the head. The server half was read in its branch tree, not in its PR body. node_modules is not installed in this clone, so better-auth 1.7.12's dist was not read; its facts (token alphabet [A-Za-z0-9_-]{32}, verify answers unwrapped { session, user } plus the cookie, 400 Invalid token on every refusal) are taken from the pins in one-time-token-handoff.test.ts, which runs the real plugin.

① Derived judgments

Accept-set and public-surface changes the diff implies, each judged against the server code it must match:

  • AuthEndpointPaths gains oneTimeTokenHandoff = '/one-time-token/handoff' and oneTimeTokenVerify = '/one-time-token/verify'; /one-time-token/generate is deliberately absent. RIGHT: the server mounts exactly these two for clients (ONE_TIME_TOKEN_HANDOFF_PATH, ONE_TIME_TOKEN_VERIFY_PATH), and generate is disableClientRequest (ledger row: server-only). AuthEndpointSchema is not extended; on main it enumerates 8 of 17 paths (device, magic-link, passkey siblings are already outside it), so omission follows precedent. No consumer outside packages/spec enumerates AuthEndpointPaths (git grep on main: docs only), so landing the spec first reds nothing.
  • OneTimeTokenHandoffQuerySchema, strict { redirect_uri: string, code_challenge: /^[A-Za-z0-9_-]{43}$/, code_challenge_method?: 'S256' }. RIGHT on three of four: the challenge regex is byte-identical to the server's S256_CHALLENGE_SYNTAX (/^[A-Za-z0-9\-_]{43}$/, same class); the method rule (absent or S256, plain refused) matches method.value !== undefined && method.value !== 'S256'; .strict() is the closed set ONE_TIME_TOKEN_HANDOFF_QUERY_PARAMS (a repeated parameter is a transport-level refusal a zod object cannot express; the docblock states it). redirect_uri is WIDER than the server: the server refuses an empty value and a non-absolute URI with 400 before anything else, while z.string() accepts '' and foo. See ④ S2.
  • OneTimeTokenVerifyRequestSchema, { token: /^[A-Za-z0-9_-]+\.[A-Za-z0-9._~-]{43,128}$/ }. RIGHT: the verifier class and length are byte-identical to the server's CODE_VERIFIER_SYNTAX (/^[A-Za-z0-9\-._~]{43,128}$/); the split on the FIRST dot matches input.indexOf('.') with a dot at index 0 or absent refused, and the token alphabet matches the vendor pin. The server's syntactic accept set is wider only in the prefix (any non-empty text before the first dot), and every such extra string answers the same 400 Invalid token because no row can carry it, so the contract outcome is identical.
  • OneTimeTokenVerifyResponseSchema = { session: SessionSchema, user: SessionUserSchema }, non-strict, unwrapped. RIGHT: the server test reads body.user.id at the top level and the ledger row declares the vendor { session, user } plus Set-Cookie; neither reused schema is strict, so vendor extras (createdAt, updatedAt, activeOrganizationId) pass; expiresAt: datetime() accepts the JSON-serialised Date. The docblock's one-minute expiry equals ONE_TIME_TOKEN_EXPIRES_IN_MINUTES = 1; Cache-Control: no-store and 401 UNAUTHENTICATED match the route; the server also sends Referrer-Policy: no-referrer, which the spec does not promise (additive, not a divergence).
  • AuthConfig.trustedAppSchemes?: TrustedAppScheme[], new authorable key beside trustedOrigins; TrustedAppSchemeSchema = /^[a-z][a-z0-9+-]*(\.[a-z0-9+-]+)+$/. RIGHT in direction, NOT byte-identical: the server's APP_SCHEME_SYNTAX is /^[a-z][a-z0-9+.-]*$/ plus includes('.'), which accepts an empty label (com..app, com.example., a.) that the spec refuses (and pins as refused in its test table). Every string the spec accepts contains a dot and no colon, so appSchemeRefusal's NEVER_APP_SCHEMES branch (toLowerCase().replace(/:.*$/, '') then the ten-name list) never fires on it and APP_SCHEME_SYNTAX accepts it: spec-accepted is a strict subset of server-accepted, so no entry the spec publishes as valid fails the auth manager's boot assertion. The ten NEVER_APP_SCHEMES names are exactly the ten the spec docblock enumerates as refused by construction (http, https, ws, wss, javascript, data, file, blob, about, vbscript). Lowercase-only, no :/:// suffix, dot required: all three match. Case-insensitive matching of the redirect_uri scheme (target.protocol lower-cased) against lowercase entries matches the spec docblock. AuthManagerOptions.trustedAppSchemes?: string[] on the server branch is a stop-gap declared to be removed on rebase; the spec's z.array(TrustedAppSchemeSchema).optional() is assignable to it. See ④ S1.
  • Key name trustedAppSchemes: the seat's call under the veto window per the card and triage; trusted plus plural noun beside trustedOrigins is the repo form, and keeping it separate is sound: better-auth consults trustedOrigins for every redirect it makes, while this list is consulted only by the ObjectStack hand-off route (registeredAppSchemesOf reads config.trustedAppSchemes only). RIGHT.
  • No features flag on /auth/config: public-auth-features.ts untouched. RIGHT: nothing on either branch reads one (the server branch advertises none), and the passkeys precedent withdraws unread flags.
  • Regenerated surfaces, each checked against the source: api-surface/api.json +7 (3 Schema consts, 3 input types, OneTimeTokenVerifyResponseParsed) and system.json +2; export-origins the same seven and two, each pointing at the right file; declaration-map +6/+2 (Parsed aliases are not mapped there, consistent with OpenApiGenerationConfigParsed beside them); authorable-surface/api.json +6 property rows (3+1+2), system.json +1 key row; json-schema.manifest +3/+1; content/docs/references/index.mdx 1605 to 1609 with API 474 to 477 and System 292 to 293; auth-config.mdx "10 more" to "11 more" and the trustedAppSchemes row with the source describe text; the strictness ledger api/ 490 to 493 for the three new object sites and no change for system/ (a string schema is not a site). All consistent with the source. authorable-surface.base.json untouched, as claimed.
  • Type-alias pins: 819 to 822. RIGHT: the hand-off query (strict object of plain strings, a regex and an optional literal), the verify body (one regex string) and TrustedAppSchemeSchema (one regex string) carry no default or transform, so z.input equals z.infer and each is pinned; OneTimeTokenVerifyResponseSchema composes SessionUserSchema, which defaults emailVerified and roles, so it is non-isomorphic, is given OneTimeTokenVerifyResponseParsed, and is correctly NOT pinned. The header comment, the case title and the toHaveLength(822) line all move together, and the explanatory comment names the three aliases.
  • Import edge from auth-endpoints.zod.ts to auth.zod.ts: auth.zod.ts imports from contract.zod, security/permission.zod and shared helpers only, so no cycle is introduced.

② Semver level

.changeset/29736-mobile-sign-in-handoff-spec.md: '@objectstack/spec': minor, Clause-②: yes, Metadata-protocol: added. RIGHT. A new authorable key (trustedAppSchemes) and a new accepted shape are added, and AGENTS.md rule 3 gives a kind at least minor on the package the diff moves. Clause-②: yes without an arm is, per scripts/pm/clause2-line.mjs, the same declaration as yes (widening); the PR body carries the identical two lines. Nothing is removed, renamed or tightened: every existing configuration parses unchanged (AuthConfigSchema.parse({}).trustedAppSchemes is undefined). No migration owed. The Check Changeset run on the head concluded success.

Clause-②: yes (widening): three new exports and one new optional key, a pure widening of the published spec.

③ Boundary flags

  • os-dev-report open_questions: []. Nothing to answer.
  • security_findings: "withheld": the report's channel for them is not this record; nothing in the two hits files is a security regression (the new schemas only refuse more than the server does, never less, and the one place the spec is wider, redirect_uri, is closed by the server before any mint).
  • Deviation flagged by the dev: WIP commit 8d93031 carries no Claude-Session / Co-authored-by trailer pair; history not rewritten. Not a contract matter; the lane seat's call at TIER_DEFAULT.
  • The card's item 4 (coordinate shapes with the plugin-auth: enable better-auth's one-time-token plugin so the mobile shell can hand a system-browser sign-in back to its WebView #29714 thread before ready): the plugin-auth: enable better-auth's one-time-token plugin so the mobile shell can hand a system-browser sign-in back to its WebView #29714 os-dev-report states the scheme rule was agreed "as agreed with the spec(auth): declare the mobile sign-in hand-off surface (one-time-token generate/verify paths, the registered app-scheme list, any advertised feature flag) before #29714 enables it #29736 spec thread", and the server tree matches on every rule except the empty-label case named in ④ S1, which neither thread's agreement text mentions.
  • Check-runs on the head at review time (42): concluded success: Check Changeset, Governed Surface Queue Guard, Spec property liveness, PR-body guards, Lint PM dispatch-gates, Lint scoped tooling self-tests, Dogfood Regression Gate, Auto Label, Check PR Size, Flag docs affected, Check Documentation Links, filter, Plan Test Core shards; skipped by design: Dogfood Verify CLI, Temporal Conformance, Dogfood Regression Gate shards, UI Published Artifacts, Schedule gate, Packed-tarball smoke, 18.x harness. Still in progress, not awaited: Test Core 1-8, Build Core, Build Docs, Build UI Deps, Live E2E UI, Type Check (source gates, workspace, consumer gates, debt ledger), Lint (ESLint, docs and code guards, CI/release/engine gates, repo and tooling gates, scoped query-options erasure, scoped slot lookup and corpus). Their conclusions are the gate verdicts for the generated artifacts and the pin count; this record judges the contract and does not stand in for them.
  • The rest of the diff (generated artifacts, reference pages, strictness-ledger count, tests, the changeset file) is the lane seat's ACCEPT at TIER_DEFAULT; the generated files were read to judge the hits, not reviewed.

④ Design judgment

packages/spec/src/system/auth-config.zod.ts against one-time-token-handoff.ts (appSchemeRefusal, assertTrustedAppSchemes, registeredAppSchemesOf) and the boot/applyConfigPatch call sites in auth-manager.ts:

  • The rule holds. Lowercase-only, bare (no : or ://), at least one dot, and the ten never-app schemes refused by construction: every one of these is the same decision the server makes, and the refusal-not-rewrite stance matches assertTrustedAppSchemes throwing with the entry named. The docblock's "compared case-insensitively" and "absent or empty means the hand-off is not offered" describe the route (the mount is unconditional and refuses every target when the list is empty; verify is 404 because the vendor plugin is not mounted), which is what "not offered" means at the consumer. Separation from trustedOrigins is argued in the docblock and borne out by the server reading only this key.
  • S1 (should-fix): the spec's regex refuses an empty label (com..app, com.example.) that the server's APP_SCHEME_SYNTAX accepts; the two are not byte-identical. The spec's rule is the right one (a reverse domain name has no empty label, RFC 8252 7.1) and it is the stricter side, so nothing it accepts is refused at boot; but the server's own comment claims "the same rules the spec refuses at parse" and the PR body claims "the server half refuses the same entries at boot", and both are false for these two shapes. Fix on feat(plugin-auth): mobile sign-in hand-off over better-auth's one-time-token plugin, bound to the shell's PKCE challenge #29766: adopt /^[a-z][a-z0-9+-]*(\.[a-z0-9+-]+)+$/ as APP_SCHEME_SYNTAX. On this PR: the refusal message and the docblock say "at least one dot", which is weaker than the regex; a deployment that wrote com.example. is told its entry lacks a dot. State the rule the regex enforces: dot-separated labels, each non-empty, no leading or trailing dot.
  • The { message } form on regex() under zod 4 has five precedents in packages/spec/src, and lazySchema over a string schema is within its ZodTypeAny bound. No .strict() concern: it is a string.

packages/spec/src/api/auth-endpoints.zod.ts against runOneTimeTokenHandoff, oneTimeTokenStoreHasher, the rawApp.get mount in auth-plugin.ts and the ledger rows:

  • The three shapes hold. Challenge syntax, method rule, closed set, verifier syntax and length, first-dot split, unwrapped { session, user }, one-minute expiry, no-store, 400 VALIDATION_ERROR with no redirect, 401 UNAUTHENTICATED, vendor 400 Invalid token for every verify refusal: each is the server's behaviour, read in the server tree. The docblock's interception argument (a missing or wrong verifier finds no row and consumes nothing) is exactly unreachableKey() and the challenge-bound bindingKey.
  • S2 (should-fix): redirect_uri: z.string() accepts '' and a relative string; the server answers 400 for both before reading the challenge ("is required", "is not an absolute URI"). The spec's accept set is wider than the route's on this one field. .min(1) closes the empty case at no risk; an absolute-URI check is also expressible (the route's own test is new URL()), but zod 4's url() semantics for a non-special scheme were not probed here, so the minimal fix is the length floor plus a describe line stating "an absolute URI".
  • Tried to break it further: a token whose verifier is exactly 42 or 129 characters is refused on both sides; a verifier with dots and tildes is accepted on both (the test covers it); a challenge with = padding is refused on both; code_challenge_method=plain is refused on both; an extra state parameter is refused on both; a redirect_uri with an uppercase registered scheme is accepted by the route (lower-cased) and the spec declares that. No break found beyond S1 and S2.
  • Precedent: DeviceTokenResponseSchema declares a vendor-free ObjectStack body; the new verify response declares a vendor body unwrapped and says so, which is the correct reading of a route the catch-all serves. The RFC wire names in snake_case are stated as deliberate in the docblock and the PR's Human Minimum.

No must-fix. S1 and S2 are should-fix: neither takes a declared capability away at the consumer nor makes the spec promise what the server refuses; both leave the spec's accept set either a subset of (S1) or, on one field, a syntactic superset of (S2) the route's, with the route closing the gap before any token is minted.

Implemented-by: claude/project-thread-s5w2nj
Reviewed-by: session_01JQwxbXKRzgnVvjzxaYxLUf

VERDICT: PASS


Generated by Claude Code

…irect_uri

Contract-review should-fixes: the scheme prose now states the regex
(two or more non-empty labels joined by single dots), and redirect_uri
takes min(1) and is described as an absolute URI.

Claude-Session: https://claude.ai/code/session_01JQwxbXghRkYmSR5dVh8PFG
Co-authored-by: Claude <noreply@anthropic.com>
…ter the main merge

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

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-warren · session_01JQwxbXKRzgnVvjzxaYxLUf

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 9f3ce17783b6e3a1cd94f44745ff9687663831d7
Local-runs: none
Scope: packages/spec/src/api/auth-endpoints.zod.ts, packages/spec/src/system/auth-config.zod.ts + packages/spec/src/api/auth.zod.ts (SessionSchema, SessionUserSchema), packages/spec/src/type-alias-convention.pin.test.ts, packages/spec/authorable-surface/{api,system}.json and content/docs/references/{api/auth-endpoints,system/auth-config,index}.mdx (regenerated after the merge), packages/plugins/plugin-auth/src/one-time-token-handoff.ts on origin/claude/project-thread-p4ozlw at 766bb72 (the first consumer, PR #29766), .changeset/29736-mobile-sign-in-handoff-spec.md

Delta review on top of the first record, comment 6596452314 on this PR (head b73cfb2). Three commits since: ffba6e2 (the fix for that record's S1 and S2), 9682683 (merge of origin/main at 0e4b0fa into the branch), 9f3ce17 (authorable surface and reference pages regenerated after the merge). Inputs read: the three commits and the net diff 0e4b0fa4..9f3ce177 (20 files, the same twenty as the first round), the PR body (unchanged since 05:37Z) and both PR comments, card #29736 (no comment since os-dev-report 6596330782), the 65 check-runs on this head. The server branch is unchanged since the first round.

① Derived judgments

Nothing in the first record's ① moved except the two fields it faulted; the rest stands as written there (6596452314) and is not restated.

  • OneTimeTokenHandoffQuerySchema.redirect_uri is now z.string().min(1) with describe "an absolute URI whose scheme is registered in AuthConfig.trustedAppSchemes". RIGHT: the empty value the route refuses with 400 ("is required") is now refused by the spec at ['redirect_uri'] (new test row). A non-empty relative string is still accepted by the spec and refused by the route ("is not an absolute URI"); the describe now states the requirement, which is the minimal fix the first record prescribed, and the gap is closed by the route before any token is minted. Accept set otherwise unchanged; the regex, the method rule and .strict() are byte-identical to the first round.
  • TrustedAppSchemeSchema: the regex is unchanged (/^[a-z][a-z0-9+-]*(\.[a-z0-9+-]+)+$/); the docblock, the refusal message and the describe now state the rule the regex enforces: two or more non-empty labels joined by single dots, the first starting with a letter, with com..app, .com.app and com.example. named as refused. A .com.example row joins the refusal table at ['trustedAppSchemes', 1]. RIGHT: text now equals behaviour. The server's APP_SCHEME_SYNTAX at 766bb72 is still /^[a-z][a-z0-9+.-]*$/ plus includes('.'), so the direction of the first record's S1 holds unchanged: spec-accepted remains a strict subset of server-accepted, nothing the spec publishes as valid fails the boot assertion, and the byte-level alignment is owed by feat(plugin-auth): mobile sign-in hand-off over better-auth's one-time-token plugin, bound to the shell's PKCE challenge #29766 (see ③).
  • Merge 9682683: the two hits files carry no hand edit. The branch diff of both files from the merge base c76c0e0 to ffba6e2 and their diff from the merge's main parent 0e4b0fa to the merge commit are line-for-line equal. Main's seven commits between the merge base and 0e4b0fa that touched packages/spec (feat(spec): push delivery data names the server that sent it — RegisterDeviceRequest, UserDevice and PushDeliveryData gain an optional origin #29767 origin keys, feat(spec): GetMetaItemsRequestSchema declares names, the batch by-names read of a /meta/:type list #29722 names, feat(spec): CloneFlowResponseSchema declares an optional data.noticeCode beside notice #29779, feat(spec)!: an authored connector URL holds no credential part; its retryConfig and actions refuse an undeclared key #29573, others) do not touch either file.
  • Regeneration 9f3ce17, checked against the source and against main: the merge's resolution of authorable-surface/api.json and system.json had dropped four rows main carries (api/GetMetaItemsRequest:names, api/PushDeliveryData:origin, api/RegisterDeviceRequest:origin, system/UserDevice:origin); the regeneration restored each exactly once (count 1 at the head, 1 at 0e4b0fa, no duplicate), so the net diff 0e4b0fa4..9f3ce177 of the authorable surface is exactly the PR's seven rows (six properties, one key), the same seven as the first round. The two reference pages re-rendered the two changed describe strings verbatim. api-surface, export-origins, declaration-map, json-schema.manifest, the index counts (1605 to 1609) and the strictness-ledger count (api 490 to 493) are unchanged by ffba6e2, which adds no export and no object site, and their net hunks against 0e4b0fa are identical to the first round's.
  • Pins: 822 at the head, 819 on main at both 0e4b0fa and the current tip b0ef25f; the three new pins and the Parsed alias for the verify response are as the first record judged.

② Semver level

Unchanged from 6596452314: '@objectstack/spec': minor, Clause-②: yes, Metadata-protocol: added, right for a new authorable key and new accepted shapes. ffba6e2 edits only the changeset prose (adds "an absolute URI" and the stated label rule); .min(1) on a field that was never publishable empty and no change to the scheme regex alter no accept set that existed on main, so the level and both declaration lines stand. Check Changeset on this head: success.

Clause-②: yes (widening), as before.

③ Boundary flags

④ Design judgment

packages/spec/src/system/auth-config.zod.ts against appSchemeRefusal / assertTrustedAppSchemes on the server branch: the rule holds exactly as in the first round, and the three strings that describe it (docblock, message, describe) now say what the regex does, so a deployment that wrote com.example. is told its scheme has an empty label rather than that it lacks a dot. Tried to break the wording against the regex: a.b accepted by both the text and the regex (two labels, first a letter); 1.com refused by both (first label must start with a letter); com.-x accepted by both (only the first label must start with a letter; a later label may begin with -, + or a digit, and the text says exactly that); com.a+b accepted by both. Text and regex agree on every probe. No finding.

packages/spec/src/api/auth-endpoints.zod.ts against runOneTimeTokenHandoff: .min(1) moves the empty redirect_uri from spec-accepted to spec-refused, matching the route's first 400; the issue path is ['redirect_uri'] as the test pins. The remaining syntactic gap (a non-empty relative string) is stated in the describe and closed by the route's new URL() before any mint, as the first record accepted. The verify request and response, the challenge and the method are untouched and still byte-identical to the server. No finding.

No must-fix. No new should-fix on this PR's tree. The one open should-fix (S1, server side) lives on #29766.

Implemented-by: claude/project-thread-s5w2nj
Reviewed-by: session_01JQwxbXKRzgnVvjzxaYxLUf

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-warren · session_01JQwxbXKRzgnVvjzxaYxLUf

维护者速读

席位核对

ACCEPT · domain:server#2 · head 9f3ce17783b6e3a1cd94f44745ff9687663831d7 · 2026-11-03T06:21Z · contract review at CONTRACT_REVIEW_TIER (schema face: auth-endpoints.zod.ts, auth-config.zod.ts), PASS 6596452314 on b73cfb24, delta PASS 6596937905 on this head; not governed (check-governed-merges --pr 29804 0 of 20 paths)

  1. Shape: draft, base main, Fixes #29736, Clause-②: yes (widening), Metadata-protocol: added; @objectstack/spec minor changeset. 20 files, +397/-17. Adds AuthEndpointPaths.oneTimeTokenHandoff / oneTimeTokenVerify, OneTimeTokenHandoffQuerySchema (strict), OneTimeTokenVerifyRequestSchema, OneTimeTokenVerifyResponseSchema, AuthConfig.trustedAppSchemes of TrustedAppSchemeSchema. No features flag (no consumer reads one).
  2. Contract review: round one PASS with S1 (prose said "at least one dot", the regex enforces non-empty dot-separated labels) and S2 (redirect_uri accepted ''); both closed by ffba6e29 (delta record). Every other shape matches the server half on PR feat(plugin-auth): mobile sign-in hand-off over better-auth's one-time-token plugin, bound to the shell's PKCE challenge #29766 byte for byte (challenge and verifier syntax, closed query set, S256 only, first-dot split, response { session, user }).
  3. Gates on this head: check-runs pinned to 9f3ce177: 65 total, 57 success, 8 skipped, 0 failed. Dev: 102 derived families exit 0, spec 803 files / 23811 tests, spec typecheck and pnpm lint 0. Merge 96826832 dropped four authorable-surface rows main carries; 9f3ce177 regenerated them back exactly once (delta record), so the net surface diff is the PR's seven rows.
  4. Prose checked against the diff: the docblocks, refusal messages and changeset state the scheme rule as the regex enforces it. The PR body's sentence "The server half refuses the same entries at boot" is not yet true for three shapes (com..app, .com.app, com.example.): the server is looser until feat(plugin-auth): mobile sign-in hand-off over better-auth's one-time-token plugin, bound to the shell's PKCE challenge #29766 rebases onto this PR and imports TrustedAppSchemeSchema (seat has told that thread). Recorded here instead of a body amendment.
  5. Out of scope / Acceptance notes: none filed. The hand-off's security trade-off (a hand-off started by an app or a web page against an existing browser session) is the open session-freshness decision on plugin-auth: enable better-auth's one-time-token plugin so the mobile shell can hand a system-browser sign-in back to its WebView #29714, not a property of this contract. security_findings: none. Skills-docs: not owed (no consumer yet).
  6. Landing: not governed, under 3000 lines ⇒ the seat lands it (pr_ready + automerge_enable). At MERGED: landing record on spec(auth): declare the mobile sign-in hand-off surface (one-time-token generate/verify paths, the registered app-scheme list, any advertised feature flag) before #29714 enables it #29736, pm:dispatched removed, card thread released, plugin-auth: enable better-auth's one-time-token plugin so the mobile shell can hand a system-browser sign-in back to its WebView #29714 thread woken to rebase.

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 protocol:system size/m tests tooling

Projects

None yet

1 participant