Skip to content

fix(console-auth): a dead invitation link reads "no longer valid", and a refused invitee is told the invited address - #27655

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-njrpew
Oct 29, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-njrpew

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC

Requested by Bill · project thread
Fixes #27509

Clause-②: no (getInvitationPreview's exported signature is unchanged; it now resolves null only for the preview door's own not-found body, which is what its documented contract already said; the new hook, notice and optional argument are on @object-ui/app-shell, a private package)
Metadata-protocol: none

维护者速读

  • 改了什么:未登录访客打开一个已失效(过期、已接受、已拒绝、已撤销或不存在)的邀请链接时,控制台两套登录页和注册页不再显示「Accept your invitation … Create an account and join」和注册表单,而是显示「This invitation link is no longer valid」并给出去普通登录页的链接;邀请仍有效但用别的邮箱注册被拒时,提示改为「This invitation was sent to i***@example.com. Register with that address to accept it.」。
  • 为什么改:邀请预览接口按 ADR-0138 D2 对五种失效情况统一回 404,前端把这个 404 当成「没有预览」照常画接受与注册流程,最后用「Self-registration is not open … Ask an administrator for an invitation」拒绝手握邀请链接的人。分诊裁定只改 ui/,不改服务端、不加错误码、文案不说「canceled」。
  • 风险与回滚:只改前端;服务端零改动。getInvitationPreview 只把带 RESOURCE_NOT_FOUND 正文的 404 读作「失效」,没有该路由的旧服务端回的空正文 404 现在按读取失败处理,页面保留原有通用文案,不会把有效链接说成失效。回滚即 revert 本 PR。
  • 先看:ui/packages/auth/src/createAuthClient.ts 的 getInvitationPreview(404 判别那几行);ui/packages/app-shell/src/console/auth/invitationArrival.tsx 的 useInvitationArrival 与 InvitationGoneNotice;两个 RegisterPage 里传给 RegisterForm 的 onError 与 signUpRefusalMessages 第二参数。
  • 两分钟验证:pnpm --dir ui exec vitest run packages/app-shell/src/console/auth/__tests__/invitationGone-27509.test.tsx apps/console/src/pages/auth/__tests__/invitationGone-27509.test.tsx,预期 10 个用例全绿。
  • 需要你做的:无,席位落地。

What changes

The signed-out preview door answers one 404 body (RESOURCE_NOT_FOUND, "Invitation not found. It may have expired, been used, or been withdrawn.") for an unknown, expired, accepted, rejected or cancelled id (ADR-0138 D2). The client read every 404 as "no preview", so all four pages kept drawing the accept-and-register flow for a dead link, and the sign-up ended in the generic SELF_REGISTRATION_CLOSED text. Per the triage answer on the card, the fix lives wholly in ui/: no server file, no new error code, no ADR change, and the copy never says "canceled".

  • @object-ui/auth getInvitationPreview. Resolves null only for a 404 whose body carries the door's error.code RESOURCE_NOT_FOUND. A server without the route answers 404 from better-auth's router with an EMPTY body (read from better-call 1.4.4's router, which returns a null body with status 404 for an unmatched path, and from plugin-auth's ${basePath}/* mount, which hands better-auth's 404 back verbatim when nothing downstream answers). That 404 now rejects like any other failed read, so a page never tells a valid invitation's holder their link is dead. The exported signature and the type's JSDoc contract are unchanged; the JSDoc now names that case.
  • One rule, in invitationArrival.tsx (app-shell). useInvitationArrival(redirect) returns { preview, gone, recheck }; gone is true only after the door's not-found answer for the current id, never for a failed read. useInvitationPreview is now its preview and behaves as before. InvitationGoneNotice is the one card all four pages draw for a gone invitation: "This invitation link is no longer valid", "It may have expired, been used, or been withdrawn. Ask the person who invited you to send a new invitation.", and a "Go to sign in" link to the plain /login (no redirect).
  • The four pages. The console's /login and /register and app-shell's DefaultLoginPage and DefaultRegisterPage draw the notice in place of the invitation header, the "Create an account and join" prompt, and the form (and, on the console login, the dev-admin row).
  • A refused invitation sign-up. Both register pages pass RegisterForm an onError that, for SELF_REGISTRATION_CLOSED on an invitation redirect, re-reads the preview. The door's 404 turns the page into the notice; an answer keeps the form. signUpRefusalMessages(t, invitation?) takes the invitation and maps SELF_REGISTRATION_CLOSED to "This invitation was sent to {{email}}. Register with that address to accept it." with the masked address, or, while no preview is known, "This invitation can only be accepted with the address it was sent to. Register with that address." The same one map, not a second one.
  • i18n. Five keys in all ten packs: auth.login.invitationGoneTitle, auth.login.invitationGoneDescription, auth.login.invitationGoneSignIn, auth.register.errors.invitationSentTo, auth.register.errors.invitationAddressOnly.
  • AcceptInvitationPage is untouched: its signed-out branch only bounces to /login?redirect=…, and its signed-in branch already reads the invitation with the session and shows its own not-found text.

Changeset: @object-ui/auth, @object-ui/app-shell, @object-ui/console, @object-ui/i18n, all patch.

Tests

New, one per host (the console's pages and app-shell's default pages), real AuthProvider over a real createAuthClient, only fetch stubbed:

  • ui/apps/console/src/pages/auth/__tests__/invitationGone-27509.test.tsx
  • ui/packages/app-shell/src/console/auth/__tests__/invitationGone-27509.test.tsx

Each pins: the door's 404 on login shows the notice with no sign-up prompt, no email field and a /login link; the door's 404 on register shows the notice and no form; a live invitation keeps the prompt and the form (control); a refused sign-up on a live invitation re-reads the preview (two reads, one sign-up) and the banner reads the masked-address text, not the generic one; a refused sign-up whose re-read answers 404 turns the page into the notice.

Updated: the two invitationPreview-26473 page files replace their "404 keeps the generic copy" case (it pinned this defect) with "a server without the route (empty-body 404) keeps the generic copy and the form"; the door's 404 moved to the 27509 files. ui/packages/auth/src/__tests__/invitationPreview-26473.test.ts adds: an empty-body 404 rejects with status: 404; a 404 with another code rejects with that code.

All runs below were re-taken on the merged head 4a728231 (origin/main merged once before this PR), except the ablation, which ran before the merge on the same implementation.

Targeted runs
  • The five touched files (inside the run below; alone before the merge): Test Files 5 passed (5), Tests 33 passed (33).
  • pnpm --dir ui exec vitest run apps/console/src/pages/auth/ packages/app-shell/src/console/auth/ packages/auth/ packages/i18n/ packages/app-shell/src/__tests__/product-neutral-renderer.ratchet.test.ts: Test Files 163 passed (163), Tests 1958 passed | 13 skipped (1971) (includes the i18n pack parity tests; 1957 of 1970 before the merge).
  • type-check for @object-ui/auth, @object-ui/i18n, @object-ui/app-shell, @object-ui/console: each VERDICT command-exit 0.
Ablation (scripts/ablation-replace.mjs, anchor 1 to 0, each restored to the HEAD blob with git diff HEAD empty)

ui vitest resolves @object-ui/* to source, so no rebuild sits between mutation and run.

  • A1, the client's body check replaced by a bare status === 404: 6 of 33 red (both client 404 cases, and the four "server without the route" page cases).
  • A2, gone forced false: 6 of 33 red (the door's-404 cases on both hosts' login and register, and both "gone meanwhile" cases).
  • A3, recheck made a no-op: 4 of 33 red (both re-read cases on both hosts).
  • A4, the invitation arm of signUpRefusalMessages bypassed: 2 of 10 red (the masked-address banner on both hosts).
Derived gates — 34 of 34 exit 0 on `4a728231`

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 34 commands (identical list before and after the merge); each ran from the worktree with its exit code captured before any pipe, and --ran reconciled: "34 derived famil(ies) accounted for — 34 run, 0 NOT-MEASURED". Among them node scripts/check-sdui-manifest.mjs (and --self-test), pnpm --dir ui exec vitest run scripts/, pnpm check:nul-bytes, pnpm check:published-files, pnpm check:type-check-coverage, pnpm check:type-check-debt, node scripts/check-empty-changeset.mjs --base origin/main and node scripts/check-adr-0087-registration.mjs --base origin/main. Also pnpm --dir ui run check:new-line-citations: 0 new citations.

The type-check lanes, the artifact-roster families and the whole-tree families are CI's; the PR's check-runs are the reading for them.

Acceptance notes

  • Between the page opening and the preview answering, an invitation visitor sees today's generic invitation card and form, which then turn into the notice for a gone id. Holding the whole page until the read settles would make a hanging read block sign-in, so the swap stays.
  • The re-read covers SELF_REGISTRATION_CLOSED only. Under an email_domain posture a mismatched address can be refused as EMAIL_DOMAIN_NOT_ALLOWED, which keeps its own text.
  • No assignee or reviewer is set on this PR: the maintainer's routing rule asks nobody before the seat's ACCEPT and review, so the harness's post-PR assign and review step was skipped on purpose.

Generated by Claude Code

…d a refused invitee is told the invited address

The invitation-preview door answers one uniform 404 for an unknown,
expired, accepted, rejected or cancelled id (ADR-0138 D2). The client
read that 404 as "no preview", so every login and register page kept
offering the accept-and-register flow for a dead link, and the
refusal at the end told the link holder to ask for an invitation.

- getInvitationPreview resolves null only for the door's own not-found
  body; a 404 without it (a server without the route) rejects.
- useInvitationArrival reports `gone`; all four pages draw
  InvitationGoneNotice in place of the card, the prompt and the form.
- A SELF_REGISTRATION_CLOSED refusal on an invitation register page
  re-reads the preview and names the masked invited address.

Claude-Session: https://claude.ai/code/session_01Utp6CaTkEVPn2dBYY2piPZ
Co-authored-by: Claude <noreply@anthropic.com>
…-read on both hosts; changeset

Claude-Session: https://claude.ai/code/session_01Utp6CaTkEVPn2dBYY2piPZ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 29, 2026
…; the not-found code constant sits above it

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

Copy link
Copy Markdown
Contributor Author

domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC

席位核对

ACCEPT · head 0c77e4f83e66a0ef85b18a202596c52a9d5a64d4 · 2026-10-29T16:48Z

  1. Shape: draft → main, Fixes #27509 only, attribution block on top, 25 files.
    • @object-ui/auth: createAuthClient.ts and types.ts (doc only), plus their test.
    • app-shell console/auth: invitationArrival.tsx, signUpRefusalMessages.ts, LoginPage.tsx, RegisterPage.tsx, two tests, and src/index.ts.
    • Console pages/auth: LoginPage.tsx, RegisterPage.tsx, two tests.
    • Five i18n keys in the ten packs, and one changeset.
    • No assignee, no reviewer.
  2. Premise measured: the seat read the diff.
    • getInvitationPreview now resolves null only for a 404 that carries the door's RESOURCE_NOT_FOUND body.
    • better-auth's empty-body 404, which a server without the route sends, now rejects, so the page keeps its generic copy.
    • useInvitationArrival returns gone only for that answer on the current id. All four pages draw InvitationGoneNotice (no prompt, no form). The copy never names one of the five cases, per ADR-0138 D2 and triage 6498828797.
    • A SELF_REGISTRATION_CLOSED refusal on an invitation register page calls recheck(). A 404 then gives the same notice; a 200 gives "This invitation was sent to i***@…" through the one refusal map (optional invitation argument).
    • useInvitationPreview keeps its signature.
    • No server file, no new error code, no packages/spec/** path.
  3. Gates (check-runs on this head): 44 success, 11 skipped, 0 red. These include Lint & Repo Gates 123587993179, TypeScript Type Check 123590474219, Test UI 123592529769 and Lint · PM dispatch-gates 123583766151. The dev ran the derived gates at 34/34 exit 0 on 4a728231. The only commit after that, 0c77e4f8, is a pure move of one const and its JSDoc above asAuthInvitationPreview's doc block, which the seat asked for. It was re-verified with the auth test (7/7) and the @object-ui/auth type-check.
  4. Contract review: not owed. Clause-②: no was confirmed by the seat:
    • getInvitationPreview's exported signature is unchanged. The narrower null set matches its documented contract, and the changeset states the behaviour change for an empty-body 404.
    • The new exports are on @object-ui/app-shell, which is private: true.
    • Metadata-protocol: none.
  5. Accepted deviations / out of scope:
  6. Landing: not governed (check-governed-merges --pr 27655: NOT governed, 0 of 25 paths), 844 lines < 3000. The seat lands it: ready + auto-merge now.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 29, 2026 16:48
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 29, 2026
Merged via the queue into main with commit 0561a98 Oct 29, 2026
56 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-njrpew branch October 29, 2026 17:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

1 participant