Skip to content

Add invite-gated signup and email verification - #615

Merged
kody-bot merged 3 commits into
mainfrom
cursor/bc-cc080c2a-3a4d-4e51-a21d-c4d1b53a808c-5a9c
Jul 5, 2026
Merged

kody-bot merged 3 commits into
mainfrom
cursor/bc-cc080c2a-3a4d-4e51-a21d-c4d1b53a808c-5a9c

Conversation

@kentcdodds

@kentcdodds kentcdodds commented Jul 5, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Adds operator-managed invite codes for production signup, with atomic use-count consumption and admin create/list/revoke UI.
  • Adds an admin-created-user flow on the same admin surface: create a pre-verified locked account, generate a 7-day password setup link, and display it for manual emailing without sending email automatically.
  • Adds account email verification tokens, /verify-email, session/account UI state, outbound email blocking for unverified accounts, and account-deletion cleanup for verification tokens.
  • Seeds direct test/dev fixtures as verified, adds migration 0046, updates auth/RBAC docs, and covers invite/verification/admin-created-user flows with unit + Playwright tests.

Validation

  • npm run validate ✅
  • Manual invite signup + email verification walkthrough ✅
  • Manual admin-created-user setup-link walkthrough ✅

invite_signup_email_verification_walkthrough.mp4

admin_created_user_setup_walkthrough.mp4

System recap — extends existing primitives (medium risk)

Mode: recap · Base: main @ 7019c1d · Head: 59c1681

Classification: extends — this PR changes auth/session behavior and extends the D1 app schema; no new system primitive id was added.

Primitives touched

Primitive Group Impact
app-sessions auth extends — signup now supports invites, admin-created password setup links, and email verification state in sessions
rbac auth composes — admin role gates invite and direct-user creation routes
d1-app-db storage extends — adds invites, email_verifications, users.email_verified_at, and users.plan; reuses password_resets for setup links
mcp-server surfaces composes — email capabilities use existing caller identity through the outbound email gate; admin-user creation service is reusable by future MCP admin actions

System map

flowchart LR
	appSessions["app-sessions"]:::extended
	rbac["rbac"]:::touched
	d1["d1-app-db"]:::extended
	mcpServer["mcp-server"]:::touched
	appSessions --> d1
	rbac --> appSessions
	mcpServer --> appSessions
	classDef touched fill:#1a7f37,color:#fff
	classDef extended fill:#9a6700,color:#fff
	classDef added fill:#cf222e,color:#fff
	classDef untouched fill:#57606a,color:#fff
Loading

Change flow

sequenceDiagram
	actor Admin
	actor User
	participant App as Browser app
	participant Svc as Admin/user services
	participant DB as D1 app DB
	participant Email as Cloudflare Email helper
	Admin->>App: Create invite in /admin/invites
	App->>DB: Insert invite row
	User->>App: Signup with invite code
	App->>DB: Conditional UPDATE consumes invite
	App->>DB: Create user + verification token
	App->>Email: Send verification link
	User->>App: Open /verify-email?token=...
	App->>DB: Set users.email_verified_at
	Admin->>App: Create user by email
	App->>Svc: adminCreateUserWithPasswordSetup
	Svc->>DB: Create verified locked user + password_resets token
	App-->>Admin: Display setup link for manual email
	User->>App: Open setup link and set password
	User->>App: Send outbound email
	App->>DB: Check email_verified_at before provider send
Loading

Before / after

Area Before After
Production signup Hardcoded 403 Valid, unexpired, unrevoked invite required
Non-production signup Open Open; consumes invite when one is provided
Admin onboarding Role management only Invite management + create verified locked user with setup link
Account email state Not tracked users.email_verified_at, verification tokens, UI state
Outbound email Sender identity only Sender identity + verified account email

Invariants

  • Per-user data remains scoped by authenticated userId; invite and direct-user creation are operator account metadata/actions behind the existing RBAC admin role.
  • Signup has no user context yet, so invite lookup is intentionally code-scoped and only creates user-scoped state after successful validation.
  • Admin-created users receive only the default user role and a no-usable-password sentinel until the displayed setup link is used.
  • Account deletion clears email_verifications.user_id alongside other integer user-id auth tables.
Open in Web Open in Cursor 

Summary by CodeRabbit

  • New Features
    • Added invite-based signups in production, including an admin invites flow to create/revoke invite codes.
    • Added email verification after signup, with a dedicated verification screen and post-signup guidance.
    • Updated account/profile and session to display whether the email is verified.
  • Bug Fixes
    • Outbound email sending is blocked until the account email is verified.
  • Tests
    • Added end-to-end coverage for admin invite signup and email verification, plus supporting E2E/test fixture updates.

@coderabbitai

coderabbitai Bot commented Jul 5, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds invite-gated signup and email verification, plus the supporting admin invite management, session propagation, outbound email gating, tests, and docs. It also wires the new routes into the worker client and server routers.

Changes

Invite Signup and Email Verification

Layer / File(s) Summary
Schema and loader types
packages/worker/src/db.ts, packages/worker/migrations/0046-invites-email-verification.sql, packages/worker/src/app/loader-data.ts, packages/worker/src/app/session-info.ts, packages/worker/src/app/request-auth-cache.ts
Adds invite and email-verification tables, email_verified_at columns, and loader/session type shapes for invites and verification state.
Invites and verification core
packages/worker/src/app/invites.ts, packages/worker/src/app/email-verification.ts, packages/worker/src/app/password-reset-tokens.ts, packages/worker/src/app/admin-user-creation.ts, packages/worker/src/app/invites.node.test.ts, packages/worker/src/app/admin-user-creation.node.test.ts
Implements invite code handling, verification token generation/storage/lookup, password setup token helpers, and unit coverage for invite consumption and admin user creation.
Signup and verify-email handlers
packages/worker/src/app/handlers/auth.ts, packages/worker/src/app/handlers/verify-email.ts, packages/worker/src/app/router.ts, packages/worker/src/app/routes.ts, packages/worker/src/app/handlers/auth-handler.node.test.ts, packages/worker/src/app/handlers/verify-email.ts, packages/worker/src/app/handlers/session-handler.node.test.ts, packages/worker/src/app/handlers/account-profile.node.test.ts
Updates signup to consume invite codes and create verification records, adds verify-email handling, and updates handler tests.
Admin invites backend and UI
packages/worker/src/app/admin-invites-data.ts, packages/worker/src/app/handlers/admin-invites.ts, packages/worker/client/routes/admin-invites.tsx, packages/worker/client/routes/admin-users.tsx, packages/worker/client/routes/index.tsx, packages/worker/src/app/router.ts, packages/worker/src/app/routes.ts, packages/worker/src/app/loader-data.ts, packages/worker/src/app/admin-user-creation.ts, packages/worker/src/app/admin-user-creation.node.test.ts
Adds admin invite data loading, API/page handlers for create/revoke/user setup, the admin invites client page, and routing.
Session and account client state
packages/worker/src/app/authenticated-user.ts, packages/worker/src/app/request-auth-cache.ts, packages/worker/src/app/session-info.ts, packages/worker/src/app/account-profile-data.ts, packages/worker/client/session.ts, packages/worker/client/routes/account.tsx, packages/worker/client/routes/login.tsx, packages/worker/client/routes/verify-email.tsx, packages/worker/client/navigation-data.node.test.ts, packages/worker/client/routes/index.tsx, packages/worker/src/app/ssr-render.node.test.ts, packages/worker/src/app/handlers/account-profile.node.test.ts, packages/worker/src/app/handlers/session-handler.node.test.ts
Propagates emailVerified through auth/session/account payloads and updates account/login/verify-email client rendering.
Outbound email verification gating
packages/worker/src/email/outbound.ts, packages/worker/src/email/outbound.workers.test.ts, packages/worker/src/mcp/capabilities/email/email-send.ts, packages/worker/src/mcp/capabilities/email/email-reply.ts, packages/worker/src/mcp/capabilities/email/email-reply.node.test.ts, packages/worker/src/email/inbound.workers.test.ts
Requires a verified account email before outbound email sending and passes accountEmail through MCP and worker flows.
E2E tests, seeding, and docs
e2e/*, tools/seed-test-data.ts, docs/contributing/*
Adds invite/signup/verification E2E coverage, E2E DB helpers, seed SQL updates, and doc updates for the new auth model.

Estimated code review effort: 4 (Complex) | ~75 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: invite-gated signup plus email verification.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cursor/bc-cc080c2a-3a4d-4e51-a21d-c4d1b53a808c-5a9c

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@kentcdodds
kentcdodds marked this pull request as ready for review July 5, 2026 16:33
@github-actions

github-actions Bot commented Jul 5, 2026 •

Copy link
Copy Markdown
Contributor

🔎 Preview deployed: https://kody-pr-615.kentcdodds.workers.dev

Worker: kody-pr-615
D1: kody-pr-615-db
KV: kody-pr-615-oauth-kv

Mocks:

Comment thread packages/worker/src/app/email-verification.ts
Comment thread packages/worker/src/app/handlers/auth.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (4)
packages/worker/src/email/outbound.workers.test.ts (1)

17-41: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate test-schema helpers across files.

ensureAccountVerificationTestSchema/seedVerifiedAccount here largely duplicate the same table-creation/seeding logic added in packages/worker/src/email/inbound.workers.test.ts (lines 23-57), but with a different signature (seedVerifiedAccount(email) vs. seedVerifiedAccount({db, email, username})). Since both already diverge, future schema changes (e.g., additional users columns) risk being applied in only one place. Consider extracting a shared test-schema helper (similar to ensureEmailTestSchema) that both files import.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/src/email/outbound.workers.test.ts` around lines 17 - 41, The
test helpers for verified accounts are duplicated between the outbound and
inbound worker tests, so schema changes could drift. Extract the shared
`ensureAccountVerificationTestSchema` and `seedVerifiedAccount` logic into a
common test utility (similar to `ensureEmailTestSchema`) and update both
`packages/worker/src/email/outbound.workers.test.ts` and the corresponding
inbound test to import and use the shared helper, keeping the existing call
sites and signatures consistent where needed.
packages/worker/client/routes/login.tsx (1)

228-239: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Placeholder copy uses internal jargon.

"Required for production launch cohorts" uses internal terminology ("cohorts") that may confuse end users signing up. Consider simpler copy, e.g. "Required if invite-only signup is enabled."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/client/routes/login.tsx` around lines 228 - 239, The signup
invite code field in login.tsx uses internal jargon in the input placeholder, so
update the copy in the isSignup block to user-friendly wording. Keep the field
name and behavior unchanged, but replace the placeholder text in the inviteCode
input with simpler language like “Required if invite-only signup is enabled.”
packages/worker/client/routes/account.tsx (1)

246-261: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

No resend-verification affordance.

The unverified banner only tells users to check their inbox, with no way to request a new verification email if the original is lost or expired. Worth confirming this is intentionally deferred (e.g., handled elsewhere) rather than an oversight.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/client/routes/account.tsx` around lines 246 - 261, The
unverified email banner in the account route only displays status and has no
resend-verification action, so add a clear affordance to request a new
verification email or confirm that resend is handled elsewhere. Update the
conditional block in the account page component around the email verification
section to include a resend control tied to the existing verification flow,
using the relevant account/email verification handlers in this route. If the
resend action is intentionally omitted here, add a brief note in the component
to make that decision explicit.
packages/worker/src/app/invites.node.test.ts (1)

6-46: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Mock DB doesn't actually interleave, so this doesn't test a real race.

run() has no internal await, so Promise.all executes both consumeInviteCode calls' DB writes fully synchronously and sequentially (JS never yields mid-update). The test currently passes because of execution order, not because atomicity was verified under contention — it would pass even if the implementation weren't atomic. Consider inserting an artificial await Promise.resolve() (or a microtask delay) inside the mock's run() before mutating state, so both calls genuinely race for the record.

Also applies to: 100-113

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/src/app/invites.node.test.ts` around lines 6 - 46, The invite
DB mock in createInviteDb is not yielding during updates, so the Promise.all
test in invs.node.test.ts does not simulate contention. Update the mocked
prepare(...).bind(...).run() path to include an async yield before mutating
records so concurrent consumeInviteCode calls can interleave, and keep the
atomicity assertion meaningful under real race conditions. Use createInviteDb,
prepare, bind, and run as the key locations to adjust the mock behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/worker/client/routes/admin-invites.tsx`:
- Around line 138-152: The invite mutation flow in admin-invites.tsx only parses
the JSON response and falls back to a generic error, so expired or forbidden
sessions are not handled like the GET paths. Update the mutation handling around
the fetch/readJson logic to inspect response.status for 401 and 403 before
relying on payload.ok, and route those cases the same way the existing
loader/query code does. Use the existing invite mutation handlers in
adminInvitesApiPath as the place to add this status-based redirect or permission
error behavior, then keep the generic fallback only for other failures.
- Around line 194-205: Clear the failed-load latch when revisiting the route so
a transient failure does not permanently block reloads. In the admin invites
route logic, update the state handling around `needsLoad`/`lastFailedHref` in
`admin-invites.tsx` so that when the visible `currentHref` changes back to this
route, the failed-href guard is reset like the admin users route does. Keep the
existing load gating in place, but ensure `needsLoad` can become true again
after a route revisit instead of staying false forever for `/admin/invites`.

In `@packages/worker/migrations/0046-invites-email-verification.sql`:
- Around line 32-33: The backfill in the migration currently updates every row
in users, which marks all legacy accounts as verified. Update the migration to
scope the email_verified_at backfill to only the intended launch cohort, or if
the global grandfathering is intentional, add an explicit note/documentation in
the migration explaining that behavior. Use the users backfill statement as the
place to adjust the filter.

In `@packages/worker/src/app/admin-invites-data.ts`:
- Around line 15-31: `loadAdminInvitesData` currently reads all invites and
creator emails without any user scope, so update this read path to be explicitly
scoped by actor identity or clearly marked as an operator-only exception. Pass
`userId`/actor context into `loadAdminInvitesData` and add the appropriate
`WHERE i.created_by = ?` filter if invites are user-owned; otherwise remove or
avoid the `users u` join for email data and document the global admin-only
behavior. Keep the fix centered on `loadAdminInvitesData` and the invite query
so all signed-in data access remains isolated.

In `@packages/worker/src/app/email-verification.ts`:
- Around line 155-181: The `isAccountEmailVerified` fallback for `stableUserId`
currently scans the entire `users` table and recomputes
`createStableUserIdFromEmail` for every row, which is both unscoped and
inefficient. Update this flow so the `users` record stores a persisted, indexed
stable user id at creation time, then change `isAccountEmailVerified` to query
that column directly with a scoped lookup instead of calling
`prepare(...).all()` and looping through every row.

In `@packages/worker/src/app/handlers/admin-invites.ts`:
- Around line 91-94: The invite creation flow in admin-invites currently
defaults invalid maxUses to 1, which can silently create unintended one-use
invites. Update the handling around readPositiveInt in the invite handlers
(including the create/update paths referenced by admin-invites) to validate that
maxUses is a safe positive integer and return a 400 response when it is missing,
malformed, or non-positive instead of applying a fallback. Make sure the change
is applied consistently anywhere maxUses is parsed in the invite request
handlers.

In `@packages/worker/src/app/handlers/auth.ts`:
- Around line 182-220: The signup flow in auth.ts currently computes
createPasswordHash before validating the invite, which wastes expensive CPU on
rejected requests. Move the invite gating logic in the signup handler so the
inviteRequired/normalizeInviteCode check and consumeInviteCode run before
createPasswordHash, and only call createPasswordHash after a valid invite has
been accepted and consumed. Keep releaseConsumedInvite handling tied to the
successful invite path in the same handler.

In `@packages/worker/src/app/handlers/verify-email.ts`:
- Around line 28-55: The verify-email flow in verifyEmailToken currently
consumes the token on the first GET, which makes GET /verify-email
non-idempotent and vulnerable to mailbox scanners. Update the verify-email
handler and the verifyEmailToken path so a GET only validates/marks completion
without invalidating the token, or move the actual consumption to a
user-initiated confirmation POST; keep the token usable until expiry and
preserve the existing logAuditEvent and renderAppPage behavior in
verify-email.ts.
- Around line 34-42: The email_verify audit event writes are currently
fire-and-forget, so they can be lost when the handler returns. Update the
verify-email flow to thread the ExecutionContext through the app handler path
and use ctx.waitUntil() for both the success and failure logAuditEvent calls.
Make sure the changes are applied in the verify-email handler and the
renderAppPage app-path wiring so the audit writes stay alive after the response
is sent.

---

Nitpick comments:
In `@packages/worker/client/routes/account.tsx`:
- Around line 246-261: The unverified email banner in the account route only
displays status and has no resend-verification action, so add a clear affordance
to request a new verification email or confirm that resend is handled elsewhere.
Update the conditional block in the account page component around the email
verification section to include a resend control tied to the existing
verification flow, using the relevant account/email verification handlers in
this route. If the resend action is intentionally omitted here, add a brief note
in the component to make that decision explicit.

In `@packages/worker/client/routes/login.tsx`:
- Around line 228-239: The signup invite code field in login.tsx uses internal
jargon in the input placeholder, so update the copy in the isSignup block to
user-friendly wording. Keep the field name and behavior unchanged, but replace
the placeholder text in the inviteCode input with simpler language like
“Required if invite-only signup is enabled.”

In `@packages/worker/src/app/invites.node.test.ts`:
- Around line 6-46: The invite DB mock in createInviteDb is not yielding during
updates, so the Promise.all test in invs.node.test.ts does not simulate
contention. Update the mocked prepare(...).bind(...).run() path to include an
async yield before mutating records so concurrent consumeInviteCode calls can
interleave, and keep the atomicity assertion meaningful under real race
conditions. Use createInviteDb, prepare, bind, and run as the key locations to
adjust the mock behavior.

In `@packages/worker/src/email/outbound.workers.test.ts`:
- Around line 17-41: The test helpers for verified accounts are duplicated
between the outbound and inbound worker tests, so schema changes could drift.
Extract the shared `ensureAccountVerificationTestSchema` and
`seedVerifiedAccount` logic into a common test utility (similar to
`ensureEmailTestSchema`) and update both
`packages/worker/src/email/outbound.workers.test.ts` and the corresponding
inbound test to import and use the shared helper, keeping the existing call
sites and signatures consistent where needed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1207d4d3-9f0b-46bd-87d6-1b0fa9ee599b

📥 Commits

Reviewing files that changed from the base of the PR and between e234f85 and 65a768a.

📒 Files selected for processing (43)
  • docs/contributing/architecture/authentication.md
  • docs/contributing/architecture/authorization.md
  • docs/contributing/architecture/primitives.yaml
  • docs/contributing/project-intent.md
  • docs/contributing/setup.md
  • e2e/auth-test-user.ts
  • e2e/d1-utils.ts
  • e2e/invite-signup-verification.spec.ts
  • packages/worker/client/navigation-data.node.test.ts
  • packages/worker/client/routes/account.tsx
  • packages/worker/client/routes/admin-invites.tsx
  • packages/worker/client/routes/admin-users.tsx
  • packages/worker/client/routes/index.tsx
  • packages/worker/client/routes/login.tsx
  • packages/worker/client/routes/verify-email.tsx
  • packages/worker/client/session.ts
  • packages/worker/migrations/0046-invites-email-verification.sql
  • packages/worker/src/app/account-profile-data.ts
  • packages/worker/src/app/admin-invites-data.ts
  • packages/worker/src/app/authenticated-user.ts
  • packages/worker/src/app/email-verification.ts
  • packages/worker/src/app/handlers/account-profile.node.test.ts
  • packages/worker/src/app/handlers/admin-invites.ts
  • packages/worker/src/app/handlers/auth-handler.node.test.ts
  • packages/worker/src/app/handlers/auth.ts
  • packages/worker/src/app/handlers/session-handler.node.test.ts
  • packages/worker/src/app/handlers/verify-email.ts
  • packages/worker/src/app/invites.node.test.ts
  • packages/worker/src/app/invites.ts
  • packages/worker/src/app/loader-data.ts
  • packages/worker/src/app/request-auth-cache.ts
  • packages/worker/src/app/router.ts
  • packages/worker/src/app/routes.ts
  • packages/worker/src/app/session-info.ts
  • packages/worker/src/app/ssr-render.node.test.ts
  • packages/worker/src/db.ts
  • packages/worker/src/email/inbound.workers.test.ts
  • packages/worker/src/email/outbound.ts
  • packages/worker/src/email/outbound.workers.test.ts
  • packages/worker/src/mcp/capabilities/email/email-reply.node.test.ts
  • packages/worker/src/mcp/capabilities/email/email-reply.ts
  • packages/worker/src/mcp/capabilities/email/email-send.ts
  • tools/seed-test-data.ts

Comment on lines +138 to +152
const response = await fetch(adminInvitesApiPath, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(body),
})
const payload = await readJson<
AdminInvitesLoaderData & { ok?: boolean; error?: string }
>(response)
if (!response.ok || !payload?.ok) {
throw new Error(payload?.error ?? 'Unable to update invites.')
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Handle expired/forbidden sessions on invite mutations.

The GET paths redirect/report 401/403, but create/revoke tries to parse the response and surfaces a generic “Unable to update invites.” Add the same status handling here so admins are sent back to login or shown the permission error.

Proposed fix
 			const response = await fetch(adminInvitesApiPath, {
 				method: 'POST',
 				headers: {
 					Accept: 'application/json',
 					'Content-Type': 'application/json',
 				},
 				credentials: 'include',
 				body: JSON.stringify(body),
 			})
+			if (response.status === 401) {
+				window.location.assign('/login')
+				return
+			}
+			if (response.status === 403) {
+				throw new Error('You do not have permission to manage invites.')
+			}
 			const payload = await readJson<
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const response = await fetch(adminInvitesApiPath, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(body),
})
const payload = await readJson<
AdminInvitesLoaderData & { ok?: boolean; error?: string }
>(response)
if (!response.ok || !payload?.ok) {
throw new Error(payload?.error ?? 'Unable to update invites.')
}
const response = await fetch(adminInvitesApiPath, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(body),
})
if (response.status === 401) {
window.location.assign('/login')
return
}
if (response.status === 403) {
throw new Error('You do not have permission to manage invites.')
}
const payload = await readJson<
AdminInvitesLoaderData & { ok?: boolean; error?: string }
>(response)
if (!response.ok || !payload?.ok) {
throw new Error(payload?.error ?? 'Unable to update invites.')
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/client/routes/admin-invites.tsx` around lines 138 - 152, The
invite mutation flow in admin-invites.tsx only parses the JSON response and
falls back to a generic error, so expired or forbidden sessions are not handled
like the GET paths. Update the mutation handling around the fetch/readJson logic
to inspect response.status for 401 and 403 before relying on payload.ok, and
route those cases the same way the existing loader/query code does. Use the
existing invite mutation handlers in adminInvitesApiPath as the place to add
this status-based redirect or permission error behavior, then keep the generic
fallback only for other failures.

Comment thread packages/worker/client/routes/admin-invites.tsx
Comment on lines +32 to +33
UPDATE users
SET email_verified_at = COALESCE(email_verified_at, CURRENT_TIMESTAMP);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file list =="
git ls-files 'packages/worker/migrations/*' | sed -n '1,120p'

echo
echo "== target migration =="
cat -n packages/worker/migrations/0046-invites-email-verification.sql

echo
echo "== nearby migrations mentioning email verification =="
rg -n "email_verified_at|invites-email-verification|verified" packages/worker/migrations packages -g '!**/node_modules/**' | sed -n '1,200p'

Repository: kentcdodds/kody

Length of output: 12974


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== email verification app logic =="
cat -n packages/worker/src/app/email-verification.ts | sed -n '1,260p'

echo
echo "== outbound email gate =="
cat -n packages/worker/src/email/outbound.ts | sed -n '140,240p'

echo
echo "== account UI text around email verification =="
cat -n packages/worker/client/routes/account.tsx | sed -n '240,320p'

echo
echo "== search for backfill / rollout notes =="
rg -n "backfill|email_verified_at|email verification|verified before sending|invite" packages/worker -g '!**/node_modules/**' | sed -n '1,240p'

Repository: kentcdodds/kody

Length of output: 31564


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== auth signup flow around email verification =="
cat -n packages/worker/src/app/handlers/auth.ts | sed -n '240,380p'

echo
echo "== invite/admin text that may explain the rollout =="
cat -n packages/worker/client/routes/admin-invites.tsx | sed -n '200,260p'

echo
echo "== search for grandfathering / launch cohort language =="
rg -n "grandfather|launch cohort|launch-cohort|trusted|existing accounts|existing users|production signup|verify your email|email stays on the account" packages/worker -g '!**/node_modules/**' | sed -n '1,220p'

Repository: kentcdodds/kody

Length of output: 13071


Scope the email_verified_at backfill
This marks every existing account as verified, so the outbound-email gate will trust legacy users too. If that grandfathering is intentional, document it; otherwise limit the migration to the intended launch cohort.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/migrations/0046-invites-email-verification.sql` around lines
32 - 33, The backfill in the migration currently updates every row in users,
which marks all legacy accounts as verified. Update the migration to scope the
email_verified_at backfill to only the intended launch cohort, or if the global
grandfathering is intentional, add an explicit note/documentation in the
migration explaining that behavior. Use the users backfill statement as the
place to adjust the filter.

Comment on lines +15 to +31
export async function loadAdminInvitesData(
env: Env,
): Promise<AdminInvitesLoaderData> {
const result = await env.APP_DB.prepare(
`SELECT i.code,
i.created_by,
u.email AS created_by_email,
i.note,
i.max_uses,
i.use_count,
i.expires_at,
i.revoked_at,
i.created_at
FROM invites i
LEFT JOIN users u ON u.id = i.created_by
ORDER BY i.created_at DESC, i.code ASC
LIMIT 200`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Scope the invite read path by userId or make the operator-shared exception explicit.

loadAdminInvitesData reads every invite and creator email without an actor/userId parameter. If invite metadata is user-owned, pass the actor user id through and add a WHERE i.created_by = ? scope; if it is intentionally global operator state, avoid joining user-owned email data here or document the explicit exception. As per coding guidelines, “All read/write paths must be scoped by userId so every signed-in user’s data remains isolated.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/src/app/admin-invites-data.ts` around lines 15 - 31,
`loadAdminInvitesData` currently reads all invites and creator emails without
any user scope, so update this read path to be explicitly scoped by actor
identity or clearly marked as an operator-only exception. Pass `userId`/actor
context into `loadAdminInvitesData` and add the appropriate `WHERE i.created_by
= ?` filter if invites are user-owned; otherwise remove or avoid the `users u`
join for email data and document the global admin-only behavior. Keep the fix
centered on `loadAdminInvitesData` and the invite query so all signed-in data
access remains isolated.

Source: Coding guidelines

Comment on lines +155 to +181
export async function isAccountEmailVerified(input: {
db: D1Database
email?: string | null
stableUserId?: string | null
}) {
const normalizedEmail =
typeof input.email === 'string' ? normalizeEmail(input.email) : ''
if (normalizedEmail) {
const row = await input.db
.prepare(`SELECT email_verified_at FROM users WHERE email = ?`)
.bind(normalizedEmail)
.first<{ email_verified_at: string | null }>()
return Boolean(row?.email_verified_at)
}

const stableUserId = input.stableUserId?.trim()
if (!stableUserId) return false
const rows = await input.db
.prepare(`SELECT email, email_verified_at FROM users`)
.all<{ email: string; email_verified_at: string | null }>()
for (const row of rows.results ?? []) {
if ((await createStableUserIdFromEmail(row.email)) === stableUserId) {
return Boolean(row.email_verified_at)
}
}
return false
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Full users-table scan for stableUserId verification lookups.

When falling back to stableUserId, this loads every row from users and hashes each email until a match is found — an unscoped, O(n) read that recomputes createStableUserIdFromEmail per row. If this check runs on the outbound-email hot path per MCP send, cost grows linearly with user count.

Consider persisting the stable user id as an indexed column at account creation so this lookup becomes a direct, scoped query instead of a full-table scan.

As per coding guidelines, "All read/write paths must be scoped by userId so every signed-in user's data remains isolated," which this unscoped table scan does not satisfy.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/src/app/email-verification.ts` around lines 155 - 181, The
`isAccountEmailVerified` fallback for `stableUserId` currently scans the entire
`users` table and recomputes `createStableUserIdFromEmail` for every row, which
is both unscoped and inefficient. Update this flow so the `users` record stores
a persisted, indexed stable user id at creation time, then change
`isAccountEmailVerified` to query that column directly with a scoped lookup
instead of calling `prepare(...).all()` and looping through every row.

Source: Coding guidelines

Comment on lines +91 to +94
const code = readString(input.body, 'code')
const note = readString(input.body, 'note') ?? ''
const maxUses = readPositiveInt(readString(input.body, 'maxUses'), 1)
const expiresAt = readExpiresAt(input.body)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reject invalid maxUses instead of silently creating a one-use invite.

Invalid values currently fall back to 1, so a bad admin/API request can create a valid invite with unintended capacity. Return 400 unless the value is a safe positive integer.

Proposed fix
-	const maxUses = readPositiveInt(readString(input.body, 'maxUses'), 1)
+	const maxUses = readPositiveInt(readString(input.body, 'maxUses'))
+	if (maxUses == null) {
+		return jsonResponse(
+			{ ok: false, error: 'Max uses must be a positive integer.' },
+			400,
+		)
+	}
...
-function readPositiveInt(value: string | null, fallback: number) {
-	if (!value) return fallback
-	const parsed = Number.parseInt(value, 10)
-	if (!Number.isFinite(parsed) || parsed < 1) {
-		return fallback
-	}
+function readPositiveInt(value: string | null) {
+	if (!value || !/^[1-9]\d*$/.test(value)) return null
+	const parsed = Number(value)
+	if (!Number.isSafeInteger(parsed)) return null
 	return parsed
 }

Also applies to: 184-190

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/src/app/handlers/admin-invites.ts` around lines 91 - 94, The
invite creation flow in admin-invites currently defaults invalid maxUses to 1,
which can silently create unintended one-use invites. Update the handling around
readPositiveInt in the invite handlers (including the create/update paths
referenced by admin-invites) to validate that maxUses is a safe positive integer
and return a 400 response when it is missing, malformed, or non-positive instead
of applying a fallback. Make sure the change is applied consistently anywhere
maxUses is parsed in the invite request handlers.

Comment on lines 182 to +220
const passwordHash = await createPasswordHash(normalizedPassword)
let consumedInviteCode: string | null = null
async function releaseConsumedInvite() {
if (!consumedInviteCode) return
await releaseInviteUse({
db: appEnv.APP_DB,
code: consumedInviteCode,
})
consumedInviteCode = null
}

const inviteRequired = isInviteRequiredForSignup(appEnv)
if (inviteRequired || normalizeInviteCode(inviteCode)) {
const inviteResult = await consumeInviteCode({
db: appEnv.APP_DB,
code: inviteCode,
})
if (!inviteResult.ok) {
void logAuditEvent({
category: 'auth',
action: 'signup',
result: 'failure',
email: normalizedEmail,
ip: requestIp,
path: url.pathname,
reason: `invite_${inviteResult.reason}`,
})
return Response.json(
{ error: getInviteFailureMessage(inviteResult.reason) },
{
status:
inviteResult.reason === 'missing' && inviteRequired
? 400
: 403,
},
)
}
consumedInviteCode = inviteResult.invite.code
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Validate the invite code before hashing the password.

Invite validation (cheap DB read/update) currently runs after createPasswordHash, which is deliberately expensive (PBKDF2, ~100k iterations per the dummy hash format). Since invite gating exists precisely to restrict who can sign up, an attacker can force full password-hash computation on every rejected signup attempt just by omitting/spoiling the invite code — wasted CPU on an unauthenticated endpoint. Move the invite check above the password hash so invalid/missing invites are rejected before the expensive hash runs.

⚡ Proposed reorder
-				const passwordHash = await createPasswordHash(normalizedPassword)
 				let consumedInviteCode: string | null = null
 				async function releaseConsumedInvite() {
 					if (!consumedInviteCode) return
 					await releaseInviteUse({
 						db: appEnv.APP_DB,
 						code: consumedInviteCode,
 					})
 					consumedInviteCode = null
 				}

 				const inviteRequired = isInviteRequiredForSignup(appEnv)
 				if (inviteRequired || normalizeInviteCode(inviteCode)) {
 					const inviteResult = await consumeInviteCode({
 						db: appEnv.APP_DB,
 						code: inviteCode,
 					})
 					if (!inviteResult.ok) {
 						void logAuditEvent({
 							category: 'auth',
 							action: 'signup',
 							result: 'failure',
 							email: normalizedEmail,
 							ip: requestIp,
 							path: url.pathname,
 							reason: `invite_${inviteResult.reason}`,
 						})
 						return Response.json(
 							{ error: getInviteFailureMessage(inviteResult.reason) },
 							{
 								status:
 									inviteResult.reason === 'missing' && inviteRequired
 										? 400
 										: 403,
 							},
 						)
 					}
 					consumedInviteCode = inviteResult.invite.code
 				}
+				const passwordHash = await createPasswordHash(normalizedPassword)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const passwordHash = await createPasswordHash(normalizedPassword)
let consumedInviteCode: string | null = null
async function releaseConsumedInvite() {
if (!consumedInviteCode) return
await releaseInviteUse({
db: appEnv.APP_DB,
code: consumedInviteCode,
})
consumedInviteCode = null
}
const inviteRequired = isInviteRequiredForSignup(appEnv)
if (inviteRequired || normalizeInviteCode(inviteCode)) {
const inviteResult = await consumeInviteCode({
db: appEnv.APP_DB,
code: inviteCode,
})
if (!inviteResult.ok) {
void logAuditEvent({
category: 'auth',
action: 'signup',
result: 'failure',
email: normalizedEmail,
ip: requestIp,
path: url.pathname,
reason: `invite_${inviteResult.reason}`,
})
return Response.json(
{ error: getInviteFailureMessage(inviteResult.reason) },
{
status:
inviteResult.reason === 'missing' && inviteRequired
? 400
: 403,
},
)
}
consumedInviteCode = inviteResult.invite.code
}
let consumedInviteCode: string | null = null
async function releaseConsumedInvite() {
if (!consumedInviteCode) return
await releaseInviteUse({
db: appEnv.APP_DB,
code: consumedInviteCode,
})
consumedInviteCode = null
}
const inviteRequired = isInviteRequiredForSignup(appEnv)
if (inviteRequired || normalizeInviteCode(inviteCode)) {
const inviteResult = await consumeInviteCode({
db: appEnv.APP_DB,
code: inviteCode,
})
if (!inviteResult.ok) {
void logAuditEvent({
category: 'auth',
action: 'signup',
result: 'failure',
email: normalizedEmail,
ip: requestIp,
path: url.pathname,
reason: `invite_${inviteResult.reason}`,
})
return Response.json(
{ error: getInviteFailureMessage(inviteResult.reason) },
{
status:
inviteResult.reason === 'missing' && inviteRequired
? 400
: 403,
},
)
}
consumedInviteCode = inviteResult.invite.code
}
const passwordHash = await createPasswordHash(normalizedPassword)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/src/app/handlers/auth.ts` around lines 182 - 220, The signup
flow in auth.ts currently computes createPasswordHash before validating the
invite, which wastes expensive CPU on rejected requests. Move the invite gating
logic in the signup handler so the inviteRequired/normalizeInviteCode check and
consumeInviteCode run before createPasswordHash, and only call
createPasswordHash after a valid invite has been accepted and consumed. Keep
releaseConsumedInvite handling tied to the successful invite path in the same
handler.

Comment on lines +28 to +55
const result = await verifyEmailToken({
db: env.APP_DB,
token: url.searchParams.get('token'),
})
const requestIp = getRequestIp(request) ?? undefined

if (!result.ok) {
void logAuditEvent({
category: 'auth',
action: 'email_verify',
result: 'failure',
ip: requestIp,
path: url.pathname,
reason: result.reason,
})
return renderAppPage({
request,
env,
title: 'Verify email',
status: 400,
loaderData: {
emailVerification: {
ok: false,
error: getVerifyEmailError(result.reason),
},
},
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== File list ==\n'
git ls-files 'packages/worker/src/app/handlers/verify-email.ts' 'packages/worker/src/app/lib/*email*' 'packages/worker/src/app/**/*email*'

printf '\n== Outline verify-email.ts ==\n'
ast-grep outline packages/worker/src/app/handlers/verify-email.ts --view expanded || true

printf '\n== Read verify-email.ts ==\n'
cat -n packages/worker/src/app/handlers/verify-email.ts | sed -n '1,220p'

printf '\n== Locate verifyEmailToken ==\n'
rg -n "verifyEmailToken|email_verifications|email_verified_at|getVerifyEmailError|Verification link is invalid" packages/worker/src/app -S

printf '\n== Outline email-verification source candidates ==\n'
for f in $(git ls-files 'packages/worker/src/app/**/*email*' | grep -E 'email-verification|verification'); do
  echo "--- $f ---"
  ast-grep outline "$f" --view expanded || true
done

printf '\n== Read matching source slices ==\n'
for f in $(git ls-files 'packages/worker/src/app/**/*email*' | grep -E 'email-verification|verification'); do
  echo "--- $f ---"
  wc -l "$f"
  cat -n "$f" | sed -n '1,260p'
done

Repository: kentcdodds/kody

Length of output: 5155


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Read email-verification.ts ==\n'
wc -l packages/worker/src/app/email-verification.ts
cat -n packages/worker/src/app/email-verification.ts | sed -n '1,260p'

printf '\n== Read auth-handler tests around email verification ==\n'
cat -n packages/worker/src/app/handlers/auth-handler.node.test.ts | sed -n '120,250p'

printf '\n== Search for repeated verify-email expectations ==\n'
rg -n "verify email|email_verify|Verification link|email_verifications|email_verified_at|already verified|single-use" packages/worker/src/app -S

Repository: kentcdodds/kody

Length of output: 13775


Make the email-verify link idempotent
GET /verify-email consumes the token on first hit, so mailbox scanners can complete the flow before the user opens the link; a later click then shows “Verification link is invalid.” Keep the token valid until expiry, or switch to a user-initiated POST/confirmation step.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/src/app/handlers/verify-email.ts` around lines 28 - 55, The
verify-email flow in verifyEmailToken currently consumes the token on the first
GET, which makes GET /verify-email non-idempotent and vulnerable to mailbox
scanners. Update the verify-email handler and the verifyEmailToken path so a GET
only validates/marks completion without invalidating the token, or move the
actual consumption to a user-initiated confirmation POST; keep the token usable
until expiry and preserve the existing logAuditEvent and renderAppPage behavior
in verify-email.ts.

Comment on lines +34 to +42
if (!result.ok) {
void logAuditEvent({
category: 'auth',
action: 'email_verify',
result: 'failure',
ip: requestIp,
path: url.pathname,
reason: result.reason,
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether the router/handler execution wraps actions with ctx.waitUntil
rg -n "waitUntil" --type=ts -C3
ast-grep run --pattern 'export function createRouter($$$) { $$$ }' --lang typescript packages/worker/src

Repository: kentcdodds/kody

Length of output: 153


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the handler and router/runtime glue
git ls-files 'packages/worker/src/**' | sed -n '1,200p'

echo '--- verify-email.ts ---'
wc -l packages/worker/src/app/handlers/verify-email.ts
cat -n packages/worker/src/app/handlers/verify-email.ts | sed -n '1,220p'

echo '--- waitUntil references under worker src ---'
rg -n "waitUntil|ctx\.|ExecutionContext|logAuditEvent\(" packages/worker/src -S -C 3

echo '--- router/create action wiring ---'
rg -n "createRouter|action\(|loader\(|renderAppPage|verify-email" packages/worker/src -S -C 3

Repository: kentcdodds/kody

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- app/handler.ts ---'
wc -l packages/worker/src/app/handler.ts
cat -n packages/worker/src/app/handler.ts | sed -n '1,260p'

echo '--- app/router.ts ---'
wc -l packages/worker/src/app/router.ts
cat -n packages/worker/src/app/router.ts | sed -n '1,260p'

echo '--- app/render.ts ---'
wc -l packages/worker/src/app/render.ts
cat -n packages/worker/src/app/render.ts | sed -n '1,240p'

echo '--- app/ssr-render.tsx ---'
wc -l packages/worker/src/app/ssr-render.tsx
cat -n packages/worker/src/app/ssr-render.tsx | sed -n '1,260p'

echo '--- app/audit-log.ts ---'
wc -l packages/worker/src/app/audit-log.ts
cat -n packages/worker/src/app/audit-log.ts | sed -n '1,220p'

Repository: kentcdodds/kody

Length of output: 14671


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- worker entrypoint ---'
wc -l packages/worker/src/index.ts
cat -n packages/worker/src/index.ts | sed -n '1,220p'

echo '--- request handling path ---'
rg -n "handleRequest\(|router\.fetch\(|ExecutionContext|waitUntil" packages/worker/src/index.ts packages/worker/src/app packages/worker/src -S -C 2 | sed -n '1,220p'

echo '--- verify-email test coverage ---'
rg -n "verify-email|email_verify|logAuditEvent|waitUntil" packages/worker/src/app/handlers/verify-email.* packages/worker/src/app/handlers/*.node.test.ts -S -C 2 | sed -n '1,220p'

Repository: kentcdodds/kody

Length of output: 28519


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- index.ts around app handler invocation ---'
sed -n '320,350p' packages/worker/src/index.ts | cat -n

echo '--- withCors implementation ---'
rg -n "function withCors|export function withCors" packages/worker/src -S -C 3

Repository: kentcdodds/kody

Length of output: 1595


Keep the email_verify audit writes alive. The app handler never receives ExecutionContext, so these void logAuditEvent(...) calls can be dropped once renderAppPage(...) returns. Thread ctx through this path and register both success and failure audit writes with waitUntil().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/src/app/handlers/verify-email.ts` around lines 34 - 42, The
email_verify audit event writes are currently fire-and-forget, so they can be
lost when the handler returns. Update the verify-email flow to thread the
ExecutionContext through the app handler path and use ctx.waitUntil() for both
the success and failure logAuditEvent calls. Make sure the changes are applied
in the verify-email handler and the renderAppPage app-path wiring so the audit
writes stay alive after the response is sent.

Comment thread packages/worker/client/routes/admin-invites.tsx

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@e2e/playwright-utils.ts`:
- Line 49: The shared auth rate-limit reset in clearAuthRateLimitsInE2eDatabase
is unsafe for parallel Playwright workers because it can delete keys used by
other tests. Update the e2e setup so auth rate-limit data is isolated per worker
or per test (using a worker-specific prefix/namespace), or mark the auth-related
specs as serial where they depend on shared state. Keep the fix localized around
clearAuthRateLimitsInE2eDatabase and the related Playwright e2e auth setup.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6004234c-aebe-4b6d-97cd-1361f4a533ef

📥 Commits

Reviewing files that changed from the base of the PR and between 65a768a and fbb457e.

📒 Files selected for processing (15)
  • docs/contributing/architecture/authentication.md
  • docs/contributing/architecture/authorization.md
  • e2e/admin-rbac.spec.ts
  • e2e/d1-utils.ts
  • e2e/invite-signup-verification.spec.ts
  • e2e/playwright-utils.ts
  • e2e/smoke.spec.ts
  • packages/worker/client/routes/admin-invites.tsx
  • packages/worker/src/app/admin-user-creation.node.test.ts
  • packages/worker/src/app/admin-user-creation.ts
  • packages/worker/src/app/handlers/admin-invites.ts
  • packages/worker/src/app/handlers/password-reset.node.test.ts
  • packages/worker/src/app/handlers/password-reset.ts
  • packages/worker/src/app/loader-data.ts
  • packages/worker/src/app/password-reset-tokens.ts
✅ Files skipped from review due to trivial changes (1)
  • docs/contributing/architecture/authorization.md
🚧 Files skipped from review as they are similar to previous changes (3)
  • e2e/invite-signup-verification.spec.ts
  • docs/contributing/architecture/authentication.md
  • packages/worker/src/app/loader-data.ts

Comment thread e2e/playwright-utils.ts
return { email, username: primaryTestUser.username, password }
}

clearAuthRateLimitsInE2eDatabase()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check Playwright config for worker/parallelism settings and DB isolation
fd -e ts -e js 'playwright.config' | xargs -I{} cat -n {}
rg -n "workers|fullyParallel" playwright.config.ts 2>/dev/null

Repository: kentcdodds/kody

Length of output: 1420


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== playwright.config.ts =="
cat -n playwright.config.ts

echo
echo "== helper definition/usages =="
rg -n "clearAuthRateLimitsInE2eDatabase|auth:ip:|_rate_limits|rate limit" e2e -S

echo
echo "== tests that mention rate limiting or serial =="
rg -n "serial|rate limit|auth.*limit|login.*limit|signup.*limit" e2e -S

Repository: kentcdodds/kody

Length of output: 3035


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== e2e/playwright-utils.ts (relevant region) =="
sed -n '1,140p' e2e/playwright-utils.ts | cat -n

echo
echo "== e2e/d1-utils.ts (helper definition) =="
sed -n '90,130p' e2e/d1-utils.ts | cat -n

echo
echo "== e2e/smoke.spec.ts =="
sed -n '1,40p' e2e/smoke.spec.ts | cat -n

echo
echo "== e2e/invite-signup-verification.spec.ts (relevant region) =="
sed -n '50,95p' e2e/invite-signup-verification.spec.ts | cat -n

Repository: kentcdodds/kody

Length of output: 11142


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== e2e/d1-utils.ts (top section) =="
sed -n '1,110p' e2e/d1-utils.ts | cat -n

echo
echo "== wrangler config files mentioning CLOUDFLARE_ENV or D1 =="
rg -n "CLOUDFLARE_ENV|d1|database" wrangler* . -g '!node_modules' -g '!dist' -g '!build' -S

Repository: kentcdodds/kody

Length of output: 50371


Avoid clearing shared auth rate limits in parallel e2e runs. This global delete can race with other Playwright workers against the shared e2e D1 file, masking or destabilizing auth rate-limit checks. Scope the keys per worker/test or make the auth specs serial.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/playwright-utils.ts` at line 49, The shared auth rate-limit reset in
clearAuthRateLimitsInE2eDatabase is unsafe for parallel Playwright workers
because it can delete keys used by other tests. Update the e2e setup so auth
rate-limit data is isolated per worker or per test (using a worker-specific
prefix/namespace), or mark the auth-related specs as serial where they depend on
shared state. Keep the fix localized around clearAuthRateLimitsInE2eDatabase and
the related Playwright e2e auth setup.

db: appEnv.APP_DB,
userId: userRecord.id,
expiresAt,
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Password reset replaces setup link

Medium Severity

Admin-created accounts get a seven-day setup link via password_resets, but a normal POST /password-reset for the same email calls createPasswordResetToken, which deletes existing rows and stores a new one-hour token. The admin’s copied link stops working, and the flow may auto-email a different reset link than the one the operator intended to send manually.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 95936ef. Configure here.

cursoragent and others added 3 commits July 5, 2026 17:32
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
@cursor
cursor Bot force-pushed the cursor/bc-cc080c2a-3a4d-4e51-a21d-c4d1b53a808c-5a9c branch from 95936ef to 59c1681 Compare July 5, 2026 17:32

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 59c1681. Configure here.

if ((result.meta.changes ?? 0) > 0) {
const invite = await getInviteByCode(input.db, code)
if (!invite) return { ok: false, reason: 'not_found' }
return { ok: true, invite }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Invite use leaked on fetch failure

Low Severity

If the conditional invite UPDATE increments use_count but the follow-up getInviteByCode returns no row, consumeInviteCode reports failure without decrementing. Signup aborts without calling releaseInviteUse, so that invite use is consumed with no account created.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 59c1681. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants