Claude Code Plugins

Community-maintained marketplace

Feedback
0
0

Convex backend patterns with security, validation, and performance best practices

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name convex-patterns
description Convex backend patterns with security, validation, and performance best practices

Convex Patterns

Purpose: Enforce secure, performant Convex backend (validation, auth, race prevention, rate limiting)

  • Keywords: convex, mutation, query, action, schema, database, ctx.db, defineSchema, internalMutation, webhook, httpAction, http, stripe, CONVEX_SITE_URL, callback, api

Quick Reference

Pattern ✅ DO ❌ AVOID
Files snake_case.ts kebab-case.ts (deploy fails)
Fields snake_case camelCase
Validation args: v.object({...}) No validators (insecure!)
Auth Check every mutation Trust client
Queries .withIndex() Table scans

CRITICAL Security

⚠️ ALL functions PUBLIC by default

Always:

  1. Use validators on all functions
  2. Check authentication (ctx.auth.getUserIdentity())
  3. Verify ownership before operations
  4. Validate beyond schema

⚠️ NEVER share CONVEX_URL publicly (GitHub, docs, screenshots)

Mutation Structure

import { mutation } from "./_generated/server"
import { v } from "convex/values"

export const createOrder = mutation({
  args: {
    amount: v.number(),
    customer_name: v.optional(v.string())
  },
  returns: v.id("orders"),
  handler: async (ctx, { amount, customer_name }) => {
    // 1. Auth
    const identity = await ctx.auth.getUserIdentity()
    if (!identity) throw new Error('Unauthorized')

    // 2. Validate beyond schema
    if (amount <= 0 || amount > 1000000) {
      throw new Error('Invalid amount')
    }

    // 3. Insert
    return await ctx.db.insert("orders", {
      amount,
      customer_name: customer_name ?? null,
      user_id: identity.subject,
      status: "pending"
    })
  }
})

Authorization Pattern

export const updateOrder = mutation({
  args: { order_id: v.id("orders"), status: v.string() },
  handler: async (ctx, { order_id, status }) => {
    // 1. Auth
    const identity = await ctx.auth.getUserIdentity()
    if (!identity) throw new Error('Unauthorized')

    // 2. Fetch
    const order = await ctx.db.get(order_id)
    if (!order) throw new Error('Not found')

    // 3. Verify ownership BEFORE update
    if (order.user_id !== identity.subject) {
      throw new Error('Access denied')
    }

    // 4. Update
    await ctx.db.patch(order_id, { status })
  }
})

Performant Queries

// Schema with indexes
export default defineSchema({
  users: defineTable({
    email: v.string(),
    status: v.string()
  })
    .index("by_email", ["email"])
    .index("by_status", ["status"])
})

// ✅ Use index (O(log n))
const user = await ctx.db
  .query('users')
  .withIndex('by_email', q => q.eq('email', email))
  .first()

// ❌ Table scan (O(n))
const user = await ctx.db
  .query('users')
  .filter(q => q.eq(q.field('email'), email))
  .first()

Idempotency

Pattern 1: Check-Before-Insert

// Schema with composite index
defineTable({
  user_id: v.string(),
  session_id: v.id('sessions'),
  data: v.string()
}).index('by_user_session', ['user_id', 'session_id'])

// Mutation (safe to retry)
const existing = await ctx.db
  .query('entries')
  .withIndex('by_user_session', q =>
    q.eq('user_id', userId).eq('session_id', session_id)
  )
  .first()

if (existing) return existing._id  // Idempotent

return await ctx.db.insert('entries', { user_id: userId, session_id, data })

Pattern 2: Idempotency Keys (Actions)

// Schema
defineTable({
  key: v.string(),
  result: v.any(),
  created_at: v.number()
}).index('by_key', ['key'])

// Action with key
export const createPayment = action({
  args: { idempotency_key: v.string(), amount: v.number() },
  handler: async (ctx, { idempotency_key, amount }) => {
    // Check if processed
    const existing = await ctx.runMutation(api.payments.checkKey, {
      key: idempotency_key
    })
    if (existing) return existing

    // Process (side effect)
    const charge = await stripe.charges.create({ amount })
    const orderId = await ctx.runMutation(api.orders.create, { amount })

    const result = { orderId, chargeId: charge.id }

    // Store result
    await ctx.runMutation(api.payments.storeResult, {
      key: idempotency_key,
      result
    })

    return result
  }
})

Client: Generate key once, safe to retry

const [key] = useState(() => uuid())
await createPayment({ idempotency_key: key, amount: 1000 })

Pattern 3: Webhook Deduplication

// Schema
defineTable({
  event_id: v.string(),  // External event ID
  event_type: v.string(),
  processed_at: v.number()
}).index('by_event_id', ['event_id'])

// Mutation
const existing = await ctx.db
  .query('webhook_events')
  .withIndex('by_event_id', q => q.eq('event_id', event_id))
  .first()

if (existing) return { processed: false, reason: 'duplicate' }

// Process event...

await ctx.db.insert('webhook_events', { event_id, event_type, processed_at: Date.now() })

HTTP Actions (Webhooks)

Use for: Webhooks, public API endpoints, external integrations

Critical: HTTP Actions use CONVEX_SITE_URL (not CONVEX_URL)

Use Case URL Why
Mutations/Queries (client SDK) CONVEX_URL Internal app communication
HTTP Actions (webhooks) CONVEX_SITE_URL Public HTTP endpoints

Setup HTTP Router

// convex/http.ts
import { httpRouter } from "convex/server"
import { httpAction } from "./_generated/server"

const http = httpRouter()

http.route({
  path: "/stripe/webhook",
  method: "POST",
  handler: httpAction(async (ctx, request) => {
    const signature = request.headers.get("stripe-signature")
    const body = await request.text()

    // 1. Verify signature BEFORE processing
    if (!verifyStripeSignature(body, signature, STRIPE_WEBHOOK_SECRET)) {
      return new Response("Invalid signature", { status: 401 })
    }

    // 2. Check timestamp (replay attack prevention)
    const event = JSON.parse(body)
    const eventTime = event.created * 1000
    if (Date.now() - eventTime > 5 * 60 * 1000) {
      return new Response("Timestamp too old", { status: 400 })
    }

    // 3. Process idempotently using event ID
    await ctx.runMutation(api.webhooks.processWebhookEvent, {
      event_id: event.id,
      event_type: event.type,
      data: event.data.object
    })

    // Always return 200 (even for duplicates)
    return new Response("OK", { status: 200 })
  })
})

export default http

Why this matters:

  • Webhook providers retry failed requests
  • Network issues cause duplicate deliveries
  • Event ID ensures exactly-once processing
  • Return 200 for duplicates prevents unnecessary retries

Webhook URL: https://YOUR_SITE.convex.site/stripe/webhook (uses CONVEX_SITE_URL)

Rate Limiting

import { RateLimiter } from '@convex-dev/ratelimiter'

const limiter = new RateLimiter(components.rateLimiter, {
  createOrder: { kind: 'token bucket', rate: 10, period: 60_000 }
})

export const createOrder = mutation({
  handler: async (ctx, { amount }) => {
    const identity = await ctx.auth.getUserIdentity()
    if (!identity) throw new Error('Unauthorized')

    await limiter.limit(ctx, 'createOrder', { key: identity.subject })

    return await ctx.db.insert("orders", { amount, user_id: identity.subject })
  }
})

Common limits:

  • Registration: 3/user/24h
  • Login: 10/email/5min
  • Orders: 10/user/min
  • API: 100/user/hour

File Naming

⚠️ CRITICAL: Convex requires snake_case for files in convex/

✅ convex/stripe_webhook.ts
✅ convex/user_queries.ts
❌ convex/stripe-webhook.ts  (deploy fails)

Error: "Path component X can only contain alphanumeric, underscores, periods"

Field Naming

Always snake_case:

await ctx.db.insert("orders", {
  transaction_id: "abc",     // ✅
  customer_name: "John",     // ✅
  _creationTime: Date.now(), // ✅ System field
  userId: identity.subject   // ❌ Should be user_id
})

Validators

Type Usage
Primitives v.string(), v.number(), v.boolean(), v.null()
ID v.id("table_name")
Optional v.optional(v.string()), v.nullable(v.number())
Object v.object({ name: v.string(), age: v.number() })
Array v.array(v.string())
Union v.union(v.literal("a"), v.literal("b"))
Any v.any() (use sparingly)

Resources

Progressive disclosure for deep dives:

  • resources/schema-design.md - Table design, index patterns
  • resources/auth-patterns.md - Comprehensive auth
  • resources/performance.md - Query optimization, caching

Docs