Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Relintio

@relintio/edge-core

npm docs license

The Relintio agent protocol for Web-standard edge runtimes.


This is not a package to install in an application. It is the shared agent protocol that @relintio/vercel and @relintio/supabase are built on — @relintio/firebase is not one of them, despite sitting beside them in the catalog, because Cloud Functions run Node rather than a Web-standard edge runtime and it is built on @relintio/agent instead: install one of those unless you are writing an integration of your own, in which case this is the thing to write it against. The entry point is the EdgeGuard class and its one method, protect(request, context), which takes a Request and returns either a Response to send instead of running your application or null to let the request through. It sits in front of the handler, decides from a ruleset it fetched from the control plane and cached in the isolate, and never throws.

import { EdgeGuard } from '@relintio/edge-core';

// Module scope, not per request. The ruleset cache, the fetch timestamp and
// the single-flight refresh are all instance state.
const guard = new EdgeGuard({
  licenseKey: process.env.RELINTIO_LICENSE_KEY,
  exceptPaths: ['/health'],
});

export default async function handler(request, context) {
  const refused = await guard.protect(request, context);

  return refused ?? new Response('hello');
}

Installation

npm install @relintio/edge-core

ESM only — "type": "module" and an exports map with no require condition. engines.node is >=18, which is the first Node with fetch, Request and Response as globals. There are no dependencies, production or development; the test suite is node --test alone.

Entry Exports
@relintio/edge-core EdgeGuard, evaluate, matchRule, the passport and signing functions, and the AGENT_VERSION, ALLOW_SAMPLE_RATE, RULES_TTL_SECONDS, CONTROL_PLANE_TIMEOUT_MS and ACTION_SCORES constants
@relintio/edge-core/crypto the same crypto surface plus PASSPORT_SKEW_SECONDS and the raw sha256Hex, hmacSha256, hmacVerify
@relintio/edge-core/rules rule matching plus the BLOCK_SCORE and CHALLENGE_SCORE thresholds, which the root entry does not re-export

Registration

Construct the guard once, at module scope, and call protect in your platform's middleware entry — the place that runs before the handler and can answer instead of it. Two things break if it goes elsewhere.

Construct it per request and you lose the cache. The ruleset, the fetch timestamp and the in-flight promise are private instance fields, so a fresh guard has an empty ruleset and calls /agent/verify before it can decide anything — on every request, with none of the single-flight collapsing that makes a cold isolate cheap.

Ignore the return value and you lose enforcement. A non-null Response is the block page or the challenge redirect; discarding it and running the handler anyway serves every request the guard refused, silently, with the decision already reported as a block.

Pass the platform's context object through. waitUntil keeps the isolate alive long enough for the decision report to land, and ip — when the platform resolves one — is trusted ahead of any header. onlyPaths and exceptPaths are evaluated first of all, before the credential is used or a policy is fetched, so a skipped path costs one startsWith and makes no network call.

Configuration

Every field is read in the constructor, and nothing else is.

Option Default Meaning
licenseKey Required. The UP_LIVE_… licence key. Keys every passport and every outbound signature. Secret — see below.
apiUrl https://api.relintio.com/v1 Control-plane base. Trailing slashes are stripped once, in the constructor.
agentKind edge Sent on /agent/verify and on every decision so the dashboard can tell the three integrations apart. Truncated to 32 characters.
onlyPaths [], meaning all paths Prefixes to protect. Empty means everything. A non-array becomes empty.
exceptPaths [] Prefixes to skip. Checked before onlyPaths and wins over it.
rulesTtlSeconds 60, the exported RULES_TTL_SECONDS 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 the default rather than to zero.

The licence key is a secret. It is the HMAC key that mints challenge passports and signs outbound calls, so anyone holding it can forge both and walk through the WAF. It belongs in the runtime's environment, never in a repository, and never in anything that reaches a browser — browsers get a publishable key (pk_live_…), which can do exactly one thing, ask for a verdict, and that is what the React and Shopify SDKs take.

The guard checks which one it was handed. A key that is empty or begins pk_ sets isUsable() to false, writes one console.error naming the two key types and where to find the right one, and turns protect into a function that returns null without ever calling out — the tests assert fetch is never reached. This is the inverse of the browser SDKs' check, for the same reason: a publishable key cannot sign a passport, so an edge deployment holding one would mint passports every origin agent rejects, and the symptom is every visitor challenged on every request, forever, with nothing in any log explaining it.

What it needs from the runtime

Nothing outside the Web platform. src/ reaches for Request, Response, Headers, URL, fetch, AbortController, setTimeout, TextEncoder/TextDecoder, btoa/atob, crypto.getRandomValues, and four crypto.subtle operations — digest, importKey, sign and verify. There is no node: import anywhere in the package.

That is why this sits beside the Node agent rather than inside it. @relintio/agent caches the ruleset on disk and uses node:crypto; an edge isolate has neither, so the protocol is implemented a second time against crypto.subtle. Anywhere that surface is present will run it — Vercel Edge Middleware, Supabase Edge Functions, Firebase, Deno, Bun, Cloudflare Workers, and Node 18 or newer.

What happens on a request

protect runs these in order and stops at the first that answers.

  1. Path filter. exceptPaths prefix match, then onlyPaths when it is non-empty. A skipped path returns null immediately.
  2. Token exchange. A ?up_token in the query string is verified and swapped for a passport cookie. This is ahead of the policy fetch, so a visitor who has just solved a challenge is admitted even while the control plane is unreachable.
  3. Passport cookie. A valid relintio_passport returns null — someone who has already proved themselves is not re-scored, and no /agent/verify call is made on their behalf.
  4. Policy. The cached ruleset, refreshed when it is older than the TTL. No policy at all means null.
  5. bypass_paths from the fetched settings, then whitelist_ips, an exact string match against the resolved address.
  6. evaluate over the synced rules, and the verdict below.

The address is context.ip when the platform supplies one, otherwise the first of cf-connecting-ip, x-real-ip, or the leading entry of x-forwarded-for, otherwise the empty string. The reported country is context.country, then cf-ipcountry, then x-vercel-ip-country, then XX.

Rule matching

matchRule implements contracts/rule-conditions-v1.json, and test/rules.test.mjs loads that file and runs its vectors rather than restating them — a thirteenth implementation asserting against its own idea of the semantics would only add a fourteenth opinion. The test also asserts at least 25 vectors were loaded, so a moved or emptied contract fails loudly instead of passing vacuously.

The types are ip, path, user_agent and header. A header pattern with no colon tests that the header is present with a non-empty value; Name: value tests the named header, with whitespace around the colon trimmed and the name matched case-insensitively. Headers are read through .get() when given a Headers object and by a case-insensitive key scan when given a plain object, so the matcher works on hosts that hand you either. The conditions are equals (exact, case-insensitive), contains (substring, case-insensitive) and regex, compiled with RegExp; a pattern that does not compile never matches and never throws.

An unrecognised condition is rejected before the type is even looked at, and an unrecognised type never matches. Both are the contract rather than caution: four SDKs had no header branch at all, so a rule authored in the dashboard scored zero and reported nothing in those runtimes, and three others accepted regex and then substring-matched it, so a rule written ^/admin$ matched /administrator.

The verdict

Scores from every matching rule are summed. The action escalates on the way through — a matched block rule is never de-escalated by a later one — and the total is then compared against the thresholds.

Condition Verdict
A matched rule with action: "block", or a total score of 100 or more block
A matched rule with action: "challenge", or a total score of 50 or more challenge
Otherwise allow

The contract's scores are block 100, challenge 60 and log 0, and test/rules.test.mjs asserts ACTION_SCORES against those numbers rather than against a copy. Accumulation is the point: two challenge rules matching the same request sum to 120 and become a block.

Verdict Response
allow null, and the request runs. Reported at the sample rate.
challenge 302 to <apiUrl without its trailing /v1>/challenge?return_url=<the full request URL>, Cache-Control: no-store
block 403 with a self-contained text/html page, Cache-Control: no-store

There is no SLOW tier and no DECOY tier here, and no built-in scoring heuristics — no user-agent lists, no rate limiter. Every point of every score comes from a rule the control plane sent, so a deployment that has not yet fetched a policy, or whose licence has no rules, enforces nothing. reportsAction still recognises DECOY and SLOW, and the tests pin it for those names, because it is the reporting predicate shared with agents that do produce them.

A challenge that cannot be presented is allowed, not blocked. When the fetched settings carry challenge_enabled: false the request goes through and is reported as ALLOW with the reason Challenge unavailable — a challenge the customer has turned off is not a reason to refuse their traffic, and the score is still recorded, which is evidence either way. The check is strictly === false, so an answer that omits the field challenges.

The passport

A visitor who solves the challenge returns with ?up_token=<passport>. The guard verifies it, mints a fresh passport with the ttl the token asked for, sets it as relintio_passport, and 302s to the same URL with only up_token removed — every other query parameter survives, and the token does not stay in the address bar as a link someone could share.

The cookie is Path=/; Max-Age=<ttl>; HttpOnly; SameSite=Lax, plus Secure when the request URL is https:. The ttl is clamped to 300–604800 seconds, with an unusable value defaulting to 86400: the token arrives signed, but a bug upstream should not be able to mint a ten-year cookie.

An up_token that does not verify is a 403 Invalid Token, not a pass-through. It is the one place the guard answers rather than releasing, and it is deliberate — the only way to hold a valid token is to have just passed the challenge.

A passport is v2.<payload>.<signature>: base64url with the padding stripped, over the exact JSON key order {v, exp, b}, signed HMAC-SHA256 under the licence key. b is the first 16 hex characters of sha256(licenceKey|userAgent|acceptLanguage), and binding to the client is what stops a lifted cookie being replayed from elsewhere. Verification is offline — no network call — using crypto.subtle.verify for the signature, which is specified constant-time, and for the binding a loop that runs to the end regardless of the first mismatch. exp is allowed 60 seconds of skew against the challenge server. Anything not beginning v2., and anything without exactly three dot-separated parts, is refused outright.

test/parity.test.mjs is what makes a second implementation of that format safe. It mints with this package and verifies with the Node agent, mints with the Node agent and verifies here, and asserts the two produce byte-identical passports for identical inputs — not merely mutually acceptable, which would leave them compatible by accident of what each one happens to tolerate. It does the same for signRequest and for the ttl clamp. What it protects against is a visitor solving a challenge at the Vercel edge and then reaching an origin running the Node or PHP agent: if the key order, the base64 padding or the bytes the HMAC covers differ anywhere, that visitor is challenged again on every request forever, and it presents as a broken challenge rather than as a serialisation bug.

Request signing

Both outbound calls — /agent/verify and /agent/log — carry:

X-Relintio-Timestamp: 1785120000
X-Relintio-Nonce:     <24 base64url chars, from 18 random bytes>
X-Relintio-Signature: v1=<64 hex>

The signature is HMAC-SHA256("v1:" + timestamp + ":" + nonce + ":" + sha256(body), licenceKey). body is the exact string handed to fetch: the private #call runs JSON.stringify once and gives that one string to both the signer and the request. Hashing an object and letting the client re-serialise it signs a byte sequence that never reached the wire; the server hashes what it received, so it rejects everything, with nothing in any log to explain it. Each call takes a fresh nonce from crypto.getRandomValues and is aborted at CONTROL_PLANE_TIMEOUT_MS — 3000 ms — by an AbortController whose timer is cleared in a finally.

Failing open

Every failure path releases the request, because a security agent that stops a page it could not score has turned an outage of ours into an outage of the customer's.

Path Behaviour
Wrong or missing credential protect returns null, and nothing is transmitted
Anything at all throwing inside protect caught at the outermost boundary; returns null
/agent/verify unreachable, timed out, non-2xx, or unparseable the cached policy stays; nothing is replaced
An answer carrying no rules array rejected as not a policy; the cache survives
No policy yet — cold isolate, or a control plane that has never answered returns null
A malformed ruleset, or a rule that is not an object treated as no rules; verdict allow
An uncompilable regex pattern that rule never matches and never throws
/agent/log failing, or the host throwing from waitUntil swallowed; the decision already made still stands

The rules guard is the one worth dwelling on. A 200 carrying {"status": "error"}, {}, {"rules": null} or a proxy's idea of a success page is not a policy, and applying it as one would replace the customer's protection with nothing at exactly the moment something is already wrong. An explicitly empty array is a policy and is applied. test/guard.test.mjs runs each of those shapes and asserts the previous ruleset still blocks.

Edge cases

The resolved address comes from headers a client can set. cf-connecting-ip, x-real-ip and x-forwarded-for are read with no notion of a trusted hop. On a platform that overwrites them this is right; on one that appends, a visitor can choose their own address and evade an ip rule — or claim one in whitelist_ips, which is an exact string match on that same value. Pass context.ip whenever the platform resolves it, because it is trusted ahead of every header.

Everything is per isolate. The ruleset cache and the single-flight refresh live on the instance, so a platform that starts an isolate per region or per burst fetches the ruleset once per isolate rather than once per deployment. A rule changed in the dashboard takes effect within rulesTtlSeconds per isolate, not globally at once.

Allowed requests are reported at 1%. ALLOW_SAMPLE_RATE is a constant and deliberately not an option: the platform multiplies reported allows back up by it, and an install sampling differently would report a number the platform then corrects with the wrong figure. Blocks and challenges are never sampled — they are the security record. The console therefore shows roughly one allow in a hundred, and that is not a reporting fault.

Telemetry without waitUntil is best-effort. The report is never awaited on the request path — a test holds /agent/log open forever and asserts the response still returns — so on a host that gives you no waitUntil, or if you do not pass the context through, the isolate may be torn down before the report lands.

The block page is fixed. One inline English HTML document, no branding hook and no template option. If you need your own, match on the 403 upstream.

The challenge URL is derived by stripping a trailing /v1. With the default apiUrl that gives https://api.relintio.com/challenge. An apiUrl that does not end in /v1 keeps its whole path and has /challenge appended to it.

The parity test needs the Node agent beside it. It imports ../../node/src/node-utils.js by relative path, so it runs in the monorepo and not from the published tarball, which ships src/, README.md and LICENSE only.

Links

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

License

See LICENSE, which package.json declares as SEE LICENSE IN LICENSE.

About

The Relintio agent protocol for Web-standard edge runtimes. The shared engine behind @relintio/vercel, @relintio/supabase and @relintio/firebase.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages