Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Relintio

@relintio/browser-core

npm docs license

The browser protocol the Relintio front-end SDKs are built on.


This is not a package you install to protect an application. It is the shared browser protocol underneath @relintio/vue-agent, @relintio/svelte-agent, @relintio/angular-agent and @relintio/expo-agent, published only so those four can depend on it — if you arrived here from a dependency list, install the binding for your framework instead and never construct anything from this package directly. What it contains is one class, RelintioAgent, exported from dist/index.js: it holds the publishable key, collects device signals, asks /agent/decision for a verdict, owns the challenge lifecycle and the state subscription bindings render from, and can wrap fetch so a 403 carrying X-Relintio-Challenge from your own API presents a challenge and retries. It renders nothing and touches no framework. A binding is a few dozen lines turning subscribe() into a hook, a store or an injectable.

import { RelintioAgent } from '@relintio/browser-core';

const agent = new RelintioAgent({ publishableKey: 'pk_live_...' });

if (agent.isUsable()) {
  const stop = agent.subscribe((state) => render(state));
  const restoreFetch = agent.interceptFetch();

  window.addEventListener('message', (event) => {
    const frame = document.querySelector<HTMLIFrameElement>('#relintio-challenge')?.contentWindow;

    if (agent.isChallengeSuccess(event, frame)) {
      agent.resolveChallenge();
    }
  });

  // On teardown, in this order.
  restoreFetch();
  stop();
  agent.dispose();
}

Installation

npm install @relintio/browser-core

Almost certainly the wrong command. The one you want:

Framework Package Install
Vue 3 @relintio/vue-agent npm install @relintio/vue-agent
Svelte / SvelteKit @relintio/svelte-agent npm install @relintio/svelte-agent
Angular @relintio/angular-agent npm install @relintio/angular-agent
Expo / React Native @relintio/expo-agent npm install @relintio/expo-agent

Each of those declares @relintio/browser-core as a ^1.0.0 dependency and re-exports the types it needs, so you do not add it to your own manifest. @relintio/react-agent is the exception: it predates this package, carries its own copy of the protocol, and does not depend on it.

There are no runtime dependencies. TypeScript is the only devDependency. The build targets ES2020 and emits CommonJS with lib: ["ES2020", "DOM"]; types ship at dist/index.d.ts.

Registration

Construct one agent per application, at application scope, once. Not per component, not per route, and not per request.

The reason is the behaviour watcher. collector.watchBehaviour() runs in the constructor — guarded on typeof document !== 'undefined', so it is skipped on a server render and in React Native — and accumulates dwell time, pointer moves, keystrokes, scrolls and touches from that moment. The signal is the shape of a session. An agent constructed at request time always reports a visitor who has done nothing, which is indistinguishable from the automation those signals exist to catch, so a per-request agent does not merely lose accuracy: it makes every real visitor look synthetic.

Two agents on the same page compound this and add their own problem. Challenge de-duplication is per instance, so two agents produce two stacked iframes racing to resolve the same visitor, and both will have wrapped globalThis.fetch — restoring them out of order reinstalls a wrapper rather than the original.

Configuration

Field Type Default Meaning
publishableKey string Required. Must begin pk_. See below.
apiUrl string https://api.relintio.com/v1 Trailing slashes are trimmed at construction.
challengeTimeoutMs number 120000 How long a challenge may stay open. Floored at 10000.
verifyOnMount boolean false Ask for a verdict on mount and act on it. Off because the interceptor is the usual shape; turn it on for a static site with no origin of its own to run a server agent.
fallbackUrl string Declared on RelintioConfig for a visitor whose challenge cannot be presented. Nothing in 1.0.0 reads it; setting it changes no behaviour.

The credential boundary

This is a browser package and it takes a publishable key only. A publishable key is public by design — every visitor can read it out of your bundle — and carries exactly one capability: asking Relintio for a verdict. It cannot read your rules, write telemetry, or cause a challenge pass to be issued.

A licence key (UP_LIVE_…) is the HMAC key for challenge passports and outbound request signing. Anyone holding one can mint themselves a pass through your WAF, which is why it belongs to the server, adapter and edge packages and must never be typed into a file that reaches a browser.

isUsable() checks that publishableKey is a string beginning pk_. Anything else — a licence key, a secret key, an empty string, undefined, a number — returns false after a console.error naming the problem. It is reported as an error rather than a warning because a licence key in a browser bundle is a security incident, not a configuration detail to route around. A refused agent then does nothing at all: verify() returns null before constructing a request, so the key it was handed is never transmitted anywhere, not even to us. The test suite asserts exactly that — that a refused agent makes no outbound call — because the failure worth preventing is a licence key leaking into a request log on its way to being rejected.

Bindings check isUsable() and return early rather than throwing. Taking a customer's application down over our configuration problem is worse than running unprotected and saying so loudly in the console.

What happens on a verdict request

verify() gathers signals, then POSTs to ${apiUrl}/agent/decision with an X-Agent-Version header and a body carrying the domain, path, referrer, return URL, the up_token query parameter if one is present, the agent kind, and the telemetry and env objects. The publishable key travels in a field spelled license_key, which is a wire-compatibility artefact and not a second credential.

The call is abandoned after five seconds by an AbortController. It is deliberately short: this request is advisory — the interceptor is what actually protects a request — so a slow answer is worth less than a fast page.

It fails open on every path, and silently. A network error, an abort, a non-2xx status, or a body that will not parse all return null with nothing thrown and nothing logged. A security agent that blocks a page because it could not reach its own control plane has turned an outage of ours into an outage of the customer's, which is a worse failure than the one it was guarding against.

The verdict shape is { action, reason?, reason_code?, risk_score?, challenge_url?, ip? }, where action is one of allow, challenge, block, slow or decoy. The core acts on exactly one of them: challenge, and only when challenge_url is also present. The others are stored on state.verdict and handed to the caller. Nothing in this package blocks, delays or redirects anything — a block verdict is information, and it is the binding or the application that decides what to do with it.

The challenge lifecycle

challenge(url) returns a promise a binding awaits before releasing whatever it was holding. A rejection means do not release.

The URL is resolved with new URL(url, href()) and must end up http: or https:. Anything else rejects with Unsupported challenge URL protocol, and an unparseable URL rejects with Invalid challenge URL. This is not defence in depth against a hypothetical: the challenge URL becomes an iframe src, and javascript: in an iframe src is script execution in your own origin. The verdict it arrives on came over the network. data: and file: are refused on the same grounds, and the tests name all three.

The timeout is Math.max(MIN_CHALLENGE_TIMEOUT_MS, challengeTimeoutMs ?? DEFAULT_CHALLENGE_TIMEOUT_MS) — a floor of 10 seconds under a default of 120. Configuring one millisecond gets ten seconds. A challenge that expires faster than a human could plausibly solve it is a blocked customer, not a tightened control, and the suite asserts the floor by setting challengeTimeoutMs: 1 and checking nothing has settled.

Concurrent calls join rather than stack: while one challenge is open, challenge() returns the same promise and keeps the first URL. Three requests failing at once produce one challenge, not three iframes racing to resolve the same visitor.

The core owns no DOM, so the binding renders the iframe and forwards message events. isChallengeSuccess(event, iframeWindow) requires three things, all of them: event.origin equal to the origin of the challenge URL currently in state, event.source identical to the iframe's contentWindow, and event.data exactly equal to relintio_challenge_success. Origin alone would let any frame on that origin pass a challenge for the visitor. An exact string rather than a prefix or a parse is the point — nothing the visitor controls decides whether they passed. On a match, call resolveChallenge(), which clears the timer, increments resolvedCount and resolves the waiter. failChallenge(error) is the other exit, used for a timeout, a dismissal, or teardown.

The device-signal collector

src/collector.js produces the device identity a challenge pass is bound to: a hash over the canvas render, the resolved fonts, the GPU string, an offline audio render and the rest. It is dependency-free ES5, and it is a byte-identical copy of agents/shared/collector.js rather than an import, for two independent reasons. The mechanical one is that npm cannot package a file from outside a package's own directory. The one that matters is that the challenge page runs that same shared file: if the two ever computed slightly different scenes, the same machine would produce two identities, a visitor would earn a pass credited to one and then load the protected page as the other, and nothing would error or log. Reputation would simply stop accumulating and every returning visitor would look like a first-timer, forever. That is why the file is not ported to TypeScript and not refactored — it is not shared code that happens to be duplicated, it is duplicated code whose only requirement is that it stay identical.

Collection never blocks the page and never throws: every probe runs inside an attempt() wrapper, and a probe that throws omits its family rather than reporting it empty, because empty means "looked and found nothing" and carries weight server-side. Audio is the only asynchronous family and is raced against a 120 ms budget, so a browser whose audio stack hangs costs a family and not a page. Behaviour is counters only — how many pointer moves, keystrokes, scrolls and touches, and how long the visitor dwelled — never content; the listeners are registered passive so they cannot delay a scroll.

collectSignals() is protected and overridable. That is how the Expo binding runs at all: React Native has no DOM, so it substitutes a native-safe subset rather than forking the protocol. hostname(), pathname(), href(), referrer(), upToken() and agentKind() are protected for the same reason.

Building and testing

npm run build is tsc && node scripts/copy-collector.mjs, and the second half is not optional. tsc cannot emit the collector: it is plain JavaScript, and allowJs cannot be turned on here without colliding with the hand-written src/collector.d.ts that types it. So scripts/copy-collector.mjs copies collector.js and collector.d.ts from src into dist itself. Without that step the published tarball contains an index.js importing a module that is not in it, and every consumer's build fails at resolve time — four broken packages, not one, and prepublishOnly runs the build, so the break ships.

npm test runs node --test against test/agent.test.mjs, which imports ../dist/index.js. Build first; the suite tests the artefact, not the sources.

Edge cases

dispose() does not restore fetch. It sets the disposed flag, rejects any open challenge and clears every listener, but the wrapper interceptFetch() installed stays installed — restoring it is the job of the function that call returned, which a binding must invoke separately. If it is not invoked, the wrapper survives on a dead agent: a 403 carrying X-Relintio-Challenge still reaches it, challenge() rejects because the agent is disposed, and the interceptor hands back the original 403 unretried. Nothing crashes, and nothing is ever challenged again.

A disposed agent arms nothing. challenge() rejects immediately when disposed is set, before parsing the URL and before setting a timer. This was a leak: a challenge holds a timer of up to two minutes and a promise a binding awaits before releasing a held request, and in a SPA that unmounts a route per navigation both used to outlive the component that owned them — one leaked timer per navigation, and worse, a promise that could settle after teardown and release a request into a tree that no longer exists. There is a regression test that counts active Timeout handles across the call.

Behaviour counters freeze after the first collection. snapshot() detaches its listeners the first time it is read, by design — the watcher is not meant to run for the life of the page. A second verify() therefore reports pointer, key, scroll and touch counts unchanged from the first call while dwell_ms keeps climbing. On an application calling verify() repeatedly this reads as a visitor who was briefly active and then went still.

A challenge that times out during verifyOnMount returns null. verify() awaits challenge() inside its own try, so a rejected challenge is swallowed by the fail-open catch and verify() resolves to null rather than to the verdict it already received. state.verdict was set before the challenge began and still holds it, so subscribers see the verdict even though the caller does not.

Only the first challenge URL is validated. The de-duplication check runs before URL parsing, so a second challenge() call made while one is open returns the pending promise without inspecting its argument. That URL is never used for anything, but a caller cannot rely on a rejection to tell it that a URL was unacceptable.

The collector copy is kept by hand. The platform's SdkCollectorParityTest asserts byte-identity for the React SDK's copy and for the two surfaces that splice the shared file in at serve time. This package's copy is not in that test. It is identical today; nothing in CI will say so tomorrow. Copy agents/shared/collector.js over src/collector.js whenever the shared file changes, and treat any edit to it as a decision to re-identify every device on the platform, because that is what it is.

href() falls back to https://localhost/. Where there is no location — a server render, React Native — relative challenge URLs resolve against that placeholder and hostname() reports an empty string. Bindings that run outside a browser should override these rather than let a verdict be requested for a domain of ''.

Links

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

License

MIT, as declared in LICENSE.

About

Framework-agnostic browser agent core for Relintio. The shared protocol behind the React, Vue, Svelte, Angular and Expo SDKs — device signals, challenge handling and fail-open behaviour in one place.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages