Request Metadata Plugin

The Request Metadata Plugin automatically captures request metadata (IP address, geolocation, user-agent, etc.) and makes it available throughout your API handlers. It supports both context-only access (for hooks and authorization) and optional database persistence.

Request Metadata Plugin

The Request Metadata Plugin automatically captures request metadata (IP address, geolocation, user-agent, etc.) and makes it available throughout your API handlers. It supports both context-only access (for hooks and authorization) and optional database persistence.

Installation

The plugin is included with @websideproject/nuxt-auto-api and can be imported from the plugins index:

import { createRequestMetadataPlugin } from '@websideproject/nuxt-auto-api/plugins'

Basic Usage

Context-Only (No Database Storage)

Use this when you only need metadata for hooks, logging, or authorization decisions:

// server/plugins/autoapi.ts
import { createRequestMetadataPlugin } from '@websideproject/nuxt-auto-api/plugins'

export default defineNitroPlugin(() => {
  // Plugin configuration...
  plugins: [
    createRequestMetadataPlugin({
      autoPopulate: false,  // Don't write to DB
    }),
  ],
})

Access metadata in hooks:

autoApi: {
  hooks: {
    users: {
      afterCreate: async (result, context) => {
        // Metadata available in context
        const country = context.requestMeta?.country || 'US'
        const ip = context.requestMeta?.ip

        // Send localized welcome email
        await sendWelcomeEmail(result.email, country)

        // Log for analytics
        console.log(`New user from ${country}: ${ip}`)
      }
    }
  }
}

Auto-Population (Database Storage)

Store metadata in database columns automatically on create/update operations:

// Schema with metadata columns
export const users = sqliteTable('users', {
  id: integer('id').primaryKey(),
  email: text('email').notNull(),
  signupIp: text('signup_ip'),
  signupCountry: text('signup_country'),
})

// Plugin configuration
createRequestMetadataPlugin({
  autoPopulate: {
    ip: 'signupIp',
    country: 'signupCountry'
  },
  autoPopulateOn: ['create'],  // Only on signup
})

When a user signs up via POST /api/users, the record will include:

{
  "email": "user@example.com",
  "signupIp": "1.2.3.4",
  "signupCountry": "US"
}

Configuration Options

extract (optional)

Custom function to extract metadata from the request. Defaults to Cloudflare headers with fallback to standard headers.

createRequestMetadataPlugin({
  extract: async (event) => {
    const ip = getRequestIP(event)
    const geo = await geoipService.lookup(ip)

    return {
      ip,
      country: geo?.country,
      city: geo?.city,
      customField: 'value'
    }
  }
})

Default extraction logic:

  • IP: CF-Connecting-IPX-Forwarded-ForX-Real-IP → socket IP
  • Country: CF-IPCountry
  • City: CF-IPCity
  • Region: CF-IPRegion
  • Timezone: CF-Timezone
  • Latitude: CF-IPLatitude
  • Longitude: CF-IPLongitude
  • User-Agent: User-Agent header

autoPopulate (optional)

Configures how metadata is stored in the database. Supports multiple strategies:

Option 1: Column Mapping (Simple)

Map metadata fields to database columns:

autoPopulate: {
  ip: 'signupIp',
  country: 'signupCountry',
  city: 'signupCity'
}

Rules:

  • Only populates if the column exists in the schema
  • Won't overwrite user-provided values
  • Skips if metadata value is undefined

Option 2: JSON Field Storage (Nested)

Store all metadata in a single JSON column:

// Schema
export const users = sqliteTable('users', {
  id: integer('id').primaryKey(),
  email: text('email').notNull(),
  metadata: text('metadata', { mode: 'json' }),
})

// Plugin
autoPopulate: {
  json: 'metadata',    // Column name
  path: 'signup',      // Optional: nest under this key
  merge: true,         // Merge with existing JSON data (default: true)
}

Result:

{
  "metadata": {
    "signup": {
      "ip": "1.2.3.4",
      "country": "US",
      "city": "San Francisco",
      "userAgent": "Mozilla/5.0..."
    }
  }
}

Without path (top-level merge):

autoPopulate: {
  json: 'metadata',
  merge: true,
}

Result:

{
  "metadata": {
    "ip": "1.2.3.4",
    "country": "US",
    "city": "San Francisco"
  }
}

Option 3: Custom Mapper (Full Control)

Use a custom function for complex storage logic:

autoPopulate: async (metadata, data, context) => {
  // Store in JSON column
  data.signupMeta = {
    ip: metadata.ip,
    location: `${metadata.city}, ${metadata.country}`,
    timestamp: new Date().toISOString(),
  }

  // Compute derived fields
  data.isDomestic = metadata.country === 'US'
  data.signupSource = metadata.country === 'US' ? 'domestic' : 'international'

  // Conditional logic
  if (metadata.vpnDetected) {
    data.riskScore = 0.8
    await notifySecurityTeam(data.email, metadata.ip)
  }

  return data
}

Option 4: Disabled (Default)

autoPopulate: false  // Context-only, no DB storage

autoPopulateOn (optional)

Which operations to auto-populate on. Default: ['create']

// Only on create (signup)
autoPopulateOn: ['create']

// Track last access on every update
autoPopulateOn: ['update']

// Both create and update
autoPopulateOn: ['create', 'update']

resources (optional)

Limit auto-population to specific resources:

resources: ['users', 'orders']  // Only auto-populate for these tables

If not specified, applies to all resources.

Use Cases

1. Auditing & Compliance

Track where users signed up from:

// Schema
export const users = sqliteTable('users', {
  id: integer('id').primaryKey(),
  email: text('email').notNull(),
  signupIp: text('signup_ip'),
  signupCountry: text('signup_country'),
  signupTimestamp: integer('signup_timestamp'),
})

// Plugin
createRequestMetadataPlugin({
  autoPopulate: {
    ip: 'signupIp',
    country: 'signupCountry'
  },
  autoPopulateOn: ['create'],
})

2. Fraud Detection

Analyze signup patterns:

// In a hook
afterCreate: async (result, context) => {
  const { ip, country, vpnDetected } = context.requestMeta || {}

  // Check for suspicious activity
  const recentSignups = await db.select()
    .from(users)
    .where(eq(users.signupIp, ip))
    .limit(10)

  if (recentSignups.length > 5) {
    await flagForReview(result.id, 'Multiple signups from same IP')
  }
}

3. Country-Based Features

Block or enable features based on country:

// Authorization function
autoApi: {
  authorization: {
    payments: {
      permissions: {
        create: (context) => {
          const country = context.requestMeta?.country
          if (country !== 'US') {
            throw createError({
              statusCode: 403,
              message: 'Payments only available in US'
            })
          }
          return true
        }
      }
    }
  }
}

4. Localized Content

Send region-appropriate emails:

afterCreate: async (result, context) => {
  const country = context.requestMeta?.country || 'US'
  const locale = country === 'US' ? 'en-US' : 'en-GB'

  await sendWelcomeEmail(result.email, locale)
}

5. Last Seen Tracking

Update user's last IP on every action:

// Schema
export const users = sqliteTable('users', {
  id: integer('id').primaryKey(),
  lastIp: text('last_ip'),
  lastSeen: integer('last_seen'),
})

// Plugin
createRequestMetadataPlugin({
  autoPopulate: { ip: 'lastIp' },
  autoPopulateOn: ['update'],
  resources: ['users'],
})

// Hook to also update timestamp
hooks: {
  users: {
    beforeUpdate: async (id, data, context) => {
      data.lastSeen = Date.now()
      return data
    }
  }
}

6. Hybrid Storage (Best Practice)

Store frequently queried fields as columns + full metadata as JSON:

// Schema
export const users = sqliteTable('users', {
  id: integer('id').primaryKey(),
  email: text('email').notNull(),
  signupCountry: text('signup_country'),  // Indexed for fast queries
  signupMeta: text('signup_meta', { mode: 'json' }),  // Full metadata
})

// Plugin
createRequestMetadataPlugin({
  autoPopulate: (metadata, data) => {
    // Store country as column for indexed queries
    data.signupCountry = metadata.country

    // Store full metadata as JSON
    data.signupMeta = metadata

    return data
  },
})

Benefits:

  • Fast queries: SELECT * FROM users WHERE signupCountry = 'US'
  • Full context preserved for detailed analysis
  • No need to add columns for every metadata field

Custom Extractors

MaxMind GeoIP

import { lookup } from 'geoip-lite'

createRequestMetadataPlugin({
  extract: async (event) => {
    const ip = getRequestIP(event)
    const geo = lookup(ip)

    return {
      ip,
      country: geo?.country,
      city: geo?.city,
      region: geo?.region,
      timezone: geo?.timezone,
      latitude: String(geo?.ll[0]),
      longitude: String(geo?.ll[1]),
      userAgent: getHeader(event, 'user-agent'),
    }
  }
})

Third-Party Geolocation API

createRequestMetadataPlugin({
  extract: async (event) => {
    const ip = getRequestIP(event)

    // Call external service
    const response = await $fetch(`https://api.ipdata.co/${ip}`, {
      params: { 'api-key': process.env.IPDATA_API_KEY }
    })

    return {
      ip,
      country: response.country_code,
      city: response.city,
      timezone: response.time_zone.name,
      isp: response.asn.name,
      vpnDetected: response.threat.is_vpn,
    }
  }
})

Custom Headers

createRequestMetadataPlugin({
  extract: (event) => {
    const headers = getRequestHeaders(event)

    return {
      ip: headers['x-real-ip'],
      userAgent: headers['user-agent'],
      referrer: headers['referer'],
      acceptLanguage: headers['accept-language'],
      // Custom headers from your proxy
      customerId: headers['x-customer-id'],
      requestId: headers['x-request-id'],
    }
  }
})

Context Structure

The plugin adds requestMeta to HandlerContext:

interface HandlerContext {
  // ... other fields ...

  requestMeta?: {
    // Default fields (Cloudflare)
    ip?: string
    country?: string
    city?: string
    region?: string
    timezone?: string
    latitude?: string
    longitude?: string
    userAgent?: string

    // Custom fields from your extractor
    [key: string]: any
  }
}

Best Practices

1. Privacy Considerations

Don't store IP addresses unless necessary:

// ✅ Good: Context-only for temporary use
createRequestMetadataPlugin({
  autoPopulate: false,  // Don't persist
})

// Use in hooks for analytics/logging
afterCreate: async (result, context) => {
  await analytics.track({
    userId: result.id,
    ip: context.requestMeta?.ip,  // Sent to analytics service, not stored
  })
}

2. Indexed Columns

Store frequently queried fields as columns:

// ✅ Good: Country as indexed column
export const users = sqliteTable('users', {
  signupCountry: text('signup_country').index(),  // Fast queries
  signupMeta: text('signup_meta', { mode: 'json' }),  // Full data
})

// Can efficiently query
const usUsers = await db.select()
  .from(users)
  .where(eq(users.signupCountry, 'US'))

3. Don't Overwrite User Input

The plugin automatically skips fields the user has set:

// User explicitly provides a value
POST /api/users
{
  "email": "user@example.com",
  "signupIp": "custom-value"  // Won't be overwritten
}

4. Handle Missing Metadata

Always check for undefined metadata:

afterCreate: async (result, context) => {
  const country = context.requestMeta?.country || 'UNKNOWN'
  const ip = context.requestMeta?.ip || 'unknown'

  // Safe to use
  console.log(`User from ${country} (${ip})`)
}

5. Resource Filtering

Only auto-populate for relevant resources:

createRequestMetadataPlugin({
  autoPopulate: { ip: 'signupIp' },
  resources: ['users'],  // Not needed for products, categories, etc.
})

TypeScript Support

The plugin is fully typed. Context metadata is available with autocomplete:

import type { HandlerContext } from '@websideproject/nuxt-auto-api'

// In hooks
afterCreate: async (result: any, context: HandlerContext) => {
  // context.requestMeta is typed
  const ip: string | undefined = context.requestMeta?.ip
  const country: string | undefined = context.requestMeta?.country
}

Custom extractors are also typed:

import type { H3Event } from 'h3'

createRequestMetadataPlugin({
  extract: async (event: H3Event): Promise<Record<string, any>> => {
    return {
      customField: 'value'
    }
  }
})

Comparison: Storage Strategies

StrategyBest ForExample
Context-onlyHooks, logging, auth decisionsautoPopulate: false
Column mappingSimple schemas, frequent queries{ ip: 'signupIp' }
JSON fieldFlexible schemas, rare queries{ json: 'metadata' }
Custom mapperComplex logic, computed fieldsCustom function
HybridProduction apps (indexed + flexible)Column + JSON

Performance Considerations

Extraction Cost

  • Cloudflare headers (default): ~0ms (just reading headers)
  • GeoIP-lite lookup: ~1-5ms (in-memory database)
  • External API calls: ~50-500ms (network latency)

For external APIs, consider:

  • Caching results by IP
  • Using background jobs for non-critical data
  • Fallback to fast headers if API fails

Storage Cost

  • Context-only: Zero DB overhead
  • Column mapping: Minimal (native column writes)
  • JSON field: Slightly slower on write, but flexible

Example: Complete Setup

// server/db/schema.ts
export const users = sqliteTable('users', {
  id: integer('id').primaryKey(),
  email: text('email').notNull().unique(),
  name: text('name'),

  // Metadata columns
  signupCountry: text('signup_country').index(),  // Fast queries
  signupMeta: text('signup_meta', { mode: 'json' }),  // Full metadata

  // Audit fields
  lastIp: text('last_ip'),
  lastSeen: integer('last_seen'),
})

// server/plugins/autoapi.ts
import { createRequestMetadataPlugin } from '@websideproject/nuxt-auto-api/plugins'

export default defineNitroPlugin(() => {
  // Auto API configuration
  plugins: [
    createRequestMetadataPlugin({
      // Use Cloudflare headers (default)
      extract: undefined,

      // Hybrid storage
      autoPopulate: (metadata, data, context) => {
        if (context.operation === 'create') {
          // Store country for fast queries
          data.signupCountry = metadata.country

          // Store full metadata
          data.signupMeta = {
            ip: metadata.ip,
            country: metadata.country,
            city: metadata.city,
            userAgent: metadata.userAgent,
            timestamp: new Date().toISOString(),
          }
        }

        if (context.operation === 'update') {
          // Track last seen
          data.lastIp = metadata.ip
          data.lastSeen = Date.now()
        }

        return data
      },

      autoPopulateOn: ['create', 'update'],
      resources: ['users'],  // Only track for users
    }),
  ],

  hooks: {
    users: {
      afterCreate: async (result, context) => {
        // Send localized welcome email
        const country = context.requestMeta?.country || 'US'
        await sendWelcomeEmail(result.email, country)

        // Log to analytics (not stored in DB)
        await analytics.track({
          event: 'user_signup',
          userId: result.id,
          properties: context.requestMeta,
        })
      }
    }
  }
})

Need a Landing Page?

Modern landing pages with optional modules (blog, docs, forms, i18n). Let's discuss your project.

Build Your MVP

Full-stack SaaS development. Expert in database design, multi-tenancy, and scalable architecture.

Deployment Help

Dockerize your backend, set up CI/CD pipelines, deploy to Cloudflare or Hetzner. Early-stage setup.

Suggest a SaaS Tool

Missing a calculator or tool? Suggest what you'd like to see on our site.