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/autoapi-plugins.ts  (nuxt.config: autoApi.plugins: '~/server/autoapi-plugins')
import { createRequestMetadataPlugin } from '@websideproject/nuxt-auto-api/plugins'

export default [
  createRequestMetadataPlugin({
    autoPopulate: false, // Don't write to DB
  }),
]

Access metadata in hooks:

// server/plugins/user-hooks.ts
import { addResourceHook } from '@websideproject/nuxt-auto-api/plugins'

export default defineNitroPlugin(() => {
  addResourceHook('users', {
    afterCreate: async (result, context) => {
      const country = context.requestMeta?.country || 'US'
      await sendWelcomeEmail(result.email, country)
      console.log(`New user from ${country}: ${context.requestMeta?.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. The default reads Cloudflare's headers when the request came through Cloudflare, and the connection's address otherwise.

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-IP on Cloudflare; the X-Forwarded-For entry your proxy saw with trustProxy; otherwise the connection's address
  • Geo (on Cloudflare only — elsewhere a client could send these headers):
    • Country: CF-IPCountry
    • City: CF-IPCity
    • Region: CF-IPRegion
    • Timezone: CF-Timezone
    • Latitude: CF-IPLatitude
    • Longitude: CF-IPLongitude
  • User-Agent: User-Agent header

trustProxy (optional)

Behind your own reverse proxy, how many proxies append to X-Forwarded-For (true = 1). Without it the header is ignored: a client can write any address there.

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 context.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:

// modules/payments/auth.ts — a function permission belongs in the resource's auth file;
// `autoApi.authorization` in nuxt.config only carries data (functions cannot be serialized)
import type { ResourceAuthConfig } from '@websideproject/nuxt-auto-api'

export const paymentsAuth: ResourceAuthConfig = {
  permissions: {
    read: ctx => !!ctx.user,
    create: ctx => ctx.requestMeta?.country === 'US', // refused with 403 elsewhere
  },
}

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 (server/plugins/user-hooks.ts)
addResourceHook('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

GeoIP lookup (geoip-lite)

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: geo ? String(geo.ll[0]) : undefined,
      longitude: geo ? String(geo.ll[1]) : undefined,
      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'),  // Fast queries (indexed below)
  signupMeta: text('signup_meta', { mode: 'json' }),  // Full data
}, t => [index('users_signup_country_idx').on(t.signupCountry)])

// 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'),  // Fast queries (indexed below)
  signupMeta: text('signup_meta', { mode: 'json' }),  // Full metadata

  // Audit fields
  lastIp: text('last_ip'),
  lastSeen: integer('last_seen', { mode: 'timestamp' }), // the plugin example writes a Date
}, t => [index('users_signup_country_idx').on(t.signupCountry)])

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

export default [
  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
  }),
]

// server/plugins/user-hooks.ts
import { addResourceHook } from '@websideproject/nuxt-auto-api/plugins'

export default defineNitroPlugin(() => {
  addResourceHook('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.