Skip to content

fix(console): /verify-email verifies through better-auth's GET route (objectui#11633) - #11651

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-11633-verify-email-get
Oct 5, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-11633-verify-email-get

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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

claude added 3 commits October 5, 2026 07:44
The page POSTed `{ token }` to /api/v1/auth/verify-email, which better-auth
serves as GET only, so every valid token showed "Verification failed: 404"
and the account stayed unverified. It now calls GET ?token= without a
callbackURL, so the route answers its JSON receipt; only that receipt counts
as success, and a garbage or expired token still renders the error state.

Claude-Session: https://claude.ai/code/session_015W8GBu6sBiqus2L2xjMsAL
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 331 chunks) 3317.5 KB 3330.4 KB
Main entry chunk (gzip) 151.8 KB 350 KB
Entry file index-Z0YkOdO4.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 17.22KB 6.37KB
app-shell (runtime-config.js) 22.52KB 7.86KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.17KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.70KB 10.94KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.11KB 7.97KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.28KB 2.60KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.50KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 574.98KB 137.97KB
core (index.js) 10.00KB 3.96KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 232.57KB 64.51KB
fields (index.js) 262.75KB 66.62KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.24KB 2.27KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.35KB 12.88KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 35.66KB 9.49KB
i18n (useSafeTranslation.js) 7.14KB 2.92KB
layout (index.js) 39.47KB 11.25KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.86KB 5.00KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.52KB 2.26KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.04KB 3.92KB
plugin-calendar (index.js) 53.17KB 15.46KB
plugin-charts (index.js) 84.26KB 23.05KB
plugin-chatbot (index.js) 198.22KB 46.97KB
plugin-dashboard (index.js) 143.58KB 38.81KB
plugin-designer (index.js) 231.41KB 48.84KB
plugin-detail (index.js) 247.21KB 65.03KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 177.09KB 45.89KB
plugin-gantt (index.js) 179.16KB 45.06KB
plugin-grid (index.js) 235.92KB 64.87KB
plugin-kanban (index.js) 50.06KB 15.74KB
plugin-list (index.js) 116.72KB 29.10KB
plugin-map (index.js) 25.60KB 8.62KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 44.12KB 12.29KB
plugin-timeline (index.js) 38.80KB 11.71KB
plugin-tree (index.js) 14.51KB 5.15KB
plugin-view (index.js) 90.23KB 22.73KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.81KB 3.58KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 120.63KB 39.56KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.31KB 2.07KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.50KB 1.99KB
sdui-parser (codegen.js) 9.45KB 3.76KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 7.30KB 3.12KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 23.87KB 7.83KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 4.12KB 1.61KB
types (authoring-nodes.js) 0.20KB 0.19KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (cloud.js) 0.20KB 0.18KB
types (complex.js) 4.44KB 2.07KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (dashboard-widget-layout.js) 2.06KB 0.96KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 1.13KB 0.65KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 5.78KB 2.70KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (node-slots.js) 7.18KB 2.34KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 4.99KB 1.96KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 19.93KB 7.25KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

ACCEPT: PR objectui#11651, head e428840. It lands when every check on this head is green

domain:ui execution seat 1 @ objectui · session_015W8GBu6sBiqus2L2xjMsAL (os-steve) · 2026-10-05T08:51Z. Reviewed against GitHub and origin/main, not against the report's prose (report 5991187522).

  • Shape. The PR is a draft against main (base 0baf86f), and its assignee is os-steve. Its first line is Fixes #11633, the only line with a closing keyword next to an issue number. Clause-②: no is on its own line. The diff is 3 files, +164/−17, inside the claim's surface: VerifyEmailPage.tsx, one new pin file and the changeset. No path is governed.
  • The resumption. The first run was lost to the container restart. This run reviewed its two pushed commits (d1a181c, the fix; 92052a8, a merge) and kept them unchanged. It added one merge of origin/main (0baf86f), with no force-push, and re-ran every reading on e428840.
  • The ruling, as written. The page calls the existing GET /api/v1/auth/verify-email?token=…, with the token encoded through URLSearchParams and no callbackURL. No server route is added.
  • Success is told honestly. Without callbackURL the route answers JSON. The dev measured the whole fetch matrix: with callbackURL, a followed redirect lands on 200 text/html for valid, expired and garbage tokens alike, and redirect: 'manual' gives status 0 for all three. So only the no-callbackURL shape tells success from failure. The page counts as success only res.ok together with status === true. A 401 shows the server's reason, and a 2xx that is not the receipt falls back to the existing localized auth.verifyEmail.errorDescription. No i18n key is added.
  • Tests. The new pin has 4 cases: a valid token via GET with the token in the query and no body; a garbage token; an expired token; a 2xx HTML answer is not success. The pre-fix page turns 3 red. Removing only the receipt check turns exactly the HTML case red. apps/console/src/pages/auth/ is green (9 files, 54 tests).
  • Live, both directions (objectstack main 27991556, the showcase with open audience posture). Before: a valid token showed "Verification failed: 404", and sign-in stayed 403 EMAIL_NOT_VERIFIED. After: "Email verified", and sign-in returns emailVerified: true. A garbage token shows "Invalid token" and an expired one "Token expired" (both 401), and the expired user stays unverified.
  • Changeset, sentence by sentence. It is @object-ui/console: patch. The 404 mechanism, the GET call without callbackURL, the receipt rule, the 401 and non-receipt error paths, "states, copy and links unchanged" and the Clause-②: no line all match the diff.

Left as noted, not filed (pre-existing or dormant, with no reach; all are in the PR's Acceptance notes):

  • @objectstack/client 17.6.0's auth.verifyEmail throws on a relative baseUrl. No console code calls it.
  • The dev build fires the verify call more than once per visit (StrictMode, and the t dependency).
  • The error reason is the server's English text in every locale.

Landing: on all-green checks on this head, ready, then auto-merge into the merge queue.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 5, 2026 09:03
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 5, 2026 09:03
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 5, 2026
Merged via the queue into main with commit 8057a8b Oct 5, 2026
45 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-11633-verify-email-get branch October 5, 2026 09:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

console(auth): /verify-email POSTs the token, but better-auth serves verify-email as GET only — every valid token shows "Verification failed: 404"

2 participants