Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Relintio

@relintio/vue-agent

npm quickstart license

The Relintio agent for Vue 3.


A Vue plugin that sits between your components and window.fetch. Install it with app.use(relintio, …) and it wraps fetch, so when your own API refuses a request with 403 and an X-Relintio-Challenge header, the visitor gets the hosted challenge and the refused request is retried once after they pass. useRelintio() hands a component the reactive state, verify() and challenge(); useRelintioChallenge() hands it the iframe wiring. The entry point is the relintio plugin object exported from @relintio/vue-agent, and it goes in main.ts.

// main.ts
import { createApp } from 'vue';
import { relintio } from '@relintio/vue-agent';
import App from './App.vue';

createApp(App)
  .use(relintio, { publishableKey: 'pk_live_...' })
  .mount('#app');

Installation

npm install @relintio/vue-agent

Vue 3.3 or newer, declared as a peer dependency — the plugin uses onScopeDispose and shallowRef, and there is no Vue 2 build. The one runtime dependency is @relintio/browser-core, which npm installs for you.

Registration

app.use(relintio, { … }) belongs in main.ts, on the app instance you are about to mount, before .mount(). The plugin's install is what calls app.provide, and inject only sees what was provided before a component's setup ran, so a plugin installed after mount leaves every already-created component without an agent. verifyOnMount, if you turn it on, also fires from install — before the first component renders, not after.

Registration is per app instance. Two createApp calls on one page are two agents unless you install the plugin on both; components under the second app cannot inject through the first.

The plugin never throws. If the key is refused, install returns before it provides anything, and the app mounts unprotected with an error in the console — taking a customer's application down over our configuration problem is the worse outcome. The cost of that choice is in Edge cases.

Configuration

The second argument to app.use is RelintioPluginOptions: RelintioConfig from the core, plus interceptFetch.

Option Default Meaning
publishableKey Required. Must begin pk_. Anything else is refused before a byte leaves the browser.
apiUrl https://api.relintio.com/v1 Override for staging or self-hosting. Trailing slashes are trimmed once, at construction.
challengeTimeoutMs 120000 How long a challenge may stay open. Floored at 10000 however low you set it.
verifyOnMount false Ask for a verdict at install time. Leave it off unless the front end is the only surface you have.
fallbackUrl Declared on RelintioConfig for a visitor whose challenge cannot be presented. Nothing in the current core reads it; setting it changes no behaviour today.
interceptFetch true Wrap globalThis.fetch. Only the literal value false turns it off.

Which key goes here

This code runs in your visitors' browsers, so whatever key it holds is readable by everyone who loads the page. That key must be a publishable key (pk_live_…). A publishable key is public by design and carries one capability: it may ask 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 for request signing. Anyone holding it can mint themselves a pass through your WAF. It belongs to your server, edge or adapter package, and it must never be pasted into main.ts, an .env file that Vite inlines into the bundle, or anything else that ships to a browser.

Handed a licence key, the agent refuses to start. isUsable() checks that the key is a string beginning pk_; a licence key is not, so it logs an error naming the mistake and returns false, and install stops there. No verdict request is made and the key is never transmitted — verify() returns null without calling fetch at all, which the core's test suite asserts against a stubbed fetch that records whether it was ever reached. The same applies to an empty string, undefined, a bare pk, or a secret key beginning sk_.

The challenge, in a component

useRelintioChallenge() returns state, a template ref, and the attributes the iframe must carry. It returns data rather than a component on purpose: a fixed-position iframe injected by a dependency is a layout bug in somebody else's design system. The markup and the styling are yours.

<script setup lang="ts">
import { useRelintioChallenge } from '@relintio/vue-agent';

const { state, frame, attrs } = useRelintioChallenge();
</script>

<template>
  <iframe
    v-if="state.isChallenging"
    ref="frame"
    :src="state.challengeUrl"
    v-bind="attrs"
    class="relintio-challenge"
  />
</template>

attrs is sandbox="allow-forms allow-scripts allow-same-origin", referrerpolicy="no-referrer" and title="Security check". There is no allow-top-navigation: a challenge page must not be able to move the visitor off the site it is protecting.

The composable attaches a message listener to window and removes it through onScopeDispose. Every event goes through agent.isChallengeSuccess(event, frame.value?.contentWindow), which requires three things at once — the event's origin equals the origin of the challenge URL currently on screen, its source is that exact iframe's contentWindow, and its data is the exact string relintio_challenge_success. Origin alone would let any frame on the challenge origin pass the challenge for the visitor; a prefix or a JSON parse would let the visitor's own page decide whether it passed.

What happens on a request

With interceptFetch on, every fetch in the app goes through the wrapper. A response that is not 403, or a 403 without an X-Relintio-Challenge header, is returned untouched. A 403 that carries one puts the challenge URL on screen and awaits it: if the visitor passes, the original request is replayed exactly once — never a loop, so a retry that is challenged again is handed to your code to decide. If the challenge times out or the agent is torn down, the caller receives the original 403 object, not a synthesised one, so your error handling sees what the server actually said.

verify() is the other path, and it is advisory rather than enforcing. It POSTs to ${apiUrl}/agent/decision with an X-Agent-Version header, carrying the domain, path, referrer, return URL, the up_token query parameter if the visitor arrived with one, agent_kind: "vue", and the collected telemetry. The request body field is named license_key for wire compatibility; the value in it is your publishable key. The call is abandoned after 5 seconds. A verdict of challenge with a challenge_url presents the challenge; every other action is state you can read, not something this package enforces. Enforcement lives at your origin, where a client cannot be told to decide differently.

Telemetry is gathered by the shared collector: screen and hardware shape, timezone, languages, resolved fonts, canvas and WebGL and audio digests, network hints, the navigator.webdriver flag, and behaviour counters. The counters are counts only — how many pointer moves, keystrokes, scrolls and touches, and how long the visitor has been on the page. Never what was typed or where the pointer went; anything more would be a keylogger on someone else's checkout. Every probe is individually guarded, and the audio probe is raced against a 120 ms budget rather than awaited, so a browser with a hung audio stack costs a missing signal and not a delayed page.

Where the protocol lives

Nothing in this package makes a security decision. The publishable-key refusal, the http/https check on a challenge URL, the three-part postMessage validation, the ten-second timeout floor and every fail-open path are in @relintio/browser-core, and this file is a few dozen lines turning subscribe() into a shallowRef, provide/inject into a composable, and a template ref into the frame identity the postMessage check compares against.

That is the whole reason for the split. React, Vue, Svelte, Angular and Expo were going to be five hand-written copies of one protocol, and those rules are precisely the ones that must not differ between them. Because the engine holds them, a fix to challenge handling lands in every framework binding at once, in one released version of one package, rather than in five packages on five schedules.

When Relintio is unreachable

Your app keeps working. verify() swallows a refused connection, a timeout, a non-2xx answer and an unparseable body alike and returns null; the interceptor returns whatever your server sent; a challenge that cannot be solved releases the original response rather than blocking. There is no path in this agent that fails closed, deliberately: a security agent that blanks a page because it could not reach its own control plane has turned our outage into yours, which is a worse failure than the one it was guarding against.

Edge cases

A refused key surfaces as a thrown error somewhere else. install returns before app.provide, so the injection key is never registered, and the first component to call useRelintio() throws useRelintio() was called without the plugin installed. The message points at your wiring; the actual cause is the console.error logged moments earlier by the key check. If you see that exception in an app whose main.ts plainly calls app.use(relintio, …), read the console line above it before you touch the wiring.

useRelintioChallenge() must be called during setup. The listener cleanup is registered with onScopeDispose, which only attaches inside an active effect scope. Call the composable from a module top level, an event handler, or an async continuation after an await, and the message listener is added to window with nothing arranged to remove it — one leaked listener per call, for the life of the page.

Forgetting ref="frame" costs the visitor two minutes. isChallengeSuccess compares event.source against frame.value?.contentWindow. An unbound ref makes that undefined, every success message is rejected, and the challenge sits on screen until challengeTimeoutMs elapses — the default is two minutes — after which the held request resolves with the original 403. Nothing is logged. The state never leaves isChallenging.

fetch is wrapped globally, not per app. The interceptor replaces globalThis.fetch and the restore function assigns back the reference captured when that app installed. With two Vue apps on one page, unmounting the first restores the pre-Relintio fetch and silently discards the second app's wrapper. If you mount more than one app, install the plugin on one of them.

Teardown hangs off app.unmount. install wraps app.unmount to restore fetch, drop the state subscription and dispose the agent. An app that is never unmounted — most production apps — never runs any of it, which is fine. Under HMR or in a test that mounts and discards apps in a loop, unmount is what keeps the wrappers from stacking, so call it.

A second verify() reports frozen behaviour counters. The behaviour watcher is started once when the agent is constructed, and its snapshot() detaches every listener the first time it is read. A second verdict request therefore carries the counts as they stood at the first one, with a dwell time that has kept growing. This matters only if you call verify() repeatedly; the mount-time call reads a live watcher.

$relintio is set but not typed. install assigns the API to app.config.globalProperties.$relintio, and the package ships no ComponentCustomProperties augmentation for it. It works at runtime from the Options API and from templates; under vue-tsc it is an unknown property. Prefer useRelintio(), which is typed.

Concurrent failures produce one challenge. Three requests refused at once join a single pending challenge and a single iframe; the URL from the first one wins and the later URLs are dropped. All three release together when the visitor passes.

Links

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

License

MIT. See LICENSE.

About

Official Vue 3 agent for Relintio. Plugin, composable and challenge handling — npm install @relintio/vue-agent. Takes a publishable key, never a licence key.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages