JORVEL

@jorvel/security

Edge-runtime-safe primitives (Web Crypto, no Buffer, no node:crypto) for federation security. Every helper is tree-shakable and side-effect-free.

Runtime guarantees

Every export runs unchanged on Vercel Edge, Cloudflare Workers, Deno Deploy, and Node. No polyfills required — the package only uses crypto.subtle, fetch,TextEncoder, and standard JS.

CSP

Build a strict-dynamic + nonce CSP that lets federated remoteEntry scripts execute while blocking everything else. cspMiddleware wraps Express/Connect; edge handlers call buildCsp directly.

ts
buildCsp(opts?: {
  nonce?: string;                  // base64url; required for strict-dynamic
  strictDynamic?: boolean;         // default true when nonce present
  strictStyles?: boolean;          // drops 'unsafe-inline' from style-src
  reportTo?: string;
  extra?: Record<string, string[]>; // merged directive overrides
}): string;

cspMeta(opts?: Parameters<typeof buildCsp>[0]): string;  // <meta http-equiv="...">
generateNonce(bytes?: number): string;                   // default 16 bytes → 22-char base64url

cspMiddleware(opts: {
  remotes?: string[];
  reportOnly?: boolean;
  extra?: Record<string, string[]>;
}): RequestHandler;                                       // Express/Connect

cspFastifyHook(opts): FastifyPreHandler;
cspHeaderFactory(opts): (req: { url: string }) => { header: string; nonce: string };

Wiring a strict-dynamic CSP into an SSR response

ts
import { buildCsp, generateNonce, safeJsonForScript } from '@jorvel/security';

export async function handler(req: Request): Promise<Response> {
  const nonce = generateNonce();
  const csp = buildCsp({
    nonce,
    strictDynamic: true,
    extra: { 'connect-src': ['https://api.acme.com'] },
  });

  const html = template
    .replace('__NONCE__', nonce)
    .replace('__STATE__', safeJsonForScript(state));

  return new Response(html, {
    headers: {
      'content-type': 'text/html; charset=utf-8',
      'content-security-policy': csp,
    },
  });
}

SRI

Subresource Integrity hashes for federation entry scripts. Generate the hash at build time with sriHashFromUrl or compute on-demand from in-memory content.

ts
sriHash(content: string | ArrayBuffer | Uint8Array, algo?: 'sha256' | 'sha384' | 'sha512'): Promise<string>;
sriAttributes(content, algo?, crossorigin?: 'anonymous' | 'use-credentials'): Promise<{ integrity: string; crossorigin: string }>;
sriHashFromUrl(url: string, opts?: { algo?: 'sha256' | 'sha384' | 'sha512'; allowHttp?: boolean }): Promise<string>;

Build-time manifest of remote SRI hashes

ts
import { sriHashFromUrl } from '@jorvel/security';

const remotes = [
  'https://cdn.acme.com/dashboard/remoteEntry.js',
  'https://cdn.acme.com/billing/remoteEntry.js',
];

const manifest = Object.fromEntries(
  await Promise.all(remotes.map(async (url) => [url, await sriHashFromUrl(url, { algo: 'sha384' })])),
);
// → { 'https://cdn.acme.com/dashboard/remoteEntry.js': 'sha384-...' }

Allowlist

Single-label (*) and multi-label (**) wildcards. Defense-in-depth even with SRI — rejects URLs at the registry level before the network request fires.

ts
new RemoteAllowlist(rules: string[], opts?: { schemes?: string[] });
// rules: 'https://cdn.acme.com'      — exact
//        'https://*.acme.com'        — single-label wildcard
//        'https://**.cdn.acme.com'   — multi-label wildcard

allow.allows(url: string, name?: string): boolean;
allow.assertAllowed(url: string, name?: string): void;   // throws on miss
allow.isAllowed(url: string, name?: string): boolean;
ts
import { RemoteAllowlist } from '@jorvel/security';
import { getRemoteRegistry } from '@jorvel/runtime';

const allow = new RemoteAllowlist([
  'https://cdn.acme.com',
  'https://*.preview.acme.com',
]);

getRemoteRegistry().setGuard((url) => allow.allows(url));

Rate limiting

Token-bucket rate limiter for protected endpoints (auth, write paths). In-memory LRU by default; bring your own store for distributed enforcement.

ts
createRateLimitGuard(opts: {
  capacity: number;                // burst
  refillPerSec: number;            // sustained
  keyFor: (req: { url: string; headers: Record<string, string> }) => string;
  store?: RateLimitStore;          // default: in-memory LRU
  maxKeys?: number;                // default 10_000
}): (req) => {
  allowed: boolean;
  headers: Record<string, string>; // X-RateLimit-*
  response?: { status: 429; headers; body };
};

interface RateLimitStore {
  consume(key: string, amount: number): Promise<{ remaining: number; resetMs: number }>;
}

Edge worker usage

ts
const limiter = createRateLimitGuard({
  capacity: 30,
  refillPerSec: 5,
  keyFor: (req) => req.headers['cf-connecting-ip'] ?? 'anon',
});

export default {
  async fetch(req) {
    const decision = await limiter({ url: req.url, headers: Object.fromEntries(req.headers) });
    if (!decision.allowed) {
      return new Response('Rate limit', { status: 429, headers: decision.headers });
    }
    return handle(req);
  },
};

Audit log

Structured audit logger with built-in PII redaction. Multiple sinks; the buffer sink is ideal for tests.

ts
new AuditLogger(opts: {
  sinks: AuditSink[];
  redactKeys?: string[];           // case-insensitive metadata key match
  defaultRedactions?: boolean;     // password, token, cookie, secret — default true
});

audit.success(entry: AuditEntry): Promise<void>;
audit.denied(entry: Omit<AuditEntry, 'metadata'> & { reason: string }): Promise<void>;
audit.error(entry: AuditEntry & { error: unknown }): Promise<void>;

interface AuditSink { (entry: NormalizedEntry): Promise<void> | void }
bufferSink(): { sink: AuditSink; drain(): NormalizedEntry[] };

OAuth helpers

Stateless helpers for PKCE flow + state-cookie protection. The package never stores anything itself — you persist via cookies / KV.

ts
generatePkcePair(): Promise<{ verifier: string; challenge: string }>;
buildAuthorizeUrl(opts: { authorizeEndpoint, clientId, redirectUri, scope, state, codeChallenge }): string;
exchangeCodeForToken(opts: { tokenEndpoint, clientId, redirectUri, code, verifier }): Promise<TokenResponse>;

constantTimeEqual(a: string, b: string): boolean;       // for state-cookie comparison
generateStateValue(bytes?: number): string;

Sessions

Stateless signed-cookie sessions (HMAC-SHA256). See Authentication.

ts
class SessionManager<T> {
  constructor(opts: { secret: string; verifySecrets?: string[]; cookieName?; maxAge?; cookie?; now? });
  sign(data: T): Promise<string>;                 // payload.signature token
  verify(token): Promise<T | null>;               // null if invalid/expired/tampered
  seal(data: T): Promise<string>;                  // Set-Cookie (HttpOnly Secure SameSite=Lax)
  destroy(): string;                               // Set-Cookie clearing the session
  read(src: string | Request): Promise<T | null>;
  requireUser(src: string | Request): Promise<T>;  // throws SessionRequiredError (status 401)
}
getSession<T>(src, opts): Promise<T | null>;
requireUser<T>(src, opts): Promise<T>;

// OAuth provider presets + profile fetch
OAUTH_PROVIDERS.github | .google | .microsoft;     // { authorizationEndpoint, tokenEndpoint, userinfoEndpoint, defaultScope }
fetchUserInfo<T>(provider, accessToken, fetcher?): Promise<T>;

CSRF

Signed double-submit cookie. See Forms & CSRF.

ts
issueCsrfToken(opts?: { cookieName?; headerName?; fieldName?; secret?; cookie? }):
  Promise<{ token: string; setCookie: string }>;

verifyCsrf(req: { method; headers }, opts?, submittedToken?):
  Promise<{ ok: true } | { ok: false; reason: 'missing-cookie'|'missing-token'|'mismatch'|'bad-signature' }>;

csrfFieldName(opts?): string;

// low-level cookie/crypto primitives
serializeCookie(name, value, opts?): string;
parseCookieHeader(header): Record<string, string>;
randomToken(bytes?): string;
hmacSign(data, secret): Promise<string>;  hmacVerify(data, sig, secret): Promise<boolean>;
timingSafeEqual(a, b): boolean;

Sandbox bridge

Postmessage bridge between a sandboxed iframe and the host. The bridge enforces an origin allowlist and a schema per message kind. Use for third-party widgets where ShadowRemoteisn't isolation enough.

ts
createSandboxBridge<E>(opts: {
  iframe: HTMLIFrameElement;
  origin: string | string[];        // exact or wildcard
  schemas: { [K in keyof E]?: Validator<E[K]> };
}): SandboxBridge<E>;

interface SandboxBridge<E> {
  send<K extends keyof E>(kind: K, payload: E[K]): void;
  on<K extends keyof E>(kind: K, handler: (payload: E[K]) => void): () => void;
  destroy(): void;
}

Sanitize

ts
escapeHtml(s: string): string;
safeJsonForScript(value: unknown): string;
isSafePathname(path: string): boolean;
pruneProtoKeys<T extends object>(obj: T): T;     // strips __proto__, constructor, prototype

safeJsonForScript

Use whenever you inline server-rendered state into a <script> tag. Escapes </script>, line separator (U+2028), and paragraph separator (U+2029) — the three sequences that turn a JSON blob into a script-injection vector.

tsx
<script id="__STATE__" type="application/json">
  {safeJsonForScript(state)}
</script>

Composite security middleware

cspMiddleware, cspFastifyHook, and cspHeaderFactory are pre-baked compositions of CSP + nonce + Referer-Policy + X-Content-Type-Options. They cover ~80% of production header policies; reach for the lower-level builders when you need custom directives.