Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Relintio

@relintio/vercel

npm quickstart license

Relintio as Vercel Edge Middleware.


Vercel Edge Middleware is the only place on Vercel where a request can be refused before it costs anything. A check inside a route handler has already paid for the invocation, the cold start and whatever the handler read before it decided; middleware runs at the edge PoP nearest the visitor and answers there, so a blocked request never reaches a function at all. The entry point is relintio(), which is also the default export: it builds one guard and returns the middleware function Vercel calls, returning a Response when the request is refused and undefined when it may continue. The protocol itself — passports, rule matching, signing, the fail-open posture — lives in the engine, @relintio/edge-core, which this package configures for Vercel and nothing more.

// middleware.js
import { relintio } from '@relintio/vercel';

export const config = {
  matcher: '/((?!_next/static|_next/image|favicon.ico).*)',
};

export default relintio();

Installation

npm install @relintio/vercel

One dependency, @relintio/edge-core at ^1.0.0, and nothing else. ESM only — "type": "module" with an exports map that has no require condition — and engines.node is >=18, the first Node with fetch, Request and Response as globals.

Set RELINTIO_LICENSE_KEY in Project → Settings → Environment Variables, for every environment the middleware runs in. Get the key from Dashboard → Deployment → Vercel.

Registration

The file is middleware.js (or middleware.ts) at the project root, and the config.matcher decides which requests reach it. Both parts matter, and the matcher is the one that goes wrong quietly: a path it excludes never invokes the middleware, so it is not scored, not reported, and not visible in the console — it looks protected because the deployment is.

Exclude the things that are not worth an invocation. _next/static, _next/image and favicon.ico are the sample's exclusions because static assets are served without ever reaching your application, and running the guard over them buys nothing while costing a middleware invocation each.

Do not exclude anything you want enforced, and in particular do not exclude a path a challenged visitor can return to. The token exchange happens inside the guard, after the path filter: a visitor coming back from the hosted challenge carries ?up_token=… in the query string, and if that path is outside the matcher nobody swaps the token for a passport cookie, so the visitor is scored from scratch on the next page and challenged again. API routes are the usual casualty of a matcher narrowed to page paths.

Call relintio() once, at module scope, as the sample does. The guard it builds holds the ruleset cache, the fetch timestamp and the single-flight refresh promise as instance state; building one per request throws all three away and calls /agent/verify before it can decide anything. The licence key is read in that same call, so a changed environment variable takes effect on redeploy rather than on the next request.

To keep middleware you already have, wrap it. Relintio assesses first and answers on its own if the request is refused; otherwise yours runs and its response is returned untouched.

import { NextResponse } from 'next/server';
import { withRelintio } from '@relintio/vercel';

export default withRelintio(async (request) => {
  return NextResponse.next();
});

The order is not cosmetic. Composed the other way round, your middleware would have already redirected, rewritten, or read a feature flag out of a database on behalf of a request we were about to block — test/middleware.test.mjs asserts the wrapped handler never runs on a blocked request.

Configuration

Every option is passed to relintio() or withRelintio(handler, options) and read once, when the guard is constructed.

Option Default Meaning
licenseKey RELINTIO_LICENSE_KEY from process.env Required. The UP_LIVE_… licence key. Secret — see below.
apiUrl RELINTIO_API_URL, else https://api.relintio.com/v1 Control-plane base. Trailing slashes are stripped.
onlyPaths [], meaning every path Prefixes to protect. Empty protects everything the matcher let through.
exceptPaths [] Prefixes to skip. Checked before onlyPaths and wins over it.
rulesTtlSeconds 60 How long a fetched ruleset is trusted. 0 attempts a refresh on every request, still single-flight. A negative or non-numeric value falls back to 60 rather than to zero.

agentKind is fixed to vercel and is not an option; it is what tells the dashboard which integration reported a decision.

Prefer config.matcher to exceptPaths where you can — a path excluded by the matcher costs nothing at all, while one excluded by exceptPaths still invokes the middleware and then returns after a startsWith. exceptPaths earns its place for paths that must stay inside the matcher for other reasons; a skipped path does not even fetch a policy, which the engine's tests pin.

The licence key is a secret. It is the HMAC key that mints challenge passports and signs every outbound call, so anyone holding it can forge both and walk through the WAF. It belongs in Vercel's environment, never in a repository, and never in anything shipped to a browser — browsers take a publishable key (pk_live_…), which can do exactly one thing, ask for a verdict, and that is what the React SDK is for. If your front end also handles challenges, that half takes the publishable key and the two must not be swapped.

What happens on a request

The engine runs the path filter, then the ?up_token exchange, then the passport cookie, then the cached policy, bypass_paths, whitelist_ips, and finally the synced rules; the first step that answers wins. A block is a 403 with a self-contained HTML page and Cache-Control: no-store, a challenge is a 302 to the hosted challenge carrying the full request URL as return_url, and everything else continues to your application.

What this package adds is the Vercel shape of that, and it is four small things that each fail while looking like they work.

Vercel detail Behaviour
Continue signal undefined, never null. Some of Vercel's handler shapes coerce null into an empty 200, which serves a blank page for every allowed request
waitUntil Taken from the context and bound to it, so the invocation stays alive for the decision report. Absent context is handled, not required
Address request.ip when the runtime populates it, otherwise the engine's header fallback
Country request.geo?.country, otherwise the x-vercel-ip-country header, otherwise XX

Edge cases

A wrong credential fails open and says so once. A key that is empty or begins pk_ puts the guard into an unusable state: it writes a single console.error naming the two key types, then returns undefined for every request without transmitting anything. The site serves and nothing is protected, and the only evidence is that one line in the function log. Check the console shows traffic after the first deploy rather than assuming a green deployment means an enforcing one.

The first request through a cold isolate waits for the policy. There is no ruleset in a fresh isolate, so that request awaits /agent/verify, which is aborted at 3000 ms. If it arrives, the request is enforced against it; if it does not, the request is allowed. A burst against a cold isolate collapses into one call rather than one per request, but that one request pays the round trip.

Everything is cached per isolate. Vercel starts isolates per region and per burst, so the ruleset is fetched once per isolate rather than once per deployment, and a rule changed in the dashboard takes effect within rulesTtlSeconds in each isolate independently rather than everywhere at once.

Roughly one allowed request in a hundred appears in the console. Allows are sampled at a fixed 1%, which the platform multiplies back up; blocks and challenges are never sampled. A console showing far fewer allows than you served is the sampling, not a reporting fault.

The address comes from headers a client can set. When request.ip is not populated the engine reads cf-connecting-ip, x-real-ip, then the first entry of x-forwarded-for, with no notion of a trusted hop. On Vercel these are overwritten at the edge, which is what makes them usable; behind a proxy of your own that appends rather than overwrites, a visitor can choose the address an ip rule — or whitelist_ips, an exact string match on the same value — is evaluated against.

Nothing is injected into your responses. The engine only ever answers or steps aside. It does not buffer, rewrite or splice a script into HTML on the way out, so a streaming or text/html response passes through with its body untouched — unlike the origin agents, which do inject.

Telemetry is best-effort without the context. The report is never awaited on the request path, so if the context object is not passed through to the wrapped handler the isolate may be torn down before the report lands. The engine also survives a host that throws from waitUntil, which several edge runtimes do when it is called at the wrong moment: the exception is caught at the outermost boundary and the request is released.

Every failure releases the request. An unreachable control plane, a non-2xx answer, an unparseable body, a 200 carrying no rules array, or any exception anywhere inside protect — all of them return undefined and the page serves. A cached ruleset survives all of them; nothing is ever replaced by a failure. A security agent that stops a page because it could not reach its control plane has turned our outage into the customer's, which is the worse failure.

In production

Start with the matcher wide and enforcement observed rather than the reverse. The dashboard shows what was scored and why, and the question worth settling before you tighten anything is whether the traffic you expect is being seen at all — a matcher that quietly excludes your API routes looks identical to a quiet week.

Links

Security reports go to support@relintio.com, not to a public issue.

License

Proprietary. package.json declares SEE LICENSE IN LICENSE, and LICENSE is the Relintio Proprietary License: all rights reserved, with permission granted to use the software solely to integrate and operate the Relintio service under a valid, active licence obtained from Relintio. It does not permit copying, redistribution, modification, reverse engineering or derivative works, it forbids removing the proprietary notices, and it disclaims all warranties. Licensing enquiries go to Relintio.

About

Relintio as Vercel Edge Middleware. Blocks at the edge PoP before the request costs a function invocation.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages