Repository navigation
Commit 8057a8b
fix(console): /verify-email verifies through better-auth's GET route (objectui#11633) (#11651)
Fixes #11633
Clause-②: no
## What changed
`VerifyEmailPage` (`apps/console/src/pages/auth/VerifyEmailPage.tsx`)
sent the token as `POST /api/v1/auth/verify-email` with a JSON body.
better-auth 1.7.3 declares `/verify-email` as `method: "GET"` only, so
the server answered 404. Every valid token showed "Verification failed:
404" and the account stayed unverified.
The page now calls `GET /api/v1/auth/verify-email?token=TOKEN`, with the
token encoded through `URLSearchParams` and no `callbackURL`. Without a
`callbackURL` the route answers JSON instead of a 302. The page counts
only that JSON receipt (`status: true`) as success. A garbage or expired
token is a 401 carrying `code` and `message`, and the page renders its
error state with the server's reason. A 2xx that is not the receipt,
such as an HTML page, is an error too. The page's states, copy and links
are unchanged. There is no server change and no new route (triage
direction, comment 5986886556).
The fence holds: no export, prop, type member or i18n key is added to
any `@object-ui/*` package. The diff is the body of the verify call, one
new test file and the changeset.
## Live before/after: real backend, real Chromium, head `e428840`
- **Backend:** objectstack `27991556` (main when read),
`examples/app-showcase`, `objectstack dev --seed-admin --fresh`, with
`OS_AUTH_AUDIENCE_POSTURE=open` and
`OS_AUTH_AUDIENCE_SELF_REGISTRATION_PERMISSION_SET=showcase_member_default`.
- **Console:** this branch's `apps/console` under Vite, proxied to that
backend.
- **Tokens:** each case signs up a fresh address and reads the token
from its `sys_email.body_text`. The mailed link reads
`ORIGIN/api/v1/auth/verify-email?token=TOKEN&callbackURL=%2F`. The
expired token is a JWT for a fresh unverified user, signed with the dev
secret, whose `exp` is an hour in the past.
- **Before leg:** the pre-fix page (blob `2b4a836`) is put on disk under
the running dev server, probed, and then restored from `HEAD`. The
restore is proved by blob hash and an empty `git diff HEAD`.
| case | before (pre-fix page) | after (this branch) |
|---|---|---|
| valid token | "Verification failed: 404"; wire `POST` → 404; sign-in
afterwards 403 `EMAIL_NOT_VERIFIED` | "Email verified"; wire `GET` →
200; sign-in afterwards 200, `emailVerified: true` |
| garbage token | "Verification failed: 404" (`POST` → 404) |
"Verification failed" with "Invalid token" (`GET` → 401) |
| expired token | "Verification failed: 404" (`POST` → 404) |
"Verification failed" with "Token expired" (`GET` → 401); sign-in
afterwards still 403 `EMAIL_NOT_VERIFIED` |
The same server answers `POST` with 404 in both the body form and the
query form, and the user stays unverified.
### Which call shape tells success from failure
The probe ran `fetch` from the console origin through the proxy, with
one fresh token per cell:
| call shape | valid | expired | garbage |
|---|---|---|---|
| no `callbackURL`, `redirect` follow or manual (same answers) | 200
`application/json` with `status: true` and `user: null` | 401 JSON with
`TOKEN_EXPIRED` and "Token expired" | 401 JSON with `INVALID_TOKEN` and
"Invalid token" |
| `callbackURL=/`, follow | 200 `text/html`, redirected to `/` | 200
`text/html`, redirected to `/?error=TOKEN_EXPIRED` | 200 `text/html`,
redirected to `/?error=INVALID_TOKEN` |
| `callbackURL=/`, manual | `opaqueredirect`, status 0 | same | same |
| `callbackURL=/`, manual, `Accept: application/json` |
`opaqueredirect`, status 0 | same | same |
Only the shape without `callbackURL` tells the three cases apart from
the response alone. A followed redirect is a 200 HTML page for all
three, and a manual one is an opaque status 0 for all three. An `Accept`
header does not change the answer, which matches the vendor source:
`ctx.redirect` is thrown whenever `callbackURL` is present.
### Measured points from the dispatch
- **Session cookie.** A successful verify sets no cookie: the browser
context holds none afterwards, and no verify response carried
`set-cookie`. `autoSignInAfterVerification` is not configured, so
better-auth's default (off) applies. The success state's "You can now
sign in" and its link to `/login` are therefore accurate, and the page
does not route anywhere new.
- **Reusable helper: none.** `@object-ui/auth`'s `AuthClient` (what
`useAuth()` exposes) has no verify-email member, and adding one would
add a type member, which the fence forbids. `@objectstack/client` 17.6.0
has `auth.verifyEmail`, but the public auth routes render outside
`ConnectedShell`, so there is no adapter or client instance there. The
method also builds `new URL(baseUrl + route + '/verify-email')` with no
base. With the console's `baseUrl` (`VITE_SERVER_URL` or empty) it
throws `TypeError: Invalid URL`, measured in node against the installed
17.6.0. The page therefore keeps its direct `fetch`.
## Tests (head `e428840`)
- `pnpm exec vitest run apps/console/src/pages/auth/`: `Test Files 9
passed (9)`, `Tests 54 passed (54)`.
- New file
`apps/console/src/pages/auth/__tests__/VerifyEmailPage-11633.test.tsx`
(4 tests). The stub answers like the live route: the JSON receipt for a
valid token, a 401 with `code` and `message` for a garbage or expired
one, 404 for any other method, and an HTML page for a request that
carries `callbackURL`. The valid token contains `+`, `/` and `=`, so
query encoding is pinned too.
- **Ablation 1:** the pre-fix page goes on disk (blob `2b4a836`,
`method: 'POST'` count 0 → 1). Result: `Tests 3 failed | 1 passed (4)`.
It is restored from `HEAD` (blob `df56771`, `git diff HEAD` empty), and
then `4 passed (4)`.
- **Ablation 2:** the fix stays, but its receipt check `if (!res.ok ||
data?.status !== true)` becomes `if (!res.ok)` (anchor 1 → 0). Only "a
2xx that is not the JSON receipt (an HTML page) is not a success" turns
red: `1 failed | 3 passed (4)`. After the restore, `4 passed (4)`.
Ablation 1 cannot make that pin fail, because the pre-fix page never
sees a 2xx from the stub. Neither ablation left a permanent test file.
## Gates (head `e428840`)
- **Exit 0:** the dependency closure build (`turbo run build
--filter='@object-ui/console^...'`, 34/34 tasks), `pnpm --filter
@object-ui/console type-check` (the script `tsc --noEmit && tsc -b
tsconfig.node.json --force` echoed) and `pnpm --filter
@object-ui/console lint`.
- **Lint detail:** the console's `eslint .` reports 0 errors and 221
warnings. One warning is on this page: `react-hooks/set-state-in-effect`
at the missing-token branch, an unchanged line that is the same on
`main`.
- **Exit 0, root checks:** `check:new-line-citations` (0 new citations),
`check:control-bytes`, `check:test-path-roots`,
`check:vi-mock-specifiers`, `check:vi-mock-inherit`,
`check:vi-mock-override-shape`, `check:changeset-claims`,
`check:pending-changeset-literals`, `check:i18n-keys`,
`check:phantom-deps`, `check:unreferenced-sources`,
`scripts/check-changeset-presence.mjs` and
`scripts/check-changeset-no-major.mjs`.
- **Not run locally:** the repository-wide `pnpm lint` and the full
`pnpm test` belong to CI.
- **Governed surface:** `scripts/check-governed-queue-guard.mjs --test`
on the three paths answers NOT GOVERNED.
## Acceptance notes
These were observed and are not changed here.
- **Three verify GETs per visit.** In the dev build each visit fires the
verify call three times: React StrictMode mounts twice, and the effect
runs once more when `t` changes identity (its dependencies are `token`
and `t`). This is not new: the pre-fix page sent three POSTs. It is
harmless for this route, because the second and third GETs answer 200
with the receipt: the route returns `status: true` for an
already-verified address (measured). A change-email confirmation token
would send its follow-up mail once per run. That path is reachable only
by opening this page by hand with such a token, since the mailed links
target the API route.
- **Raw server reason in every locale.** The error state shows
better-auth's English reason ("Invalid token", "Token expired"), as the
pre-fix page did with the server's `message`.
- **`auth.verifyEmail` throws on a relative `baseUrl`.**
`@objectstack/client`'s `auth.verifyEmail` throws `TypeError: Invalid
URL` when the client's `baseUrl` is relative or empty, as described
above. Nothing calls it today. Owner if it gets one: none named.
## Resumption
This branch was resumed from `92052a8` after a container restart. That
head held the fix `d1a181c` and a merge of `main` (`531b26c`). This run
reviewed both commits against the dispatch and kept them unchanged. It
added one merge of `main` (`0baf86f`) and re-measured everything above
on `e428840`. Run session:
`https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL`.
---
_Generated by [Claude
Code](https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL)_
Co-authored-by: Claude <noreply@anthropic.com>1 parent 0baf86f commit 8057a8b
3 files changed
Lines changed: 164 additions & 17 deletions
File tree
- .changeset
- apps/console/src/pages/auth
- __tests__
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
5 | | - | |
6 | | - | |
7 | | - | |
8 | | - | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
9 | 9 | | |
10 | 10 | | |
11 | 11 | | |
| |||
44 | 44 | | |
45 | 45 | | |
46 | 46 | | |
47 | | - | |
48 | | - | |
49 | | - | |
50 | | - | |
51 | | - | |
52 | | - | |
53 | | - | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
54 | 57 | | |
55 | | - | |
56 | 58 | | |
57 | | - | |
58 | | - | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
59 | 66 | | |
60 | | - | |
61 | | - | |
| 67 | + | |
62 | 68 | | |
63 | 69 | | |
64 | 70 | | |
| |||
Lines changed: 132 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
0 commit comments