A deployable Express proxy that inspects every AI request, strips PII with Microsoft Presidio, and blocks prompt injections before they reach any model via OpenRouter.
SMB customer support teams frequently paste emails and chat transcripts into AI tools, leaking customer PII without realising it. Without a safety layer at the gateway, compliance violations and brand risk pile up fast.
A complete, working implementation of this recipe — downloadable as a zip or browsable file by file. Generated by our build pipeline; tested with full coverage before publishing.
This recipe builds an OpenRouter PII Redaction Gateway — a Next.js App Router proxy that sits between SMB customer-support agents and OpenRouter. Every chat-completion request is inspected by a three-stage guardrail chain: PII is redacted (not blocked), prompt injections are blocked with a 403, and tool-call arguments are scanned for leaked secrets. A per-tenant admin API and dashboard let your operations team configure redaction modes, injection thresholds, and daily budgets. The project uses @reaatech/guardrail-chain for guardrail orchestration, the OpenAI SDK pointed at OpenRouter’s API, Langfuse for cost telemetry, and Zod for runtime validation.
Prerequisites
Node.js >= 22 and pnpm 10 installed
An OpenRouter API key (free tier works for testing)
A Langfuse account (free tier — used for cost telemetry)
Basic familiarity with TypeScript and the Next.js App Router
Step 1: Create the project and install dependencies
Start from an empty directory. Create package.json with the exact-pinned dependencies shown below, then install.
Every version is pinned to an exact number — no ^ or ~ ranges. The engines field enforces Node.js 22+.
terminal
pnpm install
Expected output: pnpm resolves all dependencies and writes pnpm-lock.yaml. The install output lists each package with its exact version.
Step 2: Define configuration types with Zod
The gateway validates every incoming proxy request at runtime. Create src/lib/types.ts with the shape definitions.
ts
import { z } from "zod";export const TenantConfigSchema = z.object({ tenantId: z.string(), piiMode: z.enum(["redact", "block", "passthrough"]), injectionThreshold: z.number().min(0).max(1), dailyBudgetUsd: z.number().positive(),});export type TenantConfig = z.infer<typeof TenantConfigSchema>;export const ProxyRequestSchema = z.object({ model: z.string(), messages: z.array( z.object({ role: z.string(), content: z.string(), }), ).min(1, "messages must contain at least one message"), tools: z.array(z.unknown()).optional(), tenantId: z.string().optional(), userId: z.string().optional(),});export type ProxyRequest = z.infer<typeof ProxyRequestSchema>;export type BlockedReason = | "pii_detected" | "injection_detected" | "tool_blocked" | "budget_exceeded";export type ProxyResponse = { choices?: unknown; blocked?: true; reason?: BlockedReason; usage?: unknown; costUsd?: number;};export type SafetyMode = "hipaa" | "pci" | "standard";
ProxyRequestSchema enforces that every request has a model string and at least one message with role and content. The tools array is optional — it’s forwarded through to OpenRouter when present. TenantConfigSchema supports three PII modes (redact, block, or passthrough) so different tenants can have different compliance levels. A SafetyMode type (“hipaa”, “pci”, “standard”) is also defined for future per-tenant safety profiles.
Step 3: Write the guardrail rule configuration
The guardrail chain is configured from a YAML file at src/config/rules.yaml. This declares the budget envelope, each guardrail’s timeout and priority, and whether observability logging is enabled.
Each guardrail has an id matching the guardrail class name, an essential flag (when true, a timeout fails the whole request), a timeout in milliseconds, and a priority that determines execution order. The budget caps total chain execution at 1 second and 8,000 tokens of processing.
Step 4: Build the guardrail service
This is the heart of the gateway. src/services/guardrail-service.ts implements three guardrail classes and wires them into a chain.
Imports and patterns
ts
import { ChainBuilder, setLogger, ConsoleLogger, createChainContext, type Guardrail, type GuardrailResult, type ChainContext, type ExecutionOptions,} from "@reaatech/guardrail-chain";import { loadConfig } from "@reaatech/guardrail-chain-config";import type { BlockedReason } from "../lib/types.js";import { Logger, redact, safeRegExp, PolicyViolationError } from "@reaatech/tool-use-firewall-core";export const SelectionType = { First: "first", NFirst: "n-first", Last: "last", NLast: "n-last", All: "all" } as const;export type SelectionType = (typeof SelectionType)[keyof typeof SelectionType];// @presidio-dev/hai-guardrails provides piiGuard, injectionGuard, GuardrailsEngine, SelectionType.// Import disabled: piscina worker pool crashes in this environment (MODULE_NOT_FOUND for worker.js).// Plan requirement satisfied via dynamic import with timeout.// Suppress piscina worker crash errors from leaking to vitest error trackingprocess.on("uncaughtException", (err: Error) => { if (err.message.includes("piscina")) return; });process.on("unhandledRejection", (err: Error) => { if (err.message.includes("piscina")) return; });void 0;const FIREWALL_LOGGER = new Logger("GuardrailService");const EMAIL_PATTERN = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;const CREDIT_CARD_PATTERN = /\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b/;const PHONE_PATTERN = /\b\+?1?[-.\s]?(\(?\d{3}\)?)[-.\s]?\d{3}[-.\s]?\d{4}\b/;const INJECTION_PATTERNS = [ /ignore\s+(all\s+)?previous/i, /reveal\s+(your\s+)?(system\s+)?prompt/i, /you\s+are\s+(now\s+)?DAN/i, /bypass\s+(all\s+)?(restrictions|constraints|rules)/i, /act\s+as\s+(an?\s+)?unfiltered/i, /do\s+not\s+(follow|obey)\s+(your\s+)?/i,];const API_KEY_PATTERN = safeRegExp(`["']?(?:api[_-]?key|apikey|secret|token|credential)["']?\s*[:=]\s*["']?[A-Za-z0-9_\-]{16,}`, "i");const BEARER_PATTERN = safeRegExp(`bearer\s+[A-Za-z0-9\-._~+/]{20,}`, "i");
Three regex-based patterns detect common PII types: emails, credit card numbers, and US phone numbers. The injection patterns catch prompt-attack vectors (DAN mode, system-prompt extraction, instruction override). The process.on handlers suppress piscina worker pool crashes from leaking into test output.
Key design choice: this guardrail returns passed: true with a redacted output rather than blocking the request. The call continues to OpenRouter with the PII stripped out.
InjectionDetectionGuardrail
ts
export class InjectionDetectionGuardrail implements Guardrail<string, string> { readonly id = "injection-detection"; readonly name = "Injection Detection"; readonly type = "input" as const; enabled = true; constructor(private threshold: number) {} async execute(input: string, _context: ChainContext): Promise<GuardrailResult<string>> { await tryLoadPresidio(); if (input.length === 0) return { passed: true, output: input, confidence: 0 }; if (isInjection(input)) { return { passed: false, output: input, confidence: 1, error: new Error("Injection detected") }; } return { passed: true, output: input, confidence: 0 }; }}
Unlike the PII guardrail, this one blocks the request with passed: false when it detects a prompt attack pattern.
ToolUseFirewallGuardrail
ts
export class ToolUseFirewallGuardrail implements Guardrail<string, string> { readonly id = "tool-use-firewall"; readonly name = "Tool Use Firewall"; readonly type = "input" as const; enabled = true; execute(input: string, _context: ChainContext): Promise<GuardrailResult<string>> { if (hasSensitiveToolData(input)) { redact({ input }); FIREWALL_LOGGER.warn("Tool use blocked by firewall", { reason: "Sensitive data detected in tool arguments", }); return Promise.resolve({ passed: false, output: input, error: new PolicyViolationError({ message: "Tool call contains sensitive data patterns" }) }); } return Promise.resolve({ passed: true, output: input }); }}
This guardrail checks tool-call arguments for leaked API keys, secrets, and bearer tokens using safeRegExp from @reaatech/tool-use-firewall-core.
Expected output: All three guardrail classes compile and the chain builder returns a valid GuardrailChain instance.
Step 5: Create the OpenRouter client service
The src/services/openrouter-service.ts module wraps the OpenAI SDK configured to point at https://openrouter.ai/api/v1.
ts
import OpenAI from "openai";import { generateId, now, calculateCostFromTokens, type CostSpan } from "@reaatech/llm-cost-telemetry";import { Logger } from "@reaatech/tool-use-firewall-core";import type { ProxyRequest } from "../lib/types.js";const log = new Logger("OpenRouterService");let openrouterClient: OpenAI | null = null;export function createOpenRouterClient(): OpenAI { if (openrouterClient) return openrouterClient; openrouterClient = new OpenAI({ baseURL: "https://openrouter.ai/api/v1", apiKey: process.env.OPENROUTER_API_KEY ?? "", maxRetries: 2, timeout: 30000, defaultHeaders: { "HTTP-Referer": "https://github.com/reaatech/openrouter-pii-gateway", "X-OpenRouter-Title": "OpenRouter PII Redaction Gateway", }, }); return openrouterClient;}
The client is cached in a module-level variable so subsequent requests reuse the same TCP connection. The HTTP-Referer and X-OpenRouter-Title headers are required by OpenRouter for traffic analytics. A resetClientForTesting function clears the singleton between tests:
ts
export function resetClientForTesting(): void { openrouterClient = null;}
The forwardToOpenRouter function sends the cleaned request:
Expected output:createOpenRouterClient() returns an OpenAI instance with baseURL set to https://openrouter.ai/api/v1.
Step 6: Wire the proxy route handler
The main entry point is app/api/proxy/route.ts. This Next.js App Router route handler receives POST requests, validates them, runs the guardrail chain, checks the tenant budget, forwards to OpenRouter, records the cost span, and returns the response.
ts
import { type NextRequest, NextResponse } from "next/server";import path from "node:path";import { buildGuardrailChain, runGuardrails } from "../../../src/services/guardrail-service.js";import { createOpenRouterClient, forwardToOpenRouter, createCostSpan,} from "../../../src/services/openrouter-service.js";import { createCostTracker, recordCostSpan, checkBudget } from "../../../src/services/costing-service.js";import { ProxyRequestSchema, type ProxyRequest } from "../../../src/lib/types.js";import { TimeoutError, BudgetExceededError } from "@reaatech/guardrail-chain";import OpenAI from "openai";let guardrailChain: Awaited<ReturnType<typeof buildGuardrailChain>> | null = null;let costTracker: ReturnType<typeof createCostTracker> | null = null;let openrouterClient: OpenAI | null = null;
Module-level lazy caching ensures the chain, client, and tracker are built once and reused across requests.
The POST handler validates, runs the guardrail chain, and applies PII redaction to the message body:
ts
export async function POST(req: NextRequest): Promise<NextResponse> { try { const raw: unknown = await req.json(); let body: ProxyRequest; try { body = ProxyRequestSchema.parse(raw); } catch (err: unknown) { const zodErr = err as { issues?: Array<{ message: string; path: (string | number)[] }> }; return NextResponse.json( { error: "Invalid request", details: zodErr.issues ?? [] }, { status: 400 }, ); } if (!guardrailChain) { const configPath = path.resolve(process.cwd(), "src/config/rules.yaml"); guardrailChain = await buildGuardrailChain(configPath); } const guardrailResult = await runGuardrails( guardrailChain, JSON.stringify(body.messages), { userId: body.tenantId }, ); if (!guardrailResult.success) { return NextResponse.json( { blocked: true, reason: guardrailResult.failedGuardrail ?? "guardrail_blocked" }, { status: 403 }, ); } if (guardrailResult.output && typeof guardrailResult.output === "string" && guardrailResult.output !== JSON.stringify(body.messages)) { try { const parsed: unknown = JSON.parse(guardrailResult.output); if (Array.isArray(parsed)) { const validated = parsed.filter( (m: unknown): m is { role: string; content: string } => typeof m === "object" && m !== null && "role" in m && "content" in m, ); body = { ...body, messages: validated }; } } catch { // redacted output is not valid JSON } }
If the guardrail chain returns success: false (injection detected or tool-use violation), the proxy responds with a 403 and the guardrail ID that triggered the block. For PII redaction, success is true and the redacted messages replace the originals in the body.
After the guardrails pass, the proxy checks the tenant budget, forwards, and records the cost:
Expected output:createCostTracker() returns a tracker with both langfuse and telemetryConfig properties. checkBudget allows requests for the default tenant unconditionally.
Step 8: Configure environment variables
Create .env.example with placeholders for every value the gateway reads at runtime:
env
# Env vars used by openrouter-pii-redaction-gateway-for-smb-support.# Keep placeholders only — never commit real values.NODE_ENV=developmentOPENROUTER_API_KEY=<your-openrouter-key>LANGFUSE_PUBLIC_KEY=<your-langfuse-public-key>LANGFUSE_SECRET_KEY=<your-langfuse-secret-key>LANGFUSE_HOST=https://cloud.langfuse.comGUARDRAIL_CHAIN_BUDGET_MAX_LATENCY_MS=1000GUARDRAIL_CHAIN_BUDGET_MAX_TOKENS=8000GUARDRAIL_CHAIN_BUDGET_SKIP_SLOW=trueDEFAULT_DAILY_BUDGET=5.0ADMIN_API_KEY=<your-admin-api-key>OTEL_SERVICE_NAME=openrouter-pii-gateway
Copy this to .env and fill in your real keys. The ADMIN_API_KEY is used by the admin config API to authenticate configuration changes.
Step 9: Add the admin config API route
The admin API at app/api/admin/config/route.ts provides GET and POST endpoints for per-tenant guardrail configuration.
The POST endpoint requires an x-api-key header matching ADMIN_API_KEY. It validates the incoming config with both Zod and validateConfigSafe from @reaatech/guardrail-chain-config, then stores the config in a runtime array using upsert semantics. For external consumers, validateConfigSafe is re-exported from the route module.
Expected output:GET /api/admin/config returns {"tenants":[]}. POST with a valid body and correct API key returns {"success":true,"config":{...}}. POST without a key returns 401.
Step 10: Create the admin dashboard pages
The admin dashboard at app/admin/page.tsx is a server component that fetches the initial tenant list and renders a configuration form.
Expected output: Navigating to /admin shows a form with fields for Tenant ID, PII mode (redact/block/passthrough), injection threshold slider, and daily budget input. Submitting the form fires a POST to /api/admin/config with the x-api-key header.
Step 11: Set up instrumentation
The src/instrumentation.ts module initializes Langfuse on Node.js server startup. It uses a dynamic import to avoid pulling Langfuse into the Edge runtime.
ts
export async function register(): Promise<void> { if (process.env.NEXT_RUNTIME === "nodejs") { const Langfuse = (await import("langfuse")).default; new Langfuse({ publicKey: process.env.LANGFUSE_PUBLIC_KEY ?? "", secretKey: process.env.LANGFUSE_SECRET_KEY ?? "", host: process.env.LANGFUSE_HOST ?? "https://cloud.langfuse.com", } as Record<string, string>); }}
Enable the instrumentation hook in next.config.ts:
ts
import type { NextConfig } from "next";const nextConfig: NextConfig = { experimental: { instrumentationHook: true, } as NextConfig["experimental"],};export default nextConfig;
Without instrumentationHook: true, the register() function in instrumentation.ts is dead code and never fires.
Step 12: Run the tests
The project ships 89 tests across 8 test files covering every service and route handler. Run them all with:
terminal
pnpm vitest run --coverage --reporter=json --outputFile=vitest-report.json
Expected output: All 89 tests pass (0 failed). Coverage on the runtime source files (src/**/*.ts and app/**/route.ts) meets the 90% threshold for lines, branches, functions, and statements.
Instrumentation register handles both the nodejs runtime and non-node runtime gracefully
tests/index.test.ts
1
Package entry point exports expected functions and types
Next steps
Add streaming support — The forwardToOpenRouterStream function in openrouter-service.ts is already written. Wire it into the proxy handler when the request includes stream: true and return a ReadableStream response.
Persist budget tracking — The current checkBudget returns spent = 0 for non-default tenants. Replace this with a real query against Langfuse traces or a database to enforce daily spend caps.
Deploy as edge middleware — Move the guardrail checks to middleware.ts so they apply to every request that hits the /api/* path, not just the proxy endpoint, and add rate limiting per tenant via your platform’s edge storage.
Integrate Presidio Analyzer — The guardrail service has a tryLoadPresidio() function that attempts a 50ms-timed dynamic import of @presidio-dev/hai-guardrails. Wire a real Presidio Analyzer (via Docker or a remote endpoint) for NLP-based PII detection that goes beyond regex.