Request Metadata Plugin
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-IP→X-Forwarded-For→X-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-Agentheader
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
| Strategy | Best For | Example |
|---|---|---|
| Context-only | Hooks, logging, auth decisions | autoPopulate: false |
| Column mapping | Simple schemas, frequent queries | { ip: 'signupIp' } |
| JSON field | Flexible schemas, rare queries | { json: 'metadata' } |
| Custom mapper | Complex logic, computed fields | Custom function |
| Hybrid | Production 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,
})
}
}
}
})
Related
Rate Limiting
Rate limiting is provided as a built-in plugin to protect your API from abuse and ensure fair usage. It registers as pre-auth middleware that runs automatically on every API request.
Plugin Catalog
This document lists all shipped plugins for @websideproject/nuxt-auto-api.