Skip to content

feat(fastify): add account link routes (wallet, email) #90

Merged
gaboesquivel merged 6 commits intomainfrom
account-link
Feb 15, 2026
Merged

feat(fastify): add account link routes (wallet, email) #90
gaboesquivel merged 6 commits intomainfrom
account-link

Conversation

@gaboesquivel
Copy link
Member

@gaboesquivel gaboesquivel commented Feb 15, 2026

Summary by CodeRabbit

  • New Features

    • Wallet linking for EIP‑155 and Solana; email linking with verify flow; Web3 nonce endpoint for wallet auth
    • Email template for link emails
  • Documentation

    • Updated authentication docs; added command/workflow guides; updated Fastify README with DB reset/migrate commands
  • Tests

    • Added comprehensive tests for wallet and email linking flows
  • Chores

    • Migration/run-migrate behavior made explicit; new script to reset+migrate database

gaboesquivel and others added 5 commits February 15, 2026 02:07
Co-authored-by: Cursor <cursoragent@cursor.com>
- Add verification.type column (magic_link | link_email), web3_nonces table
- Add GET /auth/web3/nonce for SIWE nonce
- Add POST /account/link/wallet/verify (Bearer, chain+message+signature)
- Add POST /account/link/email/request and verify with LinkEmailEmail template
- Add web3-verify lib for EIP-155 and Solana signature verification
- Update magiclink to set/filter by type
- Update authentication.mdx docs

Co-authored-by: Cursor <cursoragent@cursor.com>
…and core

- Fix magiclink request duplicate type property (TS1117)
- Regenerate OpenAPI from routes (never edit directly)
- Regenerate @repo/core from OpenAPI

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@vercel
Copy link

vercel bot commented Feb 15, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

3 Skipped Deployments
Project Deployment Actions Updated (UTC)
basilic-docs Ignored Ignored Preview Feb 15, 2026 3:08am
basilic-fastify Ignored Ignored Preview Feb 15, 2026 3:08am
basilic-next Ignored Ignored Preview Feb 15, 2026 3:08am

@coderabbitai
Copy link

coderabbitai bot commented Feb 15, 2026

🚥 Pre-merge checks | ✅ 3 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 9.09% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the primary feature addition: new account linking routes for both wallet and email verification in the Fastify backend.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch account-link

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
apps/fastify/src/routes/account/link/email/verify.ts (2)

16-18: Consider adding minLength to the token field.

An empty string would pass validation but is guaranteed to fail lookup. Adding Type.String({ minLength: 1 }) rejects trivially invalid requests before hitting the DB.

Proposed fix
 const VerifySchema = Type.Object({
-  token: Type.String(),
+  token: Type.String({ minLength: 1 }),
 })

86-92: Email collision query selects all columns unnecessarily.

Only the id is needed for the collision check. Selecting all columns pulls potentially sensitive fields (e.g., email, walletAddress) into memory for no reason.

Proposed fix
-      const [existingByEmail] = await db.select().from(users).where(eq(users.email, email))
+      const [existingByEmail] = await db.select({ id: users.id }).from(users).where(eq(users.email, email))

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 and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/docu/content/docs/architecture/authentication.mdx (1)

58-58: ⚠️ Potential issue | 🟡 Minor

Mermaid diagram still references the old nonce endpoint path.

Line 58 shows GET /auth/web3/:chain/nonce?address=... but line 112 documents the new endpoint as GET /auth/web3/nonce with chain as a query parameter. Update the diagram to match.

Proposed fix
-  Client->>FastifyAPI: GET /auth/web3/:chain/nonce?address=...
+  Client->>FastifyAPI: GET /auth/web3/nonce?chain=...&address=...
🤖 Fix all issues with AI agents
In `@apps/fastify/src/lib/web3-verify.ts`:
- Around line 72-78: normalizeAddress currently returns the EIP‑55 checksummed
address via getAddress(address) while nonces are stored lowercase, causing
inconsistent address casing across tables; change normalizeAddress (in
web3-verify.ts) to return getAddress(address).toLowerCase() so it canonicalizes
to lowercase; this will align with nonce storage in nonce.ts and allow verify.ts
to use normalizedAddress directly (removing the separate lookupAddr lowercasing)
and keep walletIdentities rows consistent.

In `@apps/fastify/src/routes/account/link/email/request.ts`:
- Around line 80-87: The insert of the verification record via
db.insert(verification).values(...) can leave an orphan if
emailProvider.emails.send throws; capture the inserted record id (generate the
randomUUID() into a variable instead of inlining), then wrap the
emailProvider.emails.send call in a try/catch and on error delete the
verification row (use the same id or identifier/tokenHash to remove the record)
or rethrow after cleanup; update the request flow around
db.insert(verification).values, emailProvider.emails.send, token/tokenHash and
expiresAt to ensure delete runs on send failure.

In `@apps/fastify/src/routes/account/link/email/verify.test.ts`:
- Around line 74-87: The test uses a made-up string ('expired-token-xxxx') which
triggers INVALID_TOKEN, not the expired path; to fix, create a real verification
record with an expiresAt set in the past (use your test DB helper or the same
model used by the route), retrieve its token and then call the POST
/account/link/email/verify with Authorization from getSessionToken() and payload
{ token: <expiredToken> }; assert response.statusCode === 401 and JSON body.code
=== 'EXPIRED_TOKEN' (alternatively, if you prefer to keep the current negative
test, rename the test to assert INVALID_TOKEN and require status 401 only).
Ensure this change targets the test in verify.test.ts and mirrors the route
behavior implemented in verify.ts.

In `@apps/fastify/src/routes/account/link/email/verify.ts`:
- Around line 94-109: The three DB mutations (deleting verification via
db.delete(verification) using verificationRecord.id, updating the users table
via db.update(users).set(...) with userId, and updating sessions via
db.update(sessions).set(...) using request.session.session.id after generating
refreshJti/hashToken and sessionExpiresAt) must be executed inside a single
Drizzle transaction so they either all succeed or all roll back; refactor to run
deletion of the verification row, the users update (email, emailVerified,
updatedAt), and the sessions update (token, expiresAt) within
db.transaction/transactional API and ensure errors cause a rollback so the
consumed token isn’t lost on partial failure.

In `@apps/fastify/src/routes/account/link/wallet/verify.ts`:
- Around line 131-158: Wrap the logic that checks for an existing wallet and
inserts into walletIdentities (the block using existing,
db.insert(walletIdentities), and db.delete(web3Nonce) that references nonceRow
and request.session.user.id) inside a database transaction so the
check-and-insert is atomic; inside that transaction attempt the insert and, on
catching a unique-constraint/database error for wallet_chain_address_unique,
return the same 409 WALLET_ALREADY_LINKED response instead of letting it bubble
as a 500; ensure the web3Nonce deletion
(db.delete(web3Nonce).where(eq(web3Nonce.id, nonceRow.id))) still runs exactly
once (inside the transaction or in a finally cleanup) so the nonce is removed
whether the insert succeeds or fails.
🧹 Nitpick comments (11)
apps/fastify/package.json (1)

39-39: Hardcoded local DB credentials in script — acceptable for dev but worth noting.

The postgres:postgres@127.0.0.1:54322 credentials are standard Supabase CLI local defaults, so the static analysis flag (CKV_SECRET_4) is a false positive in practice. However, consider referencing an env var or .env file instead to keep the pattern consistent with other scripts and avoid tripping secret scanners in CI.

apps/fastify/src/routes/auth/web3/nonce.ts (3)

49-61: Redundant manual validation — TypeBox schema already enforces these constraints.

The querystring schema (Lines 36-38) defines chain as Type.Union([Type.Literal('eip155'), Type.Literal('solana')]) and address as a required Type.String(). Fastify's TypeBox validation will reject invalid/missing values before the handler executes, so both the isValidChain check (Line 49) and the !chain || !address check (Line 56) are dead code.

♻️ Remove redundant checks
     async (request, reply) => {
       const { chain, address } = request.query
 
-      if (!isValidChain(chain)) {
-        return reply.code(400).send({
-          code: 'INVALID_CHAIN',
-          message: 'Chain must be eip155 or solana',
-        })
-      }
-
-      if (!chain || !address?.trim()) {
-        return reply.code(400).send({
-          code: 'MISSING_PARAMS',
-          message: 'chain and address query parameters are required',
-        })
-      }
-
       const db = await getDb()

78-87: Delete + insert not wrapped in a transaction.

If the insert on Line 81 fails after the delete on Line 78 succeeds, the user's nonce is lost with no record created. Wrapping both in a transaction ensures atomicity.

♻️ Use a transaction
-      await db
-        .delete(web3Nonce)
-        .where(and(eq(web3Nonce.chain, chain), eq(web3Nonce.address, normalizedAddr)))
-      await db.insert(web3Nonce).values({
-        id: randomUUID(),
-        chain,
-        address: normalizedAddr,
-        nonce,
-        expiresAt,
+      await db.transaction(async tx => {
+        await tx
+          .delete(web3Nonce)
+          .where(and(eq(web3Nonce.chain, chain), eq(web3Nonce.address, normalizedAddr)))
+        await tx.insert(web3Nonce).values({
+          id: randomUUID(),
+          chain,
+          address: normalizedAddr,
+          nonce,
+          expiresAt,
+        })
       })

12-18: Import isValidChain from validate-address.ts to eliminate duplication.

isValidChain is already exported from apps/fastify/src/routes/auth/web3/validate-address.ts. Remove the local function and VALID_CHAINS/ValidChain definitions (lines 13–18) and import it instead. Note: VALID_CHAINS is not exported; it's only a local constant in validate-address.ts used to define the exported Web3Chain type.

♻️ Proposed fix
+import { isValidChain, type Web3Chain } from './validate-address.js'
-const VALID_CHAINS = ['eip155', 'solana'] as const
-type ValidChain = (typeof VALID_CHAINS)[number]
-
-function isValidChain(chain: string): chain is ValidChain {
-  return VALID_CHAINS.includes(chain as ValidChain)
-}

Replace ValidChain references with the imported Web3Chain type where needed.

apps/fastify/src/lib/web3-verify.ts (1)

6-9: Web3Chain and chain constants duplicated across multiple files.

Web3Chain, VALID_CHAINS, and isValidChain are defined in validate-address.ts, nonce.ts, verify.ts, and this file. Consider a single shared module (e.g., validate-address.ts or a new web3-chains.ts) to be the canonical source.

apps/fastify/src/routes/account/link/wallet/verify.ts (1)

55-61: Redundant chain validation — already enforced by TypeBox schema.

Same issue as in nonce.ts: the VerifySchema (Line 19) constrains chain to Type.Union([Type.Literal('eip155'), Type.Literal('solana')]), so Fastify rejects invalid values before the handler runs. This check is dead code.

apps/fastify/src/routes/account/link/wallet/verify.test.ts (1)

10-192: Good integration coverage for EIP-155 — but Solana chain is untested.

The test suite thoroughly covers the EIP-155 happy path, unauthorized access, invalid signature, and wallet conflict scenarios. However, there are no tests exercising the Solana wallet linking flow. Since verify.ts supports both chains with different verification logic (nacl vs. viem), a Solana test would help prevent regressions in that code path.

Would you like me to open an issue to track adding Solana wallet linking tests?

apps/fastify/src/routes/account/account.spec.ts (1)

20-22: Inconsistent import style: missing .js extension on test imports.

Lines 2–4 use the .js extension (standard for TypeScript ESM), but the test-suite side-effect imports on lines 20–22 omit it. While Vitest typically resolves these fine, it's inconsistent within the same file.

Suggested fix
-import './link/wallet/verify.test'
-import './link/email/request.test'
-import './link/email/verify.test'
+import './link/wallet/verify.test.js'
+import './link/email/request.test.js'
+import './link/email/verify.test.js'
apps/fastify/src/routes/account/link/email/request.test.ts (1)

4-22: getSessionToken helper is duplicated and less robust than the version in verify.test.ts.

This helper is nearly identical to the one in verify.test.ts (lines 4-20), but that version accepts an email parameter and calls fastify.fakeEmail?.clear() before requesting the magic link. Without the clear() call here, extractToken() could pick up a stale email in tests that send emails before calling this helper (e.g., the "EMAIL_ALREADY_IN_USE" test at line 84).

Consider extracting a shared helper, or at minimum add a clear() call to match the safer pattern.

Quick fix — align with verify.test.ts
 async function getSessionToken(): Promise<string> {
+  fastify.fakeEmail?.clear()
   await fastify.inject({
     method: 'POST',
     url: '/auth/magiclink/request',
apps/fastify/openapi/openapi.json (1)

943-1015: New /auth/web3/nonce endpoint overlaps with existing chain-specific nonce endpoints.

The new unified GET /auth/web3/nonce?chain=eip155|solana&address=... (line 943) coexists with the pre-existing GET /auth/web3/eip155/nonce (line 1016) and GET /auth/web3/solana/nonce (line 1201). This creates two ways to fetch a nonce for the same chains, which may confuse API consumers and complicate deprecation.

Consider deprecating the chain-specific nonce endpoints or documenting the intended migration path.

apps/fastify/src/routes/account/link/email/request.ts (1)

74-78: Nit: typeof email === 'string' is redundant.

email is already validated as Type.String({ format: 'email' }) by TypeBox before the handler runs, so this check always evaluates to true.

Simplify
       const storePlain =
         env.NODE_ENV !== 'production' &&
         env.ALLOW_TEST === true &&
-        typeof email === 'string' &&
         email.endsWith('@test.ai')

- web3-verify: canonicalize EIP155 address to lowercase (align with nonce storage)
- email/request: delete verification record on send failure to avoid orphans
- email/verify.test: create real expired token record, assert EXPIRED_TOKEN
- email/verify: wrap delete/update/update in single transaction
- wallet/verify: wrap check-insert in transaction, handle 23505 as 409

Co-authored-by: Cursor <cursoragent@cursor.com>
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.

1 participant