@shieldlabs-ai/vue 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -0
- package/LICENSE +21 -0
- package/README.md +589 -0
- package/dist/index.cjs +370 -0
- package/dist/index.d.cts +166 -0
- package/dist/index.d.ts +166 -0
- package/dist/index.js +361 -0
- package/package.json +79 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@shieldlabs-ai/vue` are documented in this file. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the package uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [1.0.0] - 2026-09-30
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `createShieldLabs(options)`: a Vue plugin for `app.use()` that loads the ShieldLabs agent once per
|
|
14
|
+
app with `@shieldlabs-ai/js`, after the app is mounted in the browser. It takes the `@shieldlabs-ai/js`
|
|
15
|
+
load options (`publicKey`, `environment`, `scriptUrl`, `timeout`) plus `checkOnLoad` (one
|
|
16
|
+
`check()` when the agent becomes ready, anonymous or for a User HID, skipped when an `identify()`
|
|
17
|
+
or `check()` for the same User HID is already in flight) and `autoLoad` (default `true`; with
|
|
18
|
+
`false` nothing loads until `load()` is called, for example after consent, and until then
|
|
19
|
+
`identify()` and `check()` fail fast instead of waiting: `identify()` rejects with
|
|
20
|
+
`not_initialized`, `check()` resolves `null` and the `identify()` of `useIdentify()` resolves `null`
|
|
21
|
+
with a `not_initialized` error). A second plugin on the same app is ignored with a warning.
|
|
22
|
+
- `useShieldLabs()`: `status` (`loading`, `ready` or `error`) and `error` refs, `identify()` and
|
|
23
|
+
`check()` that wait for the agent, `load()` that starts loading it, and `getAgent()` that resolves
|
|
24
|
+
the `@shieldlabs-ai/js` agent once it has loaded (for example for `identifyOnInteraction()` on a
|
|
25
|
+
form; with `autoLoad: false` it waits for `load()`, with no timeout of its own). A failed load is
|
|
26
|
+
retried by the next `identify()`, `check()`, `getAgent()` or `load()`.
|
|
27
|
+
- One timeout per call: the `timeout` of `identify()` and `check()` (else the plugin's `timeout`,
|
|
28
|
+
10 seconds by default) covers the wait for the agent to load and the agent's answer together. A
|
|
29
|
+
call whose timeout ends before the agent is ready fails with `timeout` and never reaches the
|
|
30
|
+
agent.
|
|
31
|
+
- `useIdentify({ userId, runOnMount })`: an identify helper with read-only `result`, `isLoading`
|
|
32
|
+
and `error` refs (holding the objects from `@shieldlabs-ai/js` as they are) and `reset()`.
|
|
33
|
+
`identify()` never rejects (it resolves `null` and puts the reason in `error`), reads `userId`
|
|
34
|
+
from a string, ref or getter when it runs, and returns a call of the same helper already in flight
|
|
35
|
+
with the same `userId` and `timeout` (a call without `timeout` counts as one with the plugin's
|
|
36
|
+
`timeout`) instead of starting another identification, so a double submit costs one
|
|
37
|
+
identification. `runOnMount` identifies once after mount; with `autoLoad: false` before `load()`
|
|
38
|
+
it resolves `null` with a `not_initialized` error, like `identify()`, and does not run again after
|
|
39
|
+
`load()`.
|
|
40
|
+
- Server-side rendering: nothing touches browser globals during setup or render and nothing loads
|
|
41
|
+
on the server. `status` is `loading` during server rendering and in the first render of every
|
|
42
|
+
component, so hydration matches the server HTML. The composables also render during server
|
|
43
|
+
rendering without the plugin (a client-only Nuxt plugin).
|
|
44
|
+
- `shieldLabsKey` (injection key), `VERSION`, the `ShieldLabsError` class and the types of
|
|
45
|
+
`@shieldlabs-ai/js` (including `InteractionIdentifier`), and the types `ShieldLabsOptions`,
|
|
46
|
+
`CheckOnLoadOption`, `ShieldLabsPlugin`, `ShieldLabsContext`, `ShieldLabsStatus`,
|
|
47
|
+
`UseShieldLabsReturn`, `UseIdentifyOptions` and `UseIdentifyReturn`.
|
|
48
|
+
- ESM and CommonJS builds with TypeScript declarations. `vue` (`^3.3.0`) and `@shieldlabs-ai/js`
|
|
49
|
+
(`^1.0.0`) are peer dependencies; no runtime dependencies.
|
|
50
|
+
- `examples/vite`: a Vue signup form. README guides for Vue and Nuxt 3.
|
|
51
|
+
|
|
52
|
+
### Removed
|
|
53
|
+
|
|
54
|
+
- The `0.0.0` placeholder `useShieldLabs({ apiKey })`. The browser receives a request ID; results
|
|
55
|
+
are read on your server.
|
|
56
|
+
|
|
57
|
+
[Unreleased]: https://github.com/ShieldLabs-ai/shieldlabs-vue/compare/v1.0.0...HEAD
|
|
58
|
+
[1.0.0]: https://github.com/ShieldLabs-ai/shieldlabs-vue/releases/tag/v1.0.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ShieldLabs Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
# @shieldlabs-ai/vue
|
|
2
|
+
|
|
3
|
+
Vue 3 and Nuxt bindings for ShieldLabs device intelligence: a plugin that loads the agent once and
|
|
4
|
+
composables that return a request ID with loading and error state.
|
|
5
|
+
|
|
6
|
+
[](https://github.com/ShieldLabs-ai/shieldlabs-vue/actions/workflows/ci.yml)
|
|
7
|
+
[](https://www.npmjs.com/package/@shieldlabs-ai/vue)
|
|
8
|
+
[](./LICENSE)
|
|
9
|
+
|
|
10
|
+
`@shieldlabs-ai/vue` builds on [`@shieldlabs-ai/js`](https://github.com/ShieldLabs-ai/shieldlabs-js), the
|
|
11
|
+
browser loader that imports the hosted ShieldLabs agent from `https://cdn.shieldlabs.ai` at
|
|
12
|
+
runtime. It adds a Vue plugin, composables with refs, and safe server-side rendering for Vue SSR
|
|
13
|
+
and Nuxt 3.
|
|
14
|
+
|
|
15
|
+
New to ShieldLabs? [Start free](https://app.shieldlabs.ai), then copy the Public Key of your domain
|
|
16
|
+
from Integration > API keys in the analytics dashboard (the Install tab also shows a ready snippet
|
|
17
|
+
that contains it).
|
|
18
|
+
|
|
19
|
+
## How it fits
|
|
20
|
+
|
|
21
|
+
1. **Browser.** `@shieldlabs-ai/vue` loads the agent and runs an identification. Your component
|
|
22
|
+
receives a `requestId`.
|
|
23
|
+
2. **Your backend.** It receives the `requestId` with the protected action (signup, login,
|
|
24
|
+
checkout) and reads the verdict for it from the History API with a ShieldLabs server SDK, or
|
|
25
|
+
receives it in a signed `identification.scored` webhook.
|
|
26
|
+
3. **Decision.** Your backend acts on the Risk Score (bands: trusted 0-29, suspicious 30-59,
|
|
27
|
+
dangerous 60-100), the detection flags and identifiers such as the device ID.
|
|
28
|
+
|
|
29
|
+
The browser only ever gets the request ID. The Risk Score, risk signals, detection flags, visitor ID
|
|
30
|
+
and device ID are read on your server.
|
|
31
|
+
|
|
32
|
+
Webhooks: today each identification is delivered once, with a 1 second timeout and no retries.
|
|
33
|
+
Make your webhook handler idempotent on `data.request_id`, because future retries will resend
|
|
34
|
+
identical bytes, and read the History API when you need a guaranteed result.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm install @shieldlabs-ai/vue @shieldlabs-ai/js
|
|
40
|
+
# or
|
|
41
|
+
yarn add @shieldlabs-ai/vue @shieldlabs-ai/js
|
|
42
|
+
# or
|
|
43
|
+
pnpm add @shieldlabs-ai/vue @shieldlabs-ai/js
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`vue` (3.3 or later) and `@shieldlabs-ai/js` are peer dependencies: your app provides one copy of each.
|
|
47
|
+
|
|
48
|
+
## Quick start
|
|
49
|
+
|
|
50
|
+
Install the plugin once:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
// src/main.ts
|
|
54
|
+
import { createShieldLabs } from '@shieldlabs-ai/vue';
|
|
55
|
+
import { createApp } from 'vue';
|
|
56
|
+
import App from './App.vue';
|
|
57
|
+
|
|
58
|
+
createApp(App)
|
|
59
|
+
.use(createShieldLabs({ publicKey: import.meta.env.VITE_SHIELDLABS_PUBLIC_KEY }))
|
|
60
|
+
.mount('#app');
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Identify when the user submits a protected form, and send the `requestId` with the request:
|
|
64
|
+
|
|
65
|
+
```vue
|
|
66
|
+
<script setup lang="ts">
|
|
67
|
+
import { useIdentify } from '@shieldlabs-ai/vue';
|
|
68
|
+
import { ref } from 'vue';
|
|
69
|
+
|
|
70
|
+
const email = ref('');
|
|
71
|
+
const { identify, isLoading } = useIdentify();
|
|
72
|
+
|
|
73
|
+
async function onSubmit() {
|
|
74
|
+
const result = await identify(); // null when there is no identification
|
|
75
|
+
await fetch('/api/signup', {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: { 'Content-Type': 'application/json' },
|
|
78
|
+
body: JSON.stringify({ email: email.value, requestId: result?.requestId ?? null }),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
</script>
|
|
82
|
+
|
|
83
|
+
<template>
|
|
84
|
+
<form @submit.prevent="onSubmit">
|
|
85
|
+
<input v-model="email" type="email" required />
|
|
86
|
+
<button :disabled="isLoading">Sign up</button>
|
|
87
|
+
</form>
|
|
88
|
+
</template>
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
On your server, read the verdict for `requestId` with a ShieldLabs server SDK, for example
|
|
92
|
+
`identifications.get(requestId)` in [`@shieldlabs-ai/node`](https://github.com/ShieldLabs-ai/shieldlabs-node),
|
|
93
|
+
which waits until the identification has been scored. The History row appears about 1-3 seconds
|
|
94
|
+
after `identify()` resolves and can be refined for up to about 10 seconds as follow-up checks
|
|
95
|
+
finish. To keep that wait off the submit, start the identification when the user begins the action
|
|
96
|
+
(see [Start on the first interaction with the form](#start-on-the-first-interaction-with-the-form)).
|
|
97
|
+
Server SDKs:
|
|
98
|
+
[Node.js](https://github.com/ShieldLabs-ai/shieldlabs-node),
|
|
99
|
+
[Python](https://github.com/ShieldLabs-ai/shieldlabs-python),
|
|
100
|
+
[Go](https://github.com/ShieldLabs-ai/shieldlabs-go),
|
|
101
|
+
[PHP](https://github.com/ShieldLabs-ai/shieldlabs-php),
|
|
102
|
+
[Java](https://github.com/ShieldLabs-ai/shieldlabs-java) and
|
|
103
|
+
[.NET](https://github.com/ShieldLabs-ai/shieldlabs-dotnet).
|
|
104
|
+
|
|
105
|
+
> **Keep the page alive after `identify()` resolves.** The agent posts the identification right
|
|
106
|
+
> after it hands over the request ID. Send your request with `fetch()` as above, and start a full
|
|
107
|
+
> page navigation (`location.href = ...`, a classic form post) only after it has been sent.
|
|
108
|
+
> Client-side navigation with Vue Router keeps the page alive. Details:
|
|
109
|
+
> [`@shieldlabs-ai/js` Quick start](https://github.com/ShieldLabs-ai/shieldlabs-js#quick-start).
|
|
110
|
+
|
|
111
|
+
A complete app is in [`examples/vite`](examples/vite).
|
|
112
|
+
|
|
113
|
+
## Guide
|
|
114
|
+
|
|
115
|
+
### Protect a form with `useIdentify()`
|
|
116
|
+
|
|
117
|
+
`useIdentify()` gives each component its own identify helper:
|
|
118
|
+
|
|
119
|
+
- `identify(options?)` runs a fresh identification and resolves `{ requestId, userId }`, or `null`
|
|
120
|
+
when there is no identification (the reason is in `error`). It never rejects, so your submit
|
|
121
|
+
handler always goes on to send the protected action: test the resolved value for `null` instead
|
|
122
|
+
of catching. Your backend treats a request without `requestId` as unverified (step-up or review),
|
|
123
|
+
never as clean.
|
|
124
|
+
- `result`, `isLoading` and `error` are refs for the latest call; `reset()` clears them.
|
|
125
|
+
|
|
126
|
+
One identification authorizes one protected action. Call `identify()` for each submission and keep
|
|
127
|
+
the submit button disabled while `isLoading` is `true`, as in the Quick start. A new call clears
|
|
128
|
+
`result`. While a call with the same `userId` and `timeout` is in flight, `identify()` returns that
|
|
129
|
+
call instead of starting another identification, so a double submit costs one identification: the
|
|
130
|
+
agent runs one identification at a time for a User HID and would refuse the second one with
|
|
131
|
+
`not_initialized`. A call without `timeout` counts as one with the plugin's `timeout` (10 seconds by
|
|
132
|
+
default), so with the default `identify()` and `identify({ timeout: 10000 })` share one
|
|
133
|
+
identification. Calls are shared within one `useIdentify()` helper, also after `reset()`, which only
|
|
134
|
+
clears the refs. On the server, accept each request ID once and only within your freshness window
|
|
135
|
+
(the server SDK examples use 5 minutes).
|
|
136
|
+
|
|
137
|
+
### Signed-in users: pass a User HID
|
|
138
|
+
|
|
139
|
+
```vue
|
|
140
|
+
<script setup lang="ts">
|
|
141
|
+
import { useIdentify } from '@shieldlabs-ai/vue';
|
|
142
|
+
|
|
143
|
+
const props = defineProps<{ userHid: string }>(); // computed on your server
|
|
144
|
+
const { identify } = useIdentify({ userId: () => props.userHid });
|
|
145
|
+
</script>
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`userId` accepts a string, a ref or a getter, and is read when `identify()` runs: changing it never
|
|
149
|
+
starts an identification. `null` and `undefined` mean anonymous. Compute the User HID on your
|
|
150
|
+
server from your account ID with a secret key, for example with the `userHid()` helper of the
|
|
151
|
+
server SDKs (HMAC-SHA256, 64 hex characters). Never pass a raw email address, phone number or
|
|
152
|
+
database ID. `@shieldlabs-ai/js` rejects the reserved values `"anonymous"`, `"fail"`, `"-1"` and
|
|
153
|
+
`"unknown"`, and warns once about values that look like an email address or contain `/`, `?`, `#`
|
|
154
|
+
or `%`, which are hard to search in the History API (see
|
|
155
|
+
[Signed-in users](https://github.com/ShieldLabs-ai/shieldlabs-js#signed-in-users-pass-a-user-hid)).
|
|
156
|
+
|
|
157
|
+
Options passed to `identify()` override the composable for that call: `identify({ userId: hid })`,
|
|
158
|
+
`identify({ userId: undefined })` for an anonymous identification, or `identify({ timeout: 5000 })`
|
|
159
|
+
for a shorter wait (milliseconds). The timeout bounds the whole call: the wait for the agent to load
|
|
160
|
+
and the agent's answer together. Without one, the plugin's `timeout` applies (10 seconds by
|
|
161
|
+
default). A call whose timeout ends before the agent is ready resolves `null` with a `timeout`
|
|
162
|
+
error and never reaches the agent.
|
|
163
|
+
|
|
164
|
+
### Identify once when a component mounts
|
|
165
|
+
|
|
166
|
+
`useIdentify({ runOnMount: true })` calls `identify()` once, after the component is mounted and the
|
|
167
|
+
agent is ready. Every identification is billed: use it in a component that mounts once per
|
|
168
|
+
protected flow (for example a checkout step), not in a layout or in a component that remounts on
|
|
169
|
+
every route change. With `autoLoad: false`, call `load()` before the component mounts (see
|
|
170
|
+
[Wait for consent](#wait-for-consent-with-autoload-false)); otherwise that identification ends right
|
|
171
|
+
away with `not_initialized`, like `identify()`, and it does not run again by itself after `load()`.
|
|
172
|
+
|
|
173
|
+
### Passive monitoring with `checkOnLoad`
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
app.use(createShieldLabs({ publicKey, checkOnLoad: true })); // anonymous visitor
|
|
177
|
+
app.use(createShieldLabs({ publicKey, checkOnLoad: { userId: hid } })); // signed-in user
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
`checkOnLoad` runs the agent's limited `check()` once, when the agent becomes ready. The agent runs
|
|
181
|
+
at most one such check per visit every five minutes, shared across tabs. The page does not need its
|
|
182
|
+
request ID: your backend sees the identification in the History API. For later background checks,
|
|
183
|
+
call `check()` from `useShieldLabs()`; it resolves `null` when the agent skipped the check.
|
|
184
|
+
|
|
185
|
+
The agent runs one identification at a time for a User HID, which shapes how the check fits in:
|
|
186
|
+
|
|
187
|
+
- When an `identify()` or `check()` for the same User HID is already in flight as the agent becomes
|
|
188
|
+
ready (for example `runOnMount`, a submit while the agent loads, or the call that loads the agent
|
|
189
|
+
again after a failed load), the plugin skips the check. That call identifies the visit, and a
|
|
190
|
+
check sent ahead of it would make the agent refuse it. Calls for another User HID do not stop
|
|
191
|
+
the check.
|
|
192
|
+
- While the check is in progress, an `identify()` for the same User HID can get `not_initialized`
|
|
193
|
+
(`useIdentify()` resolves `null`). Use `checkOnLoad` on pages without an immediate protected
|
|
194
|
+
action; on a signup, login or checkout page, the `identify()` on submit already covers the visit.
|
|
195
|
+
|
|
196
|
+
### Agent state with `useShieldLabs()`
|
|
197
|
+
|
|
198
|
+
```vue
|
|
199
|
+
<script setup lang="ts">
|
|
200
|
+
import { useShieldLabs } from '@shieldlabs-ai/vue';
|
|
201
|
+
|
|
202
|
+
const { status, error } = useShieldLabs();
|
|
203
|
+
</script>
|
|
204
|
+
|
|
205
|
+
<template>
|
|
206
|
+
<p v-if="status === 'error'">Identification is unavailable ({{ error?.code }}). You can still sign up.</p>
|
|
207
|
+
</template>
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
- `status` is `'loading'`, `'ready'` or `'error'`. In a component it stays `'loading'` until the
|
|
211
|
+
component is mounted, as during server-side rendering, so hydration always matches the server
|
|
212
|
+
HTML.
|
|
213
|
+
- `error` is the `ShieldLabsError` of the last failed load, otherwise `null`. The next
|
|
214
|
+
`identify()`, `check()`, `getAgent()` or `load()` loads the agent again.
|
|
215
|
+
- `identify(options?)` and `check(options?)` are the agent methods of `@shieldlabs-ai/js` and wait for
|
|
216
|
+
the agent within their timeout. Unlike `useIdentify()`, this `identify()` rejects with a
|
|
217
|
+
`ShieldLabsError` when there is no identification. With `autoLoad: false` they do not wait before
|
|
218
|
+
`load()` is called: `identify()` rejects with `not_initialized` and `check()` resolves `null` right
|
|
219
|
+
away.
|
|
220
|
+
- `load()` starts loading the agent now, unless it is loaded or loading, and loads again after a
|
|
221
|
+
failed load. With `autoLoad: false` nothing loads before it (see
|
|
222
|
+
[Wait for consent](#wait-for-consent-with-autoload-false)).
|
|
223
|
+
- `getAgent()` resolves the `@shieldlabs-ai/js` agent once it has loaded, for example for
|
|
224
|
+
`identifyOnInteraction()` (see
|
|
225
|
+
[Start on the first interaction with the form](#start-on-the-first-interaction-with-the-form)).
|
|
226
|
+
It loads the agent when needed (with `autoLoad: false` it waits for `load()`, with no timeout of
|
|
227
|
+
its own) and rejects with the `ShieldLabsError` of a failed load.
|
|
228
|
+
|
|
229
|
+
Nothing needs to wait for `'ready'`: `identify()` waits for the agent itself (with `autoLoad: false`,
|
|
230
|
+
once `load()` has been called).
|
|
231
|
+
|
|
232
|
+
### Nuxt 3
|
|
233
|
+
|
|
234
|
+
Add the Public Key to the public runtime config and set it with an environment variable:
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
// nuxt.config.ts
|
|
238
|
+
export default defineNuxtConfig({
|
|
239
|
+
runtimeConfig: {
|
|
240
|
+
public: {
|
|
241
|
+
shieldlabsPublicKey: '', // NUXT_PUBLIC_SHIELDLABS_PUBLIC_KEY
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
# .env
|
|
249
|
+
NUXT_PUBLIC_SHIELDLABS_PUBLIC_KEY=0123456789abcdef0123456789abcdef
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Install the plugin in the browser only, with a `.client` plugin file:
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
// plugins/shieldlabs.client.ts
|
|
256
|
+
import { createShieldLabs } from '@shieldlabs-ai/vue';
|
|
257
|
+
|
|
258
|
+
export default defineNuxtPlugin((nuxtApp) => {
|
|
259
|
+
const config = useRuntimeConfig();
|
|
260
|
+
nuxtApp.vueApp.use(createShieldLabs({ publicKey: config.public.shieldlabsPublicKey }));
|
|
261
|
+
});
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Use the composables in pages and components as in any Vue app. During server rendering they render
|
|
265
|
+
the initial state (`status` `'loading'`, no result) without the plugin and never load anything; in
|
|
266
|
+
the browser the plugin loads the agent once, after hydration.
|
|
267
|
+
|
|
268
|
+
```vue
|
|
269
|
+
<!-- pages/signup.vue -->
|
|
270
|
+
<script setup lang="ts">
|
|
271
|
+
import { useIdentify } from '@shieldlabs-ai/vue';
|
|
272
|
+
|
|
273
|
+
const email = ref('');
|
|
274
|
+
const { identify, isLoading } = useIdentify();
|
|
275
|
+
|
|
276
|
+
async function onSubmit() {
|
|
277
|
+
const result = await identify();
|
|
278
|
+
await $fetch('/api/signup', { method: 'POST', body: { email: email.value, requestId: result?.requestId ?? null } });
|
|
279
|
+
}
|
|
280
|
+
</script>
|
|
281
|
+
|
|
282
|
+
<template>
|
|
283
|
+
<form @submit.prevent="onSubmit">
|
|
284
|
+
<input v-model="email" type="email" required />
|
|
285
|
+
<button :disabled="isLoading">Sign up</button>
|
|
286
|
+
</form>
|
|
287
|
+
</template>
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
The matching server route reads the verdict with `@shieldlabs-ai/node`:
|
|
291
|
+
|
|
292
|
+
```ts
|
|
293
|
+
// server/api/signup.post.ts
|
|
294
|
+
import { ShieldLabs, evaluateIdentification } from '@shieldlabs-ai/node';
|
|
295
|
+
|
|
296
|
+
const shieldlabs = new ShieldLabs({ apiKey: process.env.SHIELDLABS_API_KEY! });
|
|
297
|
+
const usedRequestIds = new Set<string>(); // use a shared store such as Redis in production
|
|
298
|
+
|
|
299
|
+
export default defineEventHandler(async (event) => {
|
|
300
|
+
const { requestId } = await readBody<{ email: string; requestId: string | null }>(event);
|
|
301
|
+
const identification = requestId ? await shieldlabs.identifications.get(requestId) : null;
|
|
302
|
+
const verdict = evaluateIdentification(identification, {
|
|
303
|
+
isReplay: (id) => usedRequestIds.has(id), // your store of request IDs already used
|
|
304
|
+
});
|
|
305
|
+
if (!verdict.ok) throw createError({ statusCode: 403, statusMessage: 'Signup refused' });
|
|
306
|
+
usedRequestIds.add(identification!.request_id);
|
|
307
|
+
// Create the account.
|
|
308
|
+
});
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
### Server-side rendering and hydration
|
|
312
|
+
|
|
313
|
+
- Importing the package and installing the plugin on the server is safe: nothing touches `window`
|
|
314
|
+
or `document` during setup or render.
|
|
315
|
+
- The plugin loads the agent in the browser after the app is mounted. The server renderer never
|
|
316
|
+
mounts an app, so the server never loads the agent, and `runOnMount` and `checkOnLoad` run only
|
|
317
|
+
in the browser.
|
|
318
|
+
- `status` is `'loading'` during server rendering and in the first render of every component, so
|
|
319
|
+
hydration matches the server HTML even when a lazy component hydrates after the agent is ready.
|
|
320
|
+
- Without the plugin on the server (a client-only plugin, as in the Nuxt guide) the composables
|
|
321
|
+
render the same initial state. Calling `identify()` there rejects with `unsupported_environment`
|
|
322
|
+
(`useIdentify()` resolves `null` and sets `error`).
|
|
323
|
+
- On the server `load()` does nothing and `getAgent()` rejects with `unsupported_environment`, with
|
|
324
|
+
or without the plugin.
|
|
325
|
+
|
|
326
|
+
### Wait for consent with `autoLoad: false`
|
|
327
|
+
|
|
328
|
+
The agent does not read your consent banner. Where your policy or applicable law requires consent
|
|
329
|
+
before identification, create the plugin with `autoLoad: false`. Nothing loads until you call
|
|
330
|
+
`load()` from `useShieldLabs()`, for example when the visitor accepts:
|
|
331
|
+
|
|
332
|
+
```ts
|
|
333
|
+
// src/main.ts
|
|
334
|
+
import { createShieldLabs, useShieldLabs } from '@shieldlabs-ai/vue';
|
|
335
|
+
import { createApp } from 'vue';
|
|
336
|
+
import App from './App.vue';
|
|
337
|
+
|
|
338
|
+
const app = createApp(App).use(
|
|
339
|
+
createShieldLabs({ publicKey: import.meta.env.VITE_SHIELDLABS_PUBLIC_KEY, autoLoad: false }),
|
|
340
|
+
);
|
|
341
|
+
// A returning visitor who already gave consent (hasConsent() reads your consent storage). Call
|
|
342
|
+
// load() before mount(), so that identifications started on mount wait for the agent.
|
|
343
|
+
if (hasConsent()) app.runWithContext(() => useShieldLabs().load());
|
|
344
|
+
app.mount('#app');
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
```vue
|
|
348
|
+
<!-- ConsentBanner.vue -->
|
|
349
|
+
<script setup lang="ts">
|
|
350
|
+
import { useShieldLabs } from '@shieldlabs-ai/vue';
|
|
351
|
+
|
|
352
|
+
const { load } = useShieldLabs();
|
|
353
|
+
|
|
354
|
+
function accept() {
|
|
355
|
+
saveConsent(); // your consent storage
|
|
356
|
+
load();
|
|
357
|
+
}
|
|
358
|
+
</script>
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Until `load()` is called, `status` stays `'loading'` and `getAgent()` waits for it, with no timeout
|
|
362
|
+
of its own. `identify()` and `check()` do not wait: `useIdentify()` resolves `null` right away with a
|
|
363
|
+
`not_initialized` error, so the protected action goes ahead unverified, and from `useShieldLabs()`,
|
|
364
|
+
`identify()` rejects with `not_initialized` and `check()` resolves `null`. A `runOnMount`
|
|
365
|
+
identification that starts then ends the same way, and `load()` does not start it again. Once
|
|
366
|
+
`load()` has been called, calls wait for the agent within their timeout as usual. `checkOnLoad` runs
|
|
367
|
+
once the agent is ready after `load()`. See
|
|
368
|
+
[Consent](https://github.com/ShieldLabs-ai/shieldlabs-js#consent) for the cookie ID the agent keeps.
|
|
369
|
+
|
|
370
|
+
### Start on the first interaction with the form
|
|
371
|
+
|
|
372
|
+
An identification takes a moment, and the agent posts it right after it hands over the request ID.
|
|
373
|
+
For a classic full-page form post, start the identification on the first interaction with the form
|
|
374
|
+
with `identifyOnInteraction()` of the agent from `getAgent()`. By the time the user submits, it has
|
|
375
|
+
normally finished, so the page can navigate right away:
|
|
376
|
+
|
|
377
|
+
```vue
|
|
378
|
+
<script setup lang="ts">
|
|
379
|
+
import { useShieldLabs, type InteractionIdentifier } from '@shieldlabs-ai/vue';
|
|
380
|
+
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
|
|
381
|
+
|
|
382
|
+
const form = ref<HTMLFormElement | null>(null);
|
|
383
|
+
const requestId = ref('');
|
|
384
|
+
const { getAgent } = useShieldLabs();
|
|
385
|
+
let identifier: InteractionIdentifier | undefined;
|
|
386
|
+
let unmounted = false;
|
|
387
|
+
|
|
388
|
+
onMounted(() => {
|
|
389
|
+
getAgent()
|
|
390
|
+
.then((agent) => {
|
|
391
|
+
if (!unmounted && form.value) identifier = agent.identifyOnInteraction(form.value);
|
|
392
|
+
})
|
|
393
|
+
.catch(() => {
|
|
394
|
+
// No agent: the form is posted without a requestId.
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
onBeforeUnmount(() => {
|
|
399
|
+
unmounted = true;
|
|
400
|
+
identifier?.dispose();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
async function onSubmit() {
|
|
404
|
+
// The identification for this submission (none while the agent has not loaded); take() re-arms
|
|
405
|
+
// for the next one.
|
|
406
|
+
const result = await identifier?.take().catch(() => null);
|
|
407
|
+
requestId.value = result?.requestId ?? '';
|
|
408
|
+
await nextTick(); // the hidden field now holds the request ID
|
|
409
|
+
form.value?.submit();
|
|
410
|
+
}
|
|
411
|
+
</script>
|
|
412
|
+
|
|
413
|
+
<template>
|
|
414
|
+
<form ref="form" action="/signup" method="post" @submit.prevent="onSubmit">
|
|
415
|
+
<input name="email" type="email" required />
|
|
416
|
+
<input type="hidden" name="requestId" :value="requestId" />
|
|
417
|
+
<button>Sign up</button>
|
|
418
|
+
</form>
|
|
419
|
+
</template>
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
`getAgent()` resolves once the agent has loaded (with `autoLoad: false`, after `load()`), and the
|
|
423
|
+
plugin loads the agent only once. `take()` hands out the early identification only while it is
|
|
424
|
+
fresh and otherwise starts a new one, and `form.submit()` does not trigger the submit handler
|
|
425
|
+
again. If users can submit without interacting first (for example autofill and a single click),
|
|
426
|
+
prefer sending the form with `fetch()` as in the Quick start, which keeps the page alive. Details:
|
|
427
|
+
[Protect a form](https://github.com/ShieldLabs-ai/shieldlabs-js#protect-a-form).
|
|
428
|
+
|
|
429
|
+
### Test your components
|
|
430
|
+
|
|
431
|
+
Provide a stand-in context under `shieldLabsKey`, so component tests need no agent. With the form
|
|
432
|
+
from the Quick start in `SignupForm.vue`:
|
|
433
|
+
|
|
434
|
+
```ts
|
|
435
|
+
import { shieldLabsKey, type ShieldLabsContext, type ShieldLabsStatus } from '@shieldlabs-ai/vue';
|
|
436
|
+
import { flushPromises, mount } from '@vue/test-utils';
|
|
437
|
+
import { expect, it, vi } from 'vitest';
|
|
438
|
+
import { ref } from 'vue';
|
|
439
|
+
import SignupForm from './SignupForm.vue';
|
|
440
|
+
|
|
441
|
+
it('sends the request ID with the signup', async () => {
|
|
442
|
+
const identify = vi.fn(async () => ({ requestId: '6f1c2e8a-3b5d-4c7e-8f9a-0b1c2d3e4f5a', userId: null }));
|
|
443
|
+
const shieldlabs: ShieldLabsContext = {
|
|
444
|
+
status: ref<ShieldLabsStatus>('ready'),
|
|
445
|
+
error: ref(null),
|
|
446
|
+
identify,
|
|
447
|
+
check: vi.fn(async () => null),
|
|
448
|
+
load: vi.fn(),
|
|
449
|
+
getAgent: vi.fn(() => Promise.reject(new Error('No agent in component tests.'))),
|
|
450
|
+
};
|
|
451
|
+
const fetchMock = vi.fn(async () => new Response(null, { status: 201 }));
|
|
452
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
453
|
+
|
|
454
|
+
const wrapper = mount(SignupForm, {
|
|
455
|
+
global: { provide: { [shieldLabsKey as symbol]: shieldlabs } },
|
|
456
|
+
});
|
|
457
|
+
await wrapper.find('form').trigger('submit');
|
|
458
|
+
await flushPromises();
|
|
459
|
+
|
|
460
|
+
expect(identify).toHaveBeenCalledTimes(1);
|
|
461
|
+
expect(fetchMock).toHaveBeenCalledWith('/api/signup', expect.objectContaining({ method: 'POST' }));
|
|
462
|
+
});
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
### Call budget, Content Security Policy and consent
|
|
466
|
+
|
|
467
|
+
Every identification that runs is billed and uses part of a small per-IP ingest budget. Call
|
|
468
|
+
`identify()` once per protected action, never on every render or route change: the plugin and the
|
|
469
|
+
composables identify only when you call them, plus `runOnMount` and `checkOnLoad` when you turn
|
|
470
|
+
them on. Before going live, read these sections of the `@shieldlabs-ai/js` README:
|
|
471
|
+
|
|
472
|
+
- [Call budget](https://github.com/ShieldLabs-ai/shieldlabs-js#call-budget): limits per visitor IP
|
|
473
|
+
and the agent's own background checks.
|
|
474
|
+
- [Content Security Policy](https://github.com/ShieldLabs-ai/shieldlabs-js#content-security-policy):
|
|
475
|
+
the `script-src` and `connect-src` origins the agent needs.
|
|
476
|
+
- [Consent](https://github.com/ShieldLabs-ai/shieldlabs-js#consent): the agent's first-party cookie
|
|
477
|
+
ID and when to load.
|
|
478
|
+
|
|
479
|
+
## Reference
|
|
480
|
+
|
|
481
|
+
| Export | Description |
|
|
482
|
+
|---|---|
|
|
483
|
+
| `createShieldLabs(options)` | Returns the Vue plugin for `app.use()`. One per app; loads the agent once |
|
|
484
|
+
| `useShieldLabs()` | `{ status, error, identify, check, load, getAgent }` of the app |
|
|
485
|
+
| `useIdentify(options?)` | `{ identify, result, isLoading, error, reset }`, an identify helper per component |
|
|
486
|
+
| `shieldLabsKey` | `InjectionKey<ShieldLabsContext>` under which the plugin provides its context |
|
|
487
|
+
| `ShieldLabsError` | The error class of `@shieldlabs-ai/js` (re-exported). Has `code` and optional `cause` |
|
|
488
|
+
| `VERSION` | The package version, for example `"1.0.0"` |
|
|
489
|
+
| Types | `ShieldLabsOptions`, `CheckOnLoadOption`, `ShieldLabsPlugin`, `ShieldLabsContext`, `ShieldLabsStatus`, `UseShieldLabsReturn`, `UseIdentifyOptions`, `UseIdentifyReturn`, and from `@shieldlabs-ai/js`: `LoadOptions`, `IdentifyOptions`, `IdentifyResult`, `ShieldLabsAgent`, `InteractionIdentifier`, `ShieldLabsErrorCode` |
|
|
490
|
+
|
|
491
|
+
`createShieldLabs(options)`
|
|
492
|
+
|
|
493
|
+
| Option | Type | Default | Description |
|
|
494
|
+
|---|---|---|---|
|
|
495
|
+
| `publicKey` | `string` | required | Public Key of your domain. `@shieldlabs-ai/js` checks it when the agent loads |
|
|
496
|
+
| `environment` | `'production' \| 'development'` | `'production'` | Which ShieldLabs CDN to load the agent from |
|
|
497
|
+
| `scriptUrl` | `string` | | Advanced: agent module URL override (`https`, or `http` on `localhost` and `127.0.0.1`) |
|
|
498
|
+
| `timeout` | `number` | `10000` | Milliseconds to wait for the agent to load, and the default timeout of each call (the wait for the agent plus its answer) |
|
|
499
|
+
| `checkOnLoad` | `boolean \| { userId?: string \| null }` | `false` | Runs `check()` once when the agent becomes ready, unless an `identify()` or `check()` for the same User HID is in flight |
|
|
500
|
+
| `autoLoad` | `boolean` | `true` | Loads the agent right after the app is mounted; with `false`, nothing loads until `load()` from `useShieldLabs()` is called, and until then `identify()` fails fast with `not_initialized`, `check()` resolves `null` and `getAgent()` waits |
|
|
501
|
+
|
|
502
|
+
`createShieldLabs()` throws a `ShieldLabsError` with code `invalid_options` for a missing options
|
|
503
|
+
object or an invalid `checkOnLoad` or `autoLoad`. A second ShieldLabs plugin on the same app is
|
|
504
|
+
ignored with a warning.
|
|
505
|
+
|
|
506
|
+
`useShieldLabs()`
|
|
507
|
+
|
|
508
|
+
| Member | Type | Description |
|
|
509
|
+
|---|---|---|
|
|
510
|
+
| `status` | `Readonly<Ref<'loading' \| 'ready' \| 'error'>>` | Agent state. `'loading'` during server rendering and a component's first render |
|
|
511
|
+
| `error` | `Readonly<Ref<ShieldLabsError \| null>>` | Why the last load failed |
|
|
512
|
+
| `identify(options?)` | `Promise<IdentifyResult>` | Fresh identification with a new request ID; `timeout` covers the wait for the agent and its answer. Rejects with a `ShieldLabsError`; with `autoLoad: false`, with `not_initialized` right away until `load()` is called |
|
|
513
|
+
| `check(options?)` | `Promise<IdentifyResult \| null>` | Background check, `null` when the agent skipped it (and right away with `autoLoad: false` until `load()` is called). Waits like `identify()` |
|
|
514
|
+
| `load()` | `void` | Starts loading the agent unless it is loaded or loading; loads again after a failed load. Does nothing during server rendering |
|
|
515
|
+
| `getAgent()` | `Promise<ShieldLabsAgent>` | The agent once it has loaded, for `identifyOnInteraction()`. Loads when needed (with `autoLoad: false`, waits for `load()` with no timeout of its own); rejects with the load's `ShieldLabsError` |
|
|
516
|
+
|
|
517
|
+
`useIdentify(options?)`
|
|
518
|
+
|
|
519
|
+
| Option | Type | Default | Description |
|
|
520
|
+
|---|---|---|---|
|
|
521
|
+
| `userId` | `MaybeRefOrGetter<string \| null \| undefined>` | | User HID computed on your server, read when `identify()` runs |
|
|
522
|
+
| `runOnMount` | `boolean` | `false` | Calls `identify()` once after the component is mounted. With `autoLoad: false`, call `load()` before the mount: a run that starts before `load()` resolves `null` with `not_initialized` and does not run again |
|
|
523
|
+
|
|
524
|
+
| Member | Type | Description |
|
|
525
|
+
|---|---|---|
|
|
526
|
+
| `identify(options?)` | `Promise<IdentifyResult \| null>` | Fresh identification. Never rejects: `null` and `error` when there is none (`not_initialized` right away with `autoLoad: false` until `load()` is called). Returns this helper's call in flight with the same `userId` and `timeout` (an omitted `timeout` counts as the plugin's) instead of starting another |
|
|
527
|
+
| `result` | `Readonly<Ref<IdentifyResult \| null>>` | Result of the latest call |
|
|
528
|
+
| `isLoading` | `Readonly<Ref<boolean>>` | `true` while the latest call runs |
|
|
529
|
+
| `error` | `Readonly<Ref<ShieldLabsError \| null>>` | Why the latest call returned `null` |
|
|
530
|
+
| `reset()` | `void` | Clears `result`, `error` and `isLoading` |
|
|
531
|
+
|
|
532
|
+
`IdentifyOptions` is `{ userId?: string; timeout?: number }` and `IdentifyResult` is
|
|
533
|
+
`{ requestId: string; userId: string | null }`, as in `@shieldlabs-ai/js`. In this package `timeout`
|
|
534
|
+
bounds the whole call: the wait for the agent to load and the agent's answer.
|
|
535
|
+
|
|
536
|
+
Composables called outside `setup()` (or `app.runWithContext()`), or in the browser without the
|
|
537
|
+
plugin, throw an `Error` that names the fix. In the Options API, call them in `setup()`; outside
|
|
538
|
+
components (for example in a router guard), use `app.runWithContext(() => useShieldLabs())`.
|
|
539
|
+
|
|
540
|
+
## Errors and retries
|
|
541
|
+
|
|
542
|
+
Every identification error is a `ShieldLabsError`. Branch on `error.code`:
|
|
543
|
+
|
|
544
|
+
| `code` | When | What to do |
|
|
545
|
+
|---|---|---|
|
|
546
|
+
| `invalid_options` | An option failed validation, for example an empty `publicKey` because the environment variable is missing (the plugin also logs this with `console.warn`) or a reserved `userId` | Fix the configuration or the call; retrying does not help |
|
|
547
|
+
| `unsupported_environment` | Server-side rendering, a worker, or a page that is not a secure context | Identify in the browser; serve the page over HTTPS (`localhost` and `127.0.0.1` also work over `http`) |
|
|
548
|
+
| `load_failed` | The agent could not be imported: network error, content blocker, Content Security Policy | Continue without an identification. The next `identify()`, `check()`, `getAgent()` or `load()` loads again |
|
|
549
|
+
| `not_initialized` | The agent did not start an identification, for example because another one is running in this or another tab. With `autoLoad: false`, also an `identify()` made before `load()` is called | Continue without an identification, or retry once later (with `autoLoad: false`, after `load()`) |
|
|
550
|
+
| `timeout` | The agent did not load and answer within the call's timeout (default 10 seconds) | Continue without an identification. A call that timed out while waiting for the agent never reaches it, and a late answer is ignored |
|
|
551
|
+
|
|
552
|
+
- The plugin reports a failed load in `status` (`'error'`) and `error`. A failed load is not kept:
|
|
553
|
+
the next `identify()`, `check()`, `getAgent()` or `load()` tries again. Mounting more components
|
|
554
|
+
does not.
|
|
555
|
+
- Identifications are never retried automatically, because every identification is billed. Call
|
|
556
|
+
`identify()` again on the next submission.
|
|
557
|
+
- Whenever there is no identification, send the protected action anyway without a `requestId`:
|
|
558
|
+
your backend treats a missing identification as unverified, never as clean.
|
|
559
|
+
|
|
560
|
+
More detail: [`@shieldlabs-ai/js` Errors](https://github.com/ShieldLabs-ai/shieldlabs-js#errors).
|
|
561
|
+
|
|
562
|
+
## Compatibility
|
|
563
|
+
|
|
564
|
+
- Vue 3.3 or later (tested with 3.3 and 3.5), with Vue SSR (`createSSRApp` and
|
|
565
|
+
`vue/server-renderer`) and Nuxt 3.
|
|
566
|
+
- Browsers: as `@shieldlabs-ai/js`, current browsers with ES modules, dynamic `import()` and
|
|
567
|
+
WebCrypto, on a secure context (HTTPS, or `localhost` and `127.0.0.1` during development).
|
|
568
|
+
- Output: ES2019 syntax as ESM and CommonJS with TypeScript declarations. No runtime dependencies;
|
|
569
|
+
peer dependencies `vue` `^3.3.0` and `@shieldlabs-ai/js` `^1.0.0`.
|
|
570
|
+
- Server runtimes (Node.js 18+, Bun, Deno, edge): safe to import and render.
|
|
571
|
+
|
|
572
|
+
## Development
|
|
573
|
+
|
|
574
|
+
```bash
|
|
575
|
+
npm ci
|
|
576
|
+
npm install --no-save ../shieldlabs-js/shieldlabs-ai-js-1.0.0.tgz # until @shieldlabs-ai/js is on npm
|
|
577
|
+
npm run typecheck
|
|
578
|
+
npm run lint
|
|
579
|
+
npm test -- --coverage # builds first, then runs the tests
|
|
580
|
+
npm run build
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
See [CONTRIBUTING.md](./CONTRIBUTING.md) for building the `@shieldlabs-ai/js` tarball. Documentation:
|
|
584
|
+
<https://docs.shieldlabs.ai>. Analytics dashboard: <https://app.shieldlabs.ai>. Support:
|
|
585
|
+
<contact@shieldlabs.ai>.
|
|
586
|
+
|
|
587
|
+
## License
|
|
588
|
+
|
|
589
|
+
[MIT](./LICENSE), Copyright (c) 2026 ShieldLabs Inc.
|