Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Relintio

@relintio/svelte-agent

npm quickstart license

The Relintio agent for Svelte and SvelteKit.


One function, createRelintio, returns a client made of two Svelte-shaped things: a readable store carrying challenge state, and an action you put on the challenge <iframe>. It wraps fetch, so a 403 from your own API carrying an X-Relintio-Challenge header becomes a challenge the visitor solves and a request that is replayed once, instead of an error your UI has to explain. Under SvelteKit it goes in the root layout, src/routes/+layout.svelte, and it is inert during server rendering.

<!-- src/routes/+layout.svelte -->
<script lang="ts">
  import { onDestroy } from 'svelte';
  import { createRelintio } from '@relintio/svelte-agent';

  const relintio = createRelintio({ publishableKey: 'pk_live_...' });
  const { state: challenge, challengeFrame, frameAttrs } = relintio;

  onDestroy(() => relintio.destroy());
</script>

{#if $challenge.isChallenging}
  <iframe
    use:challengeFrame
    src={$challenge.challengeUrl}
    {...frameAttrs}
    class="relintio-challenge"
  />
{/if}

<slot />

Installation

npm install @relintio/svelte-agent

Svelte 4 or 5, as a peer dependency. The package imports nothing from svelte at runtime — not even writable. The store contract it returns is a four-line hand-declared Readable<T> interface, which is why one build works across both major versions and why this package cannot pin your Svelte version. @relintio/browser-core is the only runtime dependency.

Registration

Call createRelintio once, from src/routes/+layout.svelte. That component wraps every route and survives client-side navigation, which is what the agent needs: fetch is wrapped for the lifetime of the client, and the challenge iframe has to be able to render over whatever page is currently mounted.

A page component is the wrong place. +page.svelte is destroyed and recreated on every navigation, so each one wraps fetch again over the previous wrapper, and a challenge open at the moment of navigation is torn down with the request it was holding.

A load function is also the wrong place. +layout.ts runs on the server as well as the client, and the client this returns holds timers, listeners and a class instance — it cannot cross the SSR serialisation boundary. Create it in the component.

Pair it with onDestroy(() => relintio.destroy()). Nothing else calls it: destroy() restores the original fetch and disposes the agent, which rejects any open challenge with Relintio agent unmounted.

Server rendering

createRelintio is safe to run during SSR because it does almost nothing there. It checks typeof window, and on the server it skips fetch interception entirely — wrapping fetch in a SvelteKit server render would wrap the framework's own request plumbing — skips verifyOnMount, and gives the store a subscribe that emits the initial state once and returns a no-op unsubscribe. $challenge.isChallenging is therefore false in the server-rendered HTML, and the client comes alive at hydration.

Configuration

RelintioOptions is RelintioConfig from the core plus interceptFetch.

Option Default Meaning
publishableKey Required. Must begin pk_. Checked in the browser only; see below.
apiUrl https://api.relintio.com/v1 Override for staging or self-hosting. Trailing slashes are trimmed once.
challengeTimeoutMs 120000 How long a challenge stays open before it fails. Floored at 10000, whatever you pass.
verifyOnMount false Request a verdict as soon as the client is created in a browser.
fallbackUrl Declared on RelintioConfig for a visitor whose challenge cannot be presented. No code path in the current core reads it.
interceptFetch true Wrap window.fetch. Pass exactly false to opt out.

Which key goes here

A publishable key (pk_live_…) and nothing else. It is public by design — it ships in your client bundle and every visitor can read it — and it can do exactly one thing: ask Relintio for a verdict on a request. It cannot read your rules, write telemetry, or cause a challenge pass to be issued.

Your licence key (UP_LIVE_…) is a different credential with a different job. It is the HMAC key that signs challenge passports and outbound agent requests, so anyone holding it can walk through your WAF unchallenged. It belongs in $env/static/private or your process environment, used by a server-side agent — never in $env/static/public, never in a +layout.svelte, never anywhere Vite can inline it into the browser bundle.

Given a licence key, the agent refuses to start and stays refused. The key check requires a string beginning pk_; UP_LIVE_… fails it, so the agent logs an error saying a licence key must never appear in browser code and reports itself unusable. In this binding that means interceptFetch is never installed, verifyOnMount never fires, verify() resolves to null and challenge() resolves immediately — and the key is never put on the wire. The core's tests assert exactly that, with a fetch stub that fails if it is called at all. A blank key, sk_live_…, or a bare pk are refused the same way.

The store and the action

state is a store in the ordinary Svelte sense: subscribe with $, and it carries isChallenging, challengeUrl, resolvedCount and the last verdict. Subscribing emits the current value immediately, so there is no undefined first frame.

challengeFrame is an action, not a component. Relintio does not inject a fixed-position element into your layout — that is a dependency putting markup into your design system. You render the iframe, style it, and place it; use:challengeFrame supplies the behaviour.

frameAttrs is what the iframe must carry: sandbox="allow-forms allow-scripts allow-same-origin", referrerpolicy="no-referrer", title="Security check". There is no allow-top-navigation, so a challenge page cannot move the visitor off the site it is protecting.

The action adds a message listener and removes it on destroy. A message counts as a pass only if all three of these hold: its origin matches the origin of the challenge URL currently on screen, its source is that iframe's own contentWindow, and its data is the exact string relintio_challenge_success. Origin alone would let any frame served from the challenge origin resolve a challenge on the visitor's behalf, and a startsWith or a JSON parse would hand the decision to a string the visitor can influence.

What happens on a request

The wrapped fetch is a pass-through for everything except a 403 carrying X-Relintio-Challenge. When one arrives, the URL from that header goes into the store, your layout renders the iframe, and the awaiting request is held. A pass replays the original request exactly once — one retry, never a loop, so a second challenge is returned to your code rather than looped on. A timeout or a teardown hands back the original 403 response object untouched, so response.status and the body are what your server actually sent.

verify() is the other call and it is advisory. It POSTs to ${apiUrl}/agent/decision with an X-Agent-Version header and a body carrying the domain, path, referrer, return URL, any up_token from the query string, agent_kind: "svelte", and telemetry. The body field is named license_key for wire compatibility; the value is your publishable key. It is abandoned after 5 seconds. A verdict of challenge with a challenge_url presents a challenge; the other actions land in the store as information. Nothing here enforces a verdict, because a browser that could enforce one could be told to enforce a different one. Enforcement belongs to the agent at your origin.

Telemetry comes from the collector shared with the challenge page — screen and hardware shape, timezone, languages, resolved fonts, canvas, WebGL and audio digests, network hints, the navigator.webdriver flag, and behaviour counters. The counters are counts and a dwell time, never content: how many pointer moves, keystrokes, scrolls and touches happened, not what was typed or where. Each probe is guarded individually and the audio probe is raced against a 120 ms budget, so a hung audio stack costs one signal rather than a rendered page.

One engine, five bindings

There is no security decision in this package. The key refusal, the http/https restriction on a challenge URL, the three-part postMessage check, the ten-second timeout floor and every fail-open path live in @relintio/browser-core. What you install here is the framework-shaped surface over that protocol: subscribe() presented as a store, a listener presented as an action, teardown presented as something onDestroy can call.

The alternative was five hand-written implementations of one protocol for React, Vue, Svelte, Angular and Expo, differing in exactly the places where they must not. Keeping the engine in one package means a fix to challenge handling — a tightened origin check, a corrected timeout — ships once and reaches every framework at the same time, rather than being true on the day each SDK was written.

When Relintio is unreachable

Nothing stops. verify() returns null on a refused connection, a timeout, a non-2xx status or an unreadable body, with no throw for your code to catch. The interceptor returns your server's own response. A challenge that cannot be presented or solved releases what it was holding. Every failure path in this agent fails open on purpose: a security agent that blanks a page because it could not reach its control plane has converted our outage into yours, which is the worse of the two failures.

Edge cases

On the server, the key is never checked. Usability is evaluated as "in a browser and the key is valid", and the short-circuit means a licence key produces no message during SSR. The error appears in the visitor's browser console at hydration — not in your build output, not in your server logs, not in a CI check. If you want that caught earlier, assert the pk_ prefix in your own config loading.

A refused key gives you a client that quietly does nothing. No exception is thrown and every method still exists: $state emits its initial value once and never changes, verify() resolves null, challenge() resolves immediately, and fetch is left alone. A store that never moves and a console error are the only symptoms, so check the console before you go looking at the store.

Do not call the store state in a Svelte 5 component. $state is a rune in Svelte 5, and in a runes-mode component the compiler reads $state as the rune rather than as a store auto-subscription. Rename it on destructure, as the example above does — any name but state works, and the store is the same object either way.

The action must be on the iframe that shows challengeUrl. The pass check compares the message's source against node.contentWindow for the node the action was attached to. Put use:challengeFrame on a wrapper <div>, or on a second iframe, and every success message is rejected: the challenge stays on screen until challengeTimeoutMs runs out — two minutes by default — and the held request then resolves with the original 403, silently.

destroy() is yours to call and nothing calls it for you. Without the onDestroy line, an unmounted layout leaves fetch wrapped by an agent no store is watching. In a single-layout SvelteKit app that layout lives as long as the tab, so the practical cost is nil; in tests, in an embedded widget, or anywhere the root is mounted repeatedly, the wrappers stack.

Simultaneous failures share one challenge. Several requests refused at once join a single pending challenge rather than stacking iframes. The first URL is the one presented; later ones are discarded, and all the held requests release together.

A repeated verify() sends stale behaviour counters. The behaviour watcher starts when the client is created and detaches its listeners the first time it is read. A second verdict request carries the counts frozen at the first read, alongside a dwell time that keeps rising. It is accurate for the mount-time call and increasingly stale for anything after it.

Readable<T> here is not svelte/store's type. It declares only subscribe, which is all the auto-subscription contract needs. It satisfies $-prefixed usage in a component, but it is not a full Svelte store: there is no set, no update, and passing it to a helper whose signature demands svelte/store's Readable will not type-check.

Links

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

License

MIT. See LICENSE.

About

Official Svelte and SvelteKit agent for Relintio. Store, action and challenge handling — npm install @relintio/svelte-agent. Takes a publishable key, never a licence key.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages