@useparallel/agent-identification 0.8.2 → 0.10.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/README.md +24 -31
- package/dist/agent-id-track.js +1 -1
- package/dist/agent-id.js +1 -1
- package/dist/browser/index.js +68 -3
- package/dist/react/index.js +1 -1
- package/dist/server/index.js +340 -25
- package/dist/types/browser/track.d.ts +5 -0
- package/dist/types/protocol.d.ts +16 -2
- package/dist/types/server/encoding.d.ts +6 -0
- package/dist/types/server/index.d.ts +2 -0
- package/dist/types/server/ip.d.ts +2 -0
- package/dist/types/server/setup.d.ts +60 -0
- package/dist/types/server/sha256.d.ts +2 -0
- package/dist/types/server/signals.d.ts +5 -0
- package/dist/types/server/tokens.d.ts +1 -0
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -24,42 +24,35 @@ hosted collector is `https://parallel-agent-identification.vercel.app`; use it
|
|
|
24
24
|
wherever the examples below say `https://YOUR-COLLECTOR`. Full documentation is at
|
|
25
25
|
[parallel-agent-dashboard.vercel.app/docs](https://parallel-agent-dashboard.vercel.app/docs).
|
|
26
26
|
|
|
27
|
-
The server code runs on Node.js 20 or later
|
|
28
|
-
|
|
27
|
+
The server code runs on Node.js 20 or later, in Next.js middleware on the Edge runtime,
|
|
28
|
+
and in Cloudflare Workers with a compatibility date of 2024-11-11 or later. The page
|
|
29
|
+
script and widget run in any current browser.
|
|
29
30
|
|
|
30
31
|
## Report agents from your server
|
|
31
32
|
|
|
32
|
-
Install the package and add
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
Install the package and add one call where your server handles requests. It skips
|
|
34
|
+
ordinary requests without a network call, and never throws or slows the response. Keep
|
|
35
|
+
the site's secret in `AGENT_ID_SITE_SECRET` on the server only.
|
|
35
36
|
|
|
36
37
|
```ts
|
|
37
|
-
import {
|
|
38
|
-
reportLink,
|
|
39
|
-
reportRequestSignals,
|
|
40
|
-
reportSignedRequest,
|
|
41
|
-
serverTiming,
|
|
42
|
-
} from "@useparallel/agent-identification/server";
|
|
38
|
+
import { agentIdentification } from "@useparallel/agent-identification/server";
|
|
43
39
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
secret: process.env.AGENT_ID_SITE_SECRET!,
|
|
48
|
-
};
|
|
40
|
+
// Express. Reads your server secret from AGENT_ID_SITE_SECRET; the hosted collector is the
|
|
41
|
+
// default. Behind a proxy, set "trust proxy" so req.ip is the visitor's address.
|
|
42
|
+
app.use(agentIdentification({ siteId: "your-public-site-id" }).express());
|
|
49
43
|
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
void reportSignedRequest(req, { ...agentId, link });
|
|
56
|
-
// req.ip lets the collector check a named agent against its operator's published addresses.
|
|
57
|
-
// Behind a proxy or load balancer, set Express's "trust proxy" so req.ip is the visitor's.
|
|
58
|
-
void reportRequestSignals(req, { ...agentId, link, clientIp: req.ip });
|
|
59
|
-
next();
|
|
60
|
-
});
|
|
44
|
+
// Next.js proxy.ts:
|
|
45
|
+
// const agentId = agentIdentification({ siteId: "your-public-site-id" });
|
|
46
|
+
// export function proxy(request, event) {
|
|
47
|
+
// return agentId.next(request, event, NextResponse.next());
|
|
48
|
+
// }
|
|
61
49
|
```
|
|
62
50
|
|
|
51
|
+
It reports signed requests, agents that name themselves (including Claude Code, Codex and Claude desktop's browser),
|
|
52
|
+
and command-line clients such as curl, each with the page requested, and checks Anthropic's
|
|
53
|
+
and OpenAI's agents against their published addresses. Report actions with
|
|
54
|
+
`agentId.action({ action, outcome, sessionBinding })`.
|
|
55
|
+
|
|
63
56
|
The page script reads the link from its own page's `Server-Timing` header and claims
|
|
64
57
|
it, so a signed request joins that visit. Only that browser has the link, and no
|
|
65
58
|
cookie is set. Keep pages that carry a link out of shared caches.
|
|
@@ -75,7 +68,7 @@ Add one tag to every page. It draws nothing, sets no cookies and needs no secret
|
|
|
75
68
|
```html
|
|
76
69
|
<script
|
|
77
70
|
defer
|
|
78
|
-
src="https://YOUR-COLLECTOR/sdk/v0.
|
|
71
|
+
src="https://YOUR-COLLECTOR/sdk/v0.10.0/agent-id-track.js"
|
|
79
72
|
data-site="YOUR-SITE-ID"
|
|
80
73
|
></script>
|
|
81
74
|
```
|
|
@@ -101,7 +94,7 @@ on servers.
|
|
|
101
94
|
<div id="visitor-choice"></div>
|
|
102
95
|
<script
|
|
103
96
|
defer
|
|
104
|
-
src="https://YOUR-COLLECTOR/sdk/v0.
|
|
97
|
+
src="https://YOUR-COLLECTOR/sdk/v0.10.0/agent-id.js"
|
|
105
98
|
data-site="YOUR-SITE-ID"
|
|
106
99
|
data-target="#visitor-choice"
|
|
107
100
|
data-mode="optional"
|
|
@@ -122,7 +115,7 @@ To require a choice before using a section of the page:
|
|
|
122
115
|
<form id="checkout"><!-- Your existing form --></form>
|
|
123
116
|
<script
|
|
124
117
|
defer
|
|
125
|
-
src="https://YOUR-COLLECTOR/sdk/v0.
|
|
118
|
+
src="https://YOUR-COLLECTOR/sdk/v0.10.0/agent-id.js"
|
|
126
119
|
data-site="YOUR-SITE-ID"
|
|
127
120
|
data-target="#visitor-choice"
|
|
128
121
|
data-mode="required"
|
|
@@ -256,7 +249,7 @@ For a restrictive Content Security Policy, allow the collector in `script-src`,
|
|
|
256
249
|
`style-src` and `connect-src`. Use external integration scripts or your existing
|
|
257
250
|
nonce policy. The widget uses an external stylesheet and requires no inline
|
|
258
251
|
script/style exceptions. Its `styleUrl` can point to your own copy of the CSS.
|
|
259
|
-
Versioned asset hashes are at `/sdk/v0.
|
|
252
|
+
Versioned asset hashes are at `/sdk/v0.10.0/integrity.json`.
|
|
260
253
|
|
|
261
254
|
## What's collected
|
|
262
255
|
|
package/dist/agent-id-track.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";(()=>{var B=512;var M=[/^\d+$/,/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,/^[0-9a-f]{12,}$/i,/@/,/^(?=(?:[^\d]*\d){3})(?=.*[a-z])[a-z0-9_-]{8,}$/i];function I(e){let t=e.replace(/[?#][\s\S]*$/,"").split("/").map(n=>{let i=n;try{i=decodeURIComponent(n)}catch{}return M.some(d=>d.test(i))?":id":n}).join("/");return(t.startsWith("/")?t:`/${t}`).slice(0,B)}function y(e){return typeof e=="string"&&/^\/[A-Za-z0-9_.~:/{}*-]{0,255}$/.test(e)}var T="agent-id";function b(e){return typeof e=="string"&&/^[A-Za-z0-9_-]{22}$/.test(e)}var f=new Map,z=15e3,p=class extends Error{constructor(t){super(`Identification service returned HTTP ${t}.`);this.status=t;this.name="HttpError"}status};function C(e,r){let t;try{t=new URL(e,window.location.href)}catch{throw new Error(`${r} must be a valid URL.`)}let n=["localhost","127.0.0.1","[::1]"].includes(t.hostname);if(t.protocol!=="https:"&&!(t.protocol==="http:"&&n)||t.username||t.password||t.search||t.hash)throw new Error(`${r} must use HTTPS (or localhost HTTP), without credentials, query or fragment.`);return t.href.replace(/\/$/,"")}function P(e){if(!/^[a-zA-Z0-9_-]{1,100}$/.test(e))throw new Error("siteId must contain 1\u2013100 letters, numbers, underscores or hyphens.");return e}function O(){if(window.location.origin==="null")throw new Error("Serve this website over HTTPS (or localhost HTTP) to use identification.");return window.location.origin}function g(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function S(e){return typeof e=="string"&&/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(e)}function m(e){return typeof e=="string"&&/^[A-Za-z0-9_-]{32,512}$/.test(e)}function h(e){return typeof e=="string"&&/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(e)&&Number.isFinite(Date.parse(e))&&Date.parse(e)>Date.now()}function _(e){return!g(e)||!m(e.token)||!S(e.sessionId)||!h(e.expiresAt)?null:{token:e.token,sessionId:e.sessionId,expiresAt:e.expiresAt}}async function U(e,r){let t=JSON.stringify([e.apiUrl,e.siteId,e.origin,r??null]),n=await crypto.subtle.digest("SHA-256",new TextEncoder().encode(t));return`parallel-agent-id:v1:${Array.from(new Uint8Array(n),i=>i.toString(16).padStart(2,"0")).join("")}`}function v(e,r){let t=f.get(e)??null;if(r==="session")try{let n=sessionStorage.getItem(e);t=n?_(JSON.parse(n)):t,n&&!t&&sessionStorage.removeItem(e)}catch{}return _(t)}function E(e,r,t){for(let[n,i]of f)h(i.expiresAt)||f.delete(n);if(t?f.set(e,{...t}):f.delete(e),r==="session")try{t?sessionStorage.setItem(e,JSON.stringify(t)):sessionStorage.removeItem(e)}catch{}}function R(e){return e instanceof Error?e:new Error("Identification request failed.")}async function x(e,r,t,n,i,d){let s=new AbortController,o=()=>s.abort();r.addEventListener("abort",o,{once:!0}),r.aborted&&s.abort();let a=setTimeout(()=>s.abort(),z);try{let c=await fetch(`${e.apiUrl}${t}`,{method:n,headers:{...i===void 0?{}:{"Content-Type":"application/json"},...d?{Authorization:`Bearer ${d}`,"X-Agent-Site":e.siteId}:{}},...i===void 0?{}:{body:JSON.stringify(i)},credentials:"omit",cache:"no-store",mode:"cors",referrerPolicy:"no-referrer",signal:s.signal});if(!c.ok)throw new p(c.status);if(n==="DELETE"){if(c.status!==204)throw new Error("Session revocation was not confirmed.");return null}if(!c.headers.get("content-type")?.toLowerCase().includes("application/json"))throw new Error("Identification service did not return JSON.");return await c.json()}catch(c){throw s.signal.aborted&&!r.aborted?new Error("Identification service timed out. Please retry.",{cause:c}):c}finally{clearTimeout(a),r.removeEventListener("abort",o)}}function $(e,r){return g(e)&&Array.isArray(e.results)&&e.results.some(t=>g(t)&&t.id===r&&(t.status==="recorded"||t.status==="duplicate"))}function N(){let e=[];return navigator.webdriver===!0&&e.push({signal:"webdriver",detail:"navigator.webdriver"}),/HeadlessChrome\//.test(navigator.userAgent)&&e.push({signal:"headless_browser",detail:"HeadlessChrome"}),e}var w=new Map;function j(){try{let[e]=performance.getEntriesByType("navigation"),r=(e?.serverTiming??[]).filter(t=>t.name===T&&b(t.description)).map(t=>t.description);return[...new Set(r)].slice(0,5)}catch{return[]}}var L=new Set,H=new WeakMap;function G(e){let{history:r}=window,t=H.get(r);if(!t){let n=()=>{for(let i of[...t])i()};t=new Set,H.set(r,t);for(let i of["pushState","replaceState"]){let d=r[i];r[i]=function(...s){let o=d.apply(this,s);return n(),o}}window.addEventListener("popstate",n)}return t.add(e),()=>t.delete(e)}function V(e){let r=e.storage??"session";if(r!=="session"&&r!=="memory")throw new Error("Invalid storage option.");if(e.route!==void 0&&!y(e.route))throw new Error("route must be a path pattern such as /tickets/:id.");return{apiUrl:C(e.apiUrl,"apiUrl"),siteId:P(e.siteId),origin:O(),storage:r,navigation:e.navigation??!0,automationSignals:e.automationSignals??!0,onError:e.onError}}function Z(e,r){return Object.keys(e).every(t=>t==="onError"&&r[t]===void 0||e[t]===r[t])}var k=class{constructor(r,t){this.config=r;this.key=U(r,void 0),this.stopWatching=r.navigation?G(()=>this.navigated()):()=>{},this.ready=this.pageView({route:t}),this.ready.then(()=>this.claimLinks()).catch(()=>{})}config;ready;controller=new AbortController;key;stopWatching;session=null;starting=null;destroyed=!1;path=window.location.pathname;async claimLinks(){let r=j();r.length&&await this.deliver("/v1/links",{links:r},r[0],"links",t=>r.every(n=>g(t)&&Array.isArray(t.results)&&t.results.some(i=>g(i)&&i.id===n)))}navigated(){this.destroyed||window.location.pathname===this.path||this.pageView().catch(()=>{})}async pageView(r={}){if(this.destroyed)throw new Error("This tracker was destroyed.");if(r.route!==void 0&&!y(r.route))throw new Error("route must be a path pattern such as /tickets/:id.");this.path=window.location.pathname;let t={id:crypto.randomUUID(),path:I(this.path),...r.route?{route:r.route}:{},occurredAt:new Date().toISOString()},n=await this.deliver("/v1/page-views",{views:[t]},t.id,"page view");this.config.automationSignals&&await this.firstSignals(n.sessionId)&&this.reportSignals().catch(()=>{})}async firstSignals(r){let t=`${await this.key}:signals`;if(L.has(r))return!1;if(L.add(r),this.config.storage==="memory")return!0;try{if(sessionStorage.getItem(t)===r)return!1;sessionStorage.setItem(t,r)}catch{}return!0}async reportSignals(){for(let r of N()){let t={id:crypto.randomUUID(),...r,occurredAt:new Date().toISOString()};await this.deliver("/v1/browser-signals",{signals:[t]},t.id,"signal")}}async current(){let r=await this.key;return this.session??=v(r,this.config.storage),this.session&&h(this.session.expiresAt)?this.session:(this.starting??=this.start(r).finally(()=>this.starting=null),this.starting)}async start(r){let t=await x(this.config,this.controller.signal,"/v1/sessions","POST",{siteId:this.config.siteId});if(!g(t)||!S(t.sessionId)||t.siteId!==this.config.siteId||t.origin!==this.config.origin||!h(t.expiresAt)||!m(t.sessionToken))throw new Error("Identification service returned an invalid new session.");return this.session={token:t.sessionToken,sessionId:t.sessionId,expiresAt:t.expiresAt},E(r,this.config.storage,this.session),this.session}async deliver(r,t,n,i,d=s=>$(s,n)){for(let s=0;;s++){let o=null;try{o=await this.current();let a=await x(this.config,this.controller.signal,r,"POST",t,o.token);if(!d(a))throw new Error(`Identification service did not confirm the ${i}.`);return o}catch(a){if(this.destroyed)throw a;let c=o!==null&&a instanceof p&&[401,404,410].includes(a.status);if(c&&o&&this.session===o){this.session=null;let l=await this.key;v(l,this.config.storage)?.token===o.token&&E(l,this.config.storage,null)}if(!(c||!(a instanceof p)||a.status===429||a.status>=500)||s>=2){let l=R(a);try{this.config.onError?.(l)}catch{}throw l}if(c||await new Promise(l=>setTimeout(l,300*3**s)),this.destroyed)throw a}}}destroy(){this.destroyed||(this.destroyed=!0,this.stopWatching(),this.controller.abort(),w.get(this.config.apiUrl+" "+this.config.siteId)===this&&w.delete(this.config.apiUrl+" "+this.config.siteId))}};function A(e){let r=V(e),t=r.apiUrl+" "+r.siteId,n=w.get(t);if(n){if(!Z(n.config,r))throw new Error("This site is already tracked on this page with different options.");return n}let i=new k(r,e.route);return w.set(t,i),i}window.AgentIdentificationTracker=Object.freeze({track:A});var u=document.currentScript;function D(e){document.dispatchEvent(new CustomEvent("agent-identification:error",{detail:{message:e}}))}if(u?.dataset.site)try{A({siteId:u.dataset.site,apiUrl:u.dataset.api??new URL(u.src).origin,storage:u.dataset.storage,route:u.dataset.route,navigation:u.dataset.navigation!=="off",automationSignals:u.dataset.automationSignals!=="off",onError:e=>D(e.message)})}catch(e){D(e instanceof Error?e.message:"Agent Identification could not start.")}})();
|
|
1
|
+
"use strict";(()=>{var M=512;var j=[/^\d+$/,/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,/^[0-9a-f]{12,}$/i,/@/,/^(?=(?:[^\d]*\d){3})(?=.*[a-z])[a-z0-9_-]{8,}$/i];function I(t){let e=t.replace(/[?#][\s\S]*$/,"").split("/").map(r=>{let i=r;try{i=decodeURIComponent(r)}catch{}return j.some(d=>d.test(i))?":id":r}).join("/");return(e.startsWith("/")?e:`/${e}`).slice(0,M)}function m(t){return typeof t=="string"&&/^\/[A-Za-z0-9_.~:/{}*-]{0,255}$/.test(t)}var A="agent-id";function C(t){return typeof t=="string"&&/^[A-Za-z0-9_-]{22}$/.test(t)}var f=new Map,z=15e3,p=class extends Error{constructor(e){super(`Identification service returned HTTP ${e}.`);this.status=e;this.name="HttpError"}status};function P(t,n){let e;try{e=new URL(t,window.location.href)}catch{throw new Error(`${n} must be a valid URL.`)}let r=["localhost","127.0.0.1","[::1]"].includes(e.hostname);if(e.protocol!=="https:"&&!(e.protocol==="http:"&&r)||e.username||e.password||e.search||e.hash)throw new Error(`${n} must use HTTPS (or localhost HTTP), without credentials, query or fragment.`);return e.href.replace(/\/$/,"")}function O(t){if(!/^[a-zA-Z0-9_-]{1,100}$/.test(t))throw new Error("siteId must contain 1\u2013100 letters, numbers, underscores or hyphens.");return t}function U(){if(window.location.origin==="null")throw new Error("Serve this website over HTTPS (or localhost HTTP) to use identification.");return window.location.origin}function g(t){return typeof t=="object"&&t!==null&&!Array.isArray(t)}function S(t){return typeof t=="string"&&/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(t)}function v(t){return typeof t=="string"&&/^[A-Za-z0-9_-]{32,512}$/.test(t)}function h(t){return typeof t=="string"&&/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(t)&&Number.isFinite(Date.parse(t))&&Date.parse(t)>Date.now()}function _(t){return!g(t)||!v(t.token)||!S(t.sessionId)||!h(t.expiresAt)?null:{token:t.token,sessionId:t.sessionId,expiresAt:t.expiresAt}}async function R(t,n){let e=JSON.stringify([t.apiUrl,t.siteId,t.origin,n??null]),r=await crypto.subtle.digest("SHA-256",new TextEncoder().encode(e));return`parallel-agent-id:v1:${Array.from(new Uint8Array(r),i=>i.toString(16).padStart(2,"0")).join("")}`}function x(t,n){let e=f.get(t)??null;if(n==="session")try{let r=sessionStorage.getItem(t);e=r?_(JSON.parse(r)):e,r&&!e&&sessionStorage.removeItem(t)}catch{}return _(e)}function E(t,n,e){for(let[r,i]of f)h(i.expiresAt)||f.delete(r);if(e?f.set(t,{...e}):f.delete(t),n==="session")try{e?sessionStorage.setItem(t,JSON.stringify(e)):sessionStorage.removeItem(t)}catch{}}function L(t){return t instanceof Error?t:new Error("Identification request failed.")}async function k(t,n,e,r,i,d){let o=new AbortController,s=()=>o.abort();n.addEventListener("abort",s,{once:!0}),n.aborted&&o.abort();let a=setTimeout(()=>o.abort(),z);try{let c=await fetch(`${t.apiUrl}${e}`,{method:r,headers:{...i===void 0?{}:{"Content-Type":"application/json"},...d?{Authorization:`Bearer ${d}`,"X-Agent-Site":t.siteId}:{}},...i===void 0?{}:{body:JSON.stringify(i)},credentials:"omit",cache:"no-store",mode:"cors",referrerPolicy:"no-referrer",signal:o.signal});if(!c.ok)throw new p(c.status);if(r==="DELETE"){if(c.status!==204)throw new Error("Session revocation was not confirmed.");return null}if(!c.headers.get("content-type")?.toLowerCase().includes("application/json"))throw new Error("Identification service did not return JSON.");return await c.json()}catch(c){throw o.signal.aborted&&!n.aborted?new Error("Identification service timed out. Please retry.",{cause:c}):c}finally{clearTimeout(a),n.removeEventListener("abort",s)}}function N(t,n){return g(t)&&Array.isArray(t.results)&&t.results.some(e=>g(e)&&e.id===n&&(e.status==="recorded"||e.status==="duplicate"))}function $(){let t=[];return navigator.webdriver===!0&&t.push({signal:"webdriver",detail:"navigator.webdriver"}),/HeadlessChrome\//.test(navigator.userAgent)&&t.push({signal:"headless_browser",detail:"HeadlessChrome"}),t}var w=new Map;function W(){try{let[t]=performance.getEntriesByType("navigation"),n=(t?.serverTiming??[]).filter(e=>e.name===A&&C(e.description)).map(e=>e.description);return[...new Set(n)].slice(0,5)}catch{return[]}}var D=new Set,H=new Set,B=new WeakMap;function K(t){let{history:n}=window,e=B.get(n);if(!e){let r=()=>{for(let i of[...e])i()};e=new Set,B.set(n,e);for(let i of["pushState","replaceState"]){let d=n[i];n[i]=function(...o){let s=d.apply(this,o);return r(),s}}window.addEventListener("popstate",r)}return e.add(t),()=>e.delete(t)}function V(t){if(!(t==null||t==="")){if(typeof t!="string"||t.length>4096)throw new Error("bindingContext must be the signed context your server created.");return t}}function q(t){let n=0,e=!1,r=()=>performance.now()-n<=1e3,i=()=>{n=performance.now()},d=()=>{e=r()},o=c=>{if(!c.isTrusted||r())return;let y=c.inputType;(y==="insertText"||y==="insertCompositionText"&&!e)&&(a(),t("typing"))},s={capture:!0,passive:!0};window.addEventListener("keydown",i,s),window.addEventListener("compositionstart",d,s),window.addEventListener("input",o,s);function a(){window.removeEventListener("keydown",i,s),window.removeEventListener("compositionstart",d,s),window.removeEventListener("input",o,s)}return a}function Z(t){let n=t.storage??"session";if(n!=="session"&&n!=="memory")throw new Error("Invalid storage option.");if(t.route!==void 0&&!m(t.route))throw new Error("route must be a path pattern such as /tickets/:id.");return{apiUrl:P(t.apiUrl,"apiUrl"),siteId:O(t.siteId),origin:U(),storage:n,navigation:t.navigation??!0,automationSignals:t.automationSignals??!0,bindingContext:V(t.bindingContext),onError:t.onError}}function J(t,n){return Object.keys(t).every(e=>e==="onError"&&n[e]===void 0||t[e]===n[e])}var T=class{constructor(n,e){this.config=n;this.key=R(n,n.bindingContext),this.stopInput=n.automationSignals?q(r=>{this.reportInput(r).catch(()=>{})}):()=>{},this.stopWatching=n.navigation?K(()=>this.navigated()):()=>{},this.ready=this.pageView({route:e}),this.ready.then(()=>this.claimLinks()).catch(()=>{})}config;ready;controller=new AbortController;key;stopWatching;stopInput;session=null;starting=null;destroyed=!1;path=window.location.pathname;async claimLinks(){let n=W();n.length&&await this.deliver("/v1/links",{links:n},n[0],"links",e=>n.every(r=>g(e)&&Array.isArray(e.results)&&e.results.some(i=>g(i)&&i.id===r)))}navigated(){this.destroyed||window.location.pathname===this.path||this.pageView().catch(()=>{})}async pageView(n={}){if(this.destroyed)throw new Error("This tracker was destroyed.");if(n.route!==void 0&&!m(n.route))throw new Error("route must be a path pattern such as /tickets/:id.");this.path=window.location.pathname;let e={id:crypto.randomUUID(),path:I(this.path),...n.route?{route:n.route}:{},occurredAt:new Date().toISOString()},r=await this.deliver("/v1/page-views",{views:[e]},e.id,"page view");this.config.automationSignals&&await this.firstSignals(r.sessionId)&&this.reportSignals().catch(()=>{})}async firstSignals(n){let e=`${await this.key}:signals`;if(D.has(n))return!1;if(D.add(n),this.config.storage==="memory")return!0;try{if(sessionStorage.getItem(e)===n)return!1;sessionStorage.setItem(e,n)}catch{}return!0}async reportInput(n){let e=await this.current(),r=`${await this.key}:input`;if(H.has(e.sessionId))return;H.add(e.sessionId);try{if(this.config.storage==="session"){if(sessionStorage.getItem(r)===e.sessionId)return;sessionStorage.setItem(r,e.sessionId)}}catch{}let i={id:crypto.randomUUID(),signal:"synthetic_input",detail:n,occurredAt:new Date().toISOString()};await this.deliver("/v1/browser-signals",{signals:[i]},i.id,"signal")}async reportSignals(){for(let n of $()){let e={id:crypto.randomUUID(),...n,occurredAt:new Date().toISOString()};await this.deliver("/v1/browser-signals",{signals:[e]},e.id,"signal")}}async current(){let n=await this.key;return this.session??=x(n,this.config.storage),this.session&&h(this.session.expiresAt)?this.session:(this.starting??=this.start(n).finally(()=>this.starting=null),this.starting)}async start(n){let e=await k(this.config,this.controller.signal,"/v1/sessions","POST",{siteId:this.config.siteId,...this.config.bindingContext?{bindingContext:this.config.bindingContext}:{}});if(!g(e)||!S(e.sessionId)||e.siteId!==this.config.siteId||e.origin!==this.config.origin||!h(e.expiresAt)||!v(e.sessionToken))throw new Error("Identification service returned an invalid new session.");return this.session={token:e.sessionToken,sessionId:e.sessionId,expiresAt:e.expiresAt},E(n,this.config.storage,this.session),this.session}async deliver(n,e,r,i,d=o=>N(o,r)){for(let o=0;;o++){let s=null;try{s=await this.current();let a=await k(this.config,this.controller.signal,n,"POST",e,s.token);if(!d(a))throw new Error(`Identification service did not confirm the ${i}.`);return s}catch(a){if(this.destroyed)throw a;let c=s!==null&&a instanceof p&&[401,404,410].includes(a.status);if(c&&s&&this.session===s){this.session=null;let l=await this.key;x(l,this.config.storage)?.token===s.token&&E(l,this.config.storage,null)}if(!(c||!(a instanceof p)||a.status===429||a.status>=500)||o>=2){let l=L(a);try{this.config.onError?.(l)}catch{}throw l}if(c||await new Promise(l=>setTimeout(l,300*3**o)),this.destroyed)throw a}}}destroy(){this.destroyed||(this.destroyed=!0,this.stopWatching(),this.stopInput(),this.controller.abort(),w.get(this.config.apiUrl+" "+this.config.siteId)===this&&w.delete(this.config.apiUrl+" "+this.config.siteId))}};function b(t){let n=Z(t),e=n.apiUrl+" "+n.siteId,r=w.get(e);if(r){if(!J(r.config,n))throw new Error("This site is already tracked on this page with different options.");return r}let i=new T(n,t.route);return w.set(e,i),i}window.AgentIdentificationTracker=Object.freeze({track:b});var u=document.currentScript;function G(t){document.dispatchEvent(new CustomEvent("agent-identification:error",{detail:{message:t}}))}if(u?.dataset.site)try{b({siteId:u.dataset.site,apiUrl:u.dataset.api??new URL(u.src).origin,storage:u.dataset.storage,route:u.dataset.route,navigation:u.dataset.navigation!=="off",automationSignals:u.dataset.automationSignals!=="off",...u.dataset.binding?{bindingContext:u.dataset.binding}:{},onError:t=>G(t.message)})}catch(t){G(t instanceof Error?t.message:"Agent Identification could not start.")}})();
|
package/dist/agent-id.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";(()=>{var E="0.8.2",W=["ai_assistant","person","prefer_not_to_say"];function D(t){return typeof t=="string"&&W.includes(t)}var V=512;var j=[/^\d+$/,/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,/^[0-9a-f]{12,}$/i,/@/,/^(?=(?:[^\d]*\d){3})(?=.*[a-z])[a-z0-9_-]{8,}$/i];function b(t){let i=t.replace(/[?#][\s\S]*$/,"").split("/").map(n=>{let r=n;try{r=decodeURIComponent(n)}catch{}return j.some(s=>s.test(r))?":id":n}).join("/");return(i.startsWith("/")?i:`/${i}`).slice(0,V)}function m(t){return typeof t=="string"&&/^\/[A-Za-z0-9_.~:/{}*-]{0,255}$/.test(t)}var h=new Map,B=15e3,u=class extends Error{constructor(i){super(`Identification service returned HTTP ${i}.`);this.status=i;this.name="HttpError"}status};function w(t,e){let i;try{i=new URL(t,window.location.href)}catch{throw new Error(`${e} must be a valid URL.`)}let n=["localhost","127.0.0.1","[::1]"].includes(i.hostname);if(i.protocol!=="https:"&&!(i.protocol==="http:"&&n)||i.username||i.password||i.search||i.hash)throw new Error(`${e} must use HTTPS (or localhost HTTP), without credentials, query or fragment.`);return i.href.replace(/\/$/,"")}function x(t){if(!/^[a-zA-Z0-9_-]{1,100}$/.test(t))throw new Error("siteId must contain 1\u2013100 letters, numbers, underscores or hyphens.");return t}function I(){if(window.location.origin==="null")throw new Error("Serve this website over HTTPS (or localhost HTTP) to use identification.");return window.location.origin}function g(t){return typeof t=="object"&&t!==null&&!Array.isArray(t)}function N(t,e=1,i=512){return typeof t=="string"&&t.length>=e&&t.length<=i&&/^[A-Za-z0-9._~-]+$/.test(t)}function p(t){return typeof t=="string"&&/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(t)}function v(t){return typeof t=="string"&&/^[A-Za-z0-9_-]{32,512}$/.test(t)}function f(t){return typeof t=="string"&&/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(t)&&Number.isFinite(Date.parse(t))&&Date.parse(t)>Date.now()}function H(t){return!g(t)||!v(t.token)||!p(t.sessionId)||!f(t.expiresAt)?null:{token:t.token,sessionId:t.sessionId,expiresAt:t.expiresAt}}async function k(t,e){let i=JSON.stringify([t.apiUrl,t.siteId,t.origin,e??null]),n=await crypto.subtle.digest("SHA-256",new TextEncoder().encode(i));return`parallel-agent-id:v1:${Array.from(new Uint8Array(n),r=>r.toString(16).padStart(2,"0")).join("")}`}function A(t,e){let i=h.get(t)??null;if(e==="session")try{let n=sessionStorage.getItem(t);i=n?H(JSON.parse(n)):i,n&&!i&&sessionStorage.removeItem(t)}catch{}return H(i)}function C(t,e,i){for(let[n,r]of h)f(r.expiresAt)||h.delete(n);if(i?h.set(t,{...i}):h.delete(t),e==="session")try{i?sessionStorage.setItem(t,JSON.stringify(i)):sessionStorage.removeItem(t)}catch{}}function S(t){return t instanceof Error?t:new Error("Identification request failed.")}async function T(t,e,i,n,r,s){let o=new AbortController,a=()=>o.abort();e.addEventListener("abort",a,{once:!0}),e.aborted&&o.abort();let d=setTimeout(()=>o.abort(),B);try{let l=await fetch(`${t.apiUrl}${i}`,{method:n,headers:{...r===void 0?{}:{"Content-Type":"application/json"},...s?{Authorization:`Bearer ${s}`,"X-Agent-Site":t.siteId}:{}},...r===void 0?{}:{body:JSON.stringify(r)},credentials:"omit",cache:"no-store",mode:"cors",referrerPolicy:"no-referrer",signal:o.signal});if(!l.ok)throw new u(l.status);if(n==="DELETE"){if(l.status!==204)throw new Error("Session revocation was not confirmed.");return null}if(!l.headers.get("content-type")?.toLowerCase().includes("application/json"))throw new Error("Identification service did not return JSON.");return await l.json()}catch(l){throw o.signal.aborted&&!e.aborted?new Error("Identification service timed out. Please retry.",{cause:l}):l}finally{clearTimeout(d),e.removeEventListener("abort",a)}}function _(t,e){return g(t)&&Array.isArray(t.results)&&t.results.some(i=>g(i)&&i.id===e&&(i.status==="recorded"||i.status==="duplicate"))}function P(){let t=[];return navigator.webdriver===!0&&t.push({signal:"webdriver",detail:"navigator.webdriver"}),/HeadlessChrome\//.test(navigator.userAgent)&&t.push({signal:"headless_browser",detail:"HeadlessChrome"}),t}var R=new WeakMap,L=new WeakMap,O={ai_assistant:"AI assistant",person:"Person",prefer_not_to_say:"Prefer not to say"};function q(t,e){let i=typeof t=="string"?document.querySelector(t):t;if(!(i instanceof HTMLElement))throw new Error(`${e} must identify an existing HTML element.`);return i}function z(t){let e=q(t.container,"container"),i=t.gate===void 0?void 0:q(t.gate,"gate"),n=w(t.apiUrl,"apiUrl"),r=t.mode??"optional",s=t.storage??"session";if(r!=="optional"&&r!=="required")throw new Error("Invalid identification mode.");if(s!=="session"&&s!=="memory")throw new Error("Invalid storage option.");if(i&&(i===e||i.contains(e)))throw new Error("The gate must not contain the identification widget.");let o=I();if(t.route!==void 0&&!m(t.route))throw new Error("route must be a path pattern such as /tickets/:id.");return{container:e,gate:i,apiUrl:n,siteId:x(t.siteId),origin:o,mode:r,storage:s,styleUrl:w(t.styleUrl??`${n}/sdk/v${E}/agent-id.css`,"styleUrl"),bindingContext:t.bindingContext,onRecorded:t.onRecorded,onError:t.onError,pageViews:t.pageViews??!0,route:t.route,automationSignals:t.automationSignals??!1}}function G(t,e){return Object.keys(t).every(i=>(i==="onRecorded"||i==="onError")&&e[i]===void 0?!0:t[i]===e[i])}function U(t,e,i,n){if(!g(t)||!p(t.sessionId)||i!==void 0&&t.sessionId!==i||t.siteId!==e.siteId||t.origin!==e.origin||!f(t.expiresAt)||t.evidence!=="self_reported")throw new Error("Identification service returned an invalid or mismatched session.");let r=t.choice===null&&t.receipt===null&&t.eventId===null,s=D(t.choice)&&N(t.receipt,1,8192)&&p(t.eventId);if(!r&&!s||n!==void 0&&t.choice!==n)throw new Error("Identification service returned an invalid or mismatched response.");return{sessionId:t.sessionId,siteId:e.siteId,origin:e.origin,expiresAt:t.expiresAt,choice:t.choice,receipt:t.receipt,eventId:t.eventId,evidence:"self_reported"}}var M=class{constructor(e){this.config=e;this.originalInert=e.gate?.inert??!1,this.build(),this.lock(),this.ready=this.start(this.version),this.ready.catch(()=>{})}config;ready;host=document.createElement("div");root=this.host.attachShadow({mode:"open"});status=document.createElement("p");retry=document.createElement("button");buttons=[];originalInert;version=0;controller=new AbortController;destroyed=!1;state=null;session=null;key=null;pending=null;retryAction=null;expiry;viewed=null;signalled=null;build(){this.host.dataset.agentIdentification=E;let e=document.createElement("link");e.rel="stylesheet",e.href=this.config.styleUrl;let i=document.createElement("section");i.className="agent-identification",i.setAttribute("aria-label","Visitor identification");let n=document.createElement("h2");n.textContent=this.config.mode==="required"?"Before continuing":"How are you visiting?";let r=document.createElement("p");r.textContent="Choose one option. Your answer is self-reported.";let s=document.createElement("div");s.className="choices";for(let o of Object.keys(O)){let a=document.createElement("button");a.type="button",a.textContent=O[o],a.disabled=!0,a.addEventListener("click",()=>{this.pending={choice:o,requestId:crypto.randomUUID()},this.submit(this.version).catch(()=>{})}),this.buttons.push(a),s.append(a)}this.status.className="status",this.status.setAttribute("role","status"),this.status.setAttribute("aria-live","polite"),this.status.setAttribute("aria-atomic","true"),this.retry.type="button",this.retry.textContent="Retry",this.retry.hidden=!0,this.retry.addEventListener("click",()=>{let o=this.retryAction;o&&o().catch(()=>{})}),i.append(n,r,s,this.status,this.retry),this.root.append(e,i),this.config.container.append(this.host)}active(e){if(this.destroyed||e!==this.version)throw new DOMException("Widget operation was superseded.","AbortError")}lock(){this.config.mode==="required"&&this.config.gate&&(this.config.gate.inert=!0)}busy(e){this.status.textContent=e,this.retry.hidden=!0,this.retryAction=null;for(let i of this.buttons)i.disabled=!0}fail(e,i,n,r){if(this.destroyed||i!==this.version)return;let s=S(e);this.status.textContent=`${r} ${s.message}`,this.retry.hidden=!1,this.retryAction=n,this.notifyError(s)}notifyError(e){try{this.config.onError?.(e)}catch{}}accepted(e){if(this.state=e,clearTimeout(this.expiry),this.expiry=setTimeout(()=>{this.destroyed||this.state?.sessionId!==e.sessionId||(this.state=null,this.lock(),this.busy("Your identification session expired."),this.retry.hidden=!1,this.retryAction=()=>this.restart())},Math.min(Date.parse(e.expiresAt)-Date.now(),2147483647)),this.retry.hidden=!0,this.retryAction=null,e.choice!==null){this.status.textContent=`Response recorded: ${O[e.choice]}.`,this.config.mode==="required"&&this.config.gate&&(this.config.gate.inert=this.originalInert);try{this.config.onRecorded?.({...e})}catch{this.notifyError(new Error("Your onRecorded callback failed after the response was recorded."))}}else this.status.textContent=this.config.mode==="required"?"Select any option to continue.":"You can choose an option or continue without answering.";for(let i of this.buttons)i.disabled=e.choice!==null;return{...e}}load(){return this.key?A(this.key,this.config.storage):null}persist(e){this.key&&C(this.key,this.config.storage,e)}request(e,i,n,r){return T(this.config,this.controller.signal,e,i,n,r)}async start(e){this.busy("Loading identification\u2026"),this.lock();try{let i=typeof this.config.bindingContext=="function"?await this.config.bindingContext():this.config.bindingContext;if(this.active(e),i!==void 0&&(typeof i!="string"||i.length===0||i.length>8192))throw new Error("bindingContext must be a nonempty signed context.");let n=await k(this.config,i);if(this.active(e),this.key=n,this.session=this.load(),this.session)try{let o=await this.request("/v1/sessions/current","GET",void 0,this.session.token);this.active(e);let a=U(o,this.config,this.session.sessionId);return this.session.expiresAt=a.expiresAt,this.persist(this.session),this.view(this.accepted(a))}catch(o){if(this.active(e),!(o instanceof u)||![401,404,410].includes(o.status))throw o;this.persist(null),this.session=null}let r=await this.request("/v1/sessions","POST",{siteId:this.config.siteId,...i===void 0?{}:{bindingContext:i}});this.active(e);let s=U(r,this.config);if(!g(r)||!v(r.sessionToken)||s.choice!==null)throw new Error("Identification service returned an invalid new session.");return this.session={token:r.sessionToken,sessionId:s.sessionId,expiresAt:s.expiresAt},this.persist(this.session),this.view(this.accepted(s))}catch(i){throw this.fail(i,e,()=>this.restart(),"Could not load identification."),i}}async submit(e){if(!this.pending||!this.session)throw new Error("Identification session is not ready.");let i=this.pending,n=this.session;this.busy("Recording your response\u2026");try{let r=await this.request("/v1/declarations","POST",i,n.token);this.active(e);let s=U(r,this.config,n.sessionId,i.choice);return n.expiresAt=s.expiresAt,this.persist(n),this.pending=null,this.accepted(s)}catch(r){throw this.fail(r,e,()=>this.submit(this.version),"Could not record your response."),r}}view(e){return this.config.pageViews&&this.viewed!==e.sessionId&&(this.viewed=e.sessionId,this.pageView({route:this.config.route}).catch(()=>{})),this.config.automationSignals&&this.signalled!==e.sessionId&&(this.signalled=e.sessionId,this.reportSignals().catch(()=>{})),e}async pageView(e={}){if(this.active(this.version),e.route!==void 0&&!m(e.route))throw new Error("route must be a path pattern such as /tickets/:id.");let i={id:crypto.randomUUID(),path:b(window.location.pathname),...e.route?{route:e.route}:{},occurredAt:new Date().toISOString()};await this.deliver("/v1/page-views",{views:[i]},i.id,"page view")}async deliver(e,i,n,r){let s=this.controller.signal;for(let o=0;;o++){let a=this.session;try{if(!a)throw new Error("Identification session is not ready.");let d=await this.request(e,"POST",i,a.token);if(!_(d,n))throw new Error(`Identification service did not confirm the ${r}.`);return}catch(d){if(s.aborted||this.destroyed)throw d;if(!(a!==null&&(!(d instanceof u)||d.status===429||d.status>=500))||o>=2){let y=S(d);throw this.notifyError(y),y}if(await new Promise(y=>setTimeout(y,300*3**o)),s.aborted||this.destroyed)throw d}}}async reportSignals(){for(let e of P()){let i={id:crypto.randomUUID(),...e,occurredAt:new Date().toISOString()};await this.deliver("/v1/browser-signals",{signals:[i]},i.id,"signal")}}restart(){return this.active(this.version),this.controller.abort(),clearTimeout(this.expiry),this.controller=new AbortController,this.version+=1,this.start(this.version)}getState(){return this.state?{...this.state}:null}async reset(){this.active(this.version),this.controller.abort(),clearTimeout(this.expiry),this.controller=new AbortController;let e=++this.version;this.busy("Resetting identification\u2026"),this.state=null,this.pending=null,this.lock();try{this.session&&(await this.request("/v1/sessions/current","DELETE",void 0,this.session.token),this.active(e)),this.persist(null),this.session=null}catch(i){throw this.fail(i,e,()=>this.reset(),"Could not reset identification."),i}return this.start(e)}destroy(){this.destroyed||(this.destroyed=!0,this.version+=1,this.controller.abort(),clearTimeout(this.expiry),this.host.remove(),this.config.mode==="required"&&this.config.gate&&(this.config.gate.inert=this.originalInert,L.delete(this.config.gate)),R.delete(this.config.container),this.retryAction=null)}};function $(t){let e=z(t),i=R.get(e.container);if(i){if(!G(i.config,e))throw new Error("This container already has an identification widget with different options.");return i}if(e.mode==="required"&&e.gate&&L.has(e.gate))throw new Error("This gate is already controlled by another identification widget.");let n=new M(e);return R.set(e.container,n),e.mode==="required"&&e.gate&&L.set(e.gate,n),n}window.AgentIdentification=Object.freeze({init:$});var c=document.currentScript;if(c?.dataset.site){let t=()=>{try{let e;c.dataset.target?e=c.dataset.target:(e=document.createElement("div"),e.dataset.agentIdentificationContainer="",document.body.append(e));let i={container:e,siteId:c.dataset.site,apiUrl:c.dataset.api??new URL(c.src).origin,mode:c.dataset.mode,gate:c.dataset.gate,storage:c.dataset.storage,styleUrl:c.dataset.style,pageViews:c.dataset.pageViews!=="off",route:c.dataset.route,automationSignals:c.dataset.automationSignals==="on",onRecorded:n=>{(typeof e=="string"?document.querySelector(e):e).dispatchEvent(new CustomEvent("agent-identification:recorded",{detail:n,bubbles:!0}))},onError:n=>{document.dispatchEvent(new CustomEvent("agent-identification:error",{detail:{message:n.message}}))}};$(i)}catch(e){let i=e instanceof Error?e.message:"Identification widget could not initialize.",n=document.createElement("p");n.setAttribute("role","alert"),n.textContent=`Visitor identification unavailable. ${i}`,c.insertAdjacentElement("afterend",n),document.dispatchEvent(new CustomEvent("agent-identification:error",{detail:{message:i}}))}};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",t,{once:!0}):t()}})();
|
|
1
|
+
"use strict";(()=>{var E="0.10.0",W=["ai_assistant","person","prefer_not_to_say"];function $(t){return typeof t=="string"&&W.includes(t)}var V=512;var B=[/^\d+$/,/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,/^[0-9a-f]{12,}$/i,/@/,/^(?=(?:[^\d]*\d){3})(?=.*[a-z])[a-z0-9_-]{8,}$/i];function b(t){let i=t.replace(/[?#][\s\S]*$/,"").split("/").map(n=>{let r=n;try{r=decodeURIComponent(n)}catch{}return B.some(s=>s.test(r))?":id":n}).join("/");return(i.startsWith("/")?i:`/${i}`).slice(0,V)}function m(t){return typeof t=="string"&&/^\/[A-Za-z0-9_.~:/{}*-]{0,255}$/.test(t)}var h=new Map,j=15e3,l=class extends Error{constructor(i){super(`Identification service returned HTTP ${i}.`);this.status=i;this.name="HttpError"}status};function w(t,e){let i;try{i=new URL(t,window.location.href)}catch{throw new Error(`${e} must be a valid URL.`)}let n=["localhost","127.0.0.1","[::1]"].includes(i.hostname);if(i.protocol!=="https:"&&!(i.protocol==="http:"&&n)||i.username||i.password||i.search||i.hash)throw new Error(`${e} must use HTTPS (or localhost HTTP), without credentials, query or fragment.`);return i.href.replace(/\/$/,"")}function x(t){if(!/^[a-zA-Z0-9_-]{1,100}$/.test(t))throw new Error("siteId must contain 1\u2013100 letters, numbers, underscores or hyphens.");return t}function I(){if(window.location.origin==="null")throw new Error("Serve this website over HTTPS (or localhost HTTP) to use identification.");return window.location.origin}function g(t){return typeof t=="object"&&t!==null&&!Array.isArray(t)}function N(t,e=1,i=512){return typeof t=="string"&&t.length>=e&&t.length<=i&&/^[A-Za-z0-9._~-]+$/.test(t)}function p(t){return typeof t=="string"&&/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(t)}function v(t){return typeof t=="string"&&/^[A-Za-z0-9_-]{32,512}$/.test(t)}function f(t){return typeof t=="string"&&/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(t)&&Number.isFinite(Date.parse(t))&&Date.parse(t)>Date.now()}function M(t){return!g(t)||!v(t.token)||!p(t.sessionId)||!f(t.expiresAt)?null:{token:t.token,sessionId:t.sessionId,expiresAt:t.expiresAt}}async function k(t,e){let i=JSON.stringify([t.apiUrl,t.siteId,t.origin,e??null]),n=await crypto.subtle.digest("SHA-256",new TextEncoder().encode(i));return`parallel-agent-id:v1:${Array.from(new Uint8Array(n),r=>r.toString(16).padStart(2,"0")).join("")}`}function C(t,e){let i=h.get(t)??null;if(e==="session")try{let n=sessionStorage.getItem(t);i=n?M(JSON.parse(n)):i,n&&!i&&sessionStorage.removeItem(t)}catch{}return M(i)}function T(t,e,i){for(let[n,r]of h)f(r.expiresAt)||h.delete(n);if(i?h.set(t,{...i}):h.delete(t),e==="session")try{i?sessionStorage.setItem(t,JSON.stringify(i)):sessionStorage.removeItem(t)}catch{}}function S(t){return t instanceof Error?t:new Error("Identification request failed.")}async function A(t,e,i,n,r,s){let o=new AbortController,a=()=>o.abort();e.addEventListener("abort",a,{once:!0}),e.aborted&&o.abort();let c=setTimeout(()=>o.abort(),j);try{let u=await fetch(`${t.apiUrl}${i}`,{method:n,headers:{...r===void 0?{}:{"Content-Type":"application/json"},...s?{Authorization:`Bearer ${s}`,"X-Agent-Site":t.siteId}:{}},...r===void 0?{}:{body:JSON.stringify(r)},credentials:"omit",cache:"no-store",mode:"cors",referrerPolicy:"no-referrer",signal:o.signal});if(!u.ok)throw new l(u.status);if(n==="DELETE"){if(u.status!==204)throw new Error("Session revocation was not confirmed.");return null}if(!u.headers.get("content-type")?.toLowerCase().includes("application/json"))throw new Error("Identification service did not return JSON.");return await u.json()}catch(u){throw o.signal.aborted&&!e.aborted?new Error("Identification service timed out. Please retry.",{cause:u}):u}finally{clearTimeout(c),e.removeEventListener("abort",a)}}function P(t,e){return g(t)&&Array.isArray(t.results)&&t.results.some(i=>g(i)&&i.id===e&&(i.status==="recorded"||i.status==="duplicate"))}function _(){let t=[];return navigator.webdriver===!0&&t.push({signal:"webdriver",detail:"navigator.webdriver"}),/HeadlessChrome\//.test(navigator.userAgent)&&t.push({signal:"headless_browser",detail:"HeadlessChrome"}),t}var U=new WeakMap,L=new WeakMap,O={ai_assistant:"AI assistant",person:"Person",prefer_not_to_say:"Prefer not to say"};function q(t,e){let i=typeof t=="string"?document.querySelector(t):t;if(!(i instanceof HTMLElement))throw new Error(`${e} must identify an existing HTML element.`);return i}function G(t){let e=q(t.container,"container"),i=t.gate===void 0?void 0:q(t.gate,"gate"),n=w(t.apiUrl,"apiUrl"),r=t.mode??"optional",s=t.storage??"session";if(r!=="optional"&&r!=="required")throw new Error("Invalid identification mode.");if(s!=="session"&&s!=="memory")throw new Error("Invalid storage option.");if(i&&(i===e||i.contains(e)))throw new Error("The gate must not contain the identification widget.");let o=I();if(t.route!==void 0&&!m(t.route))throw new Error("route must be a path pattern such as /tickets/:id.");return{container:e,gate:i,apiUrl:n,siteId:x(t.siteId),origin:o,mode:r,storage:s,styleUrl:w(t.styleUrl??`${n}/sdk/v${E}/agent-id.css`,"styleUrl"),bindingContext:t.bindingContext,onRecorded:t.onRecorded,onError:t.onError,pageViews:t.pageViews??!0,route:t.route,automationSignals:t.automationSignals??!1}}function z(t,e){return Object.keys(t).every(i=>(i==="onRecorded"||i==="onError")&&e[i]===void 0?!0:t[i]===e[i])}function R(t,e,i,n){if(!g(t)||!p(t.sessionId)||i!==void 0&&t.sessionId!==i||t.siteId!==e.siteId||t.origin!==e.origin||!f(t.expiresAt)||t.evidence!=="self_reported")throw new Error("Identification service returned an invalid or mismatched session.");let r=t.choice===null&&t.receipt===null&&t.eventId===null,s=$(t.choice)&&N(t.receipt,1,8192)&&p(t.eventId);if(!r&&!s||n!==void 0&&t.choice!==n)throw new Error("Identification service returned an invalid or mismatched response.");return{sessionId:t.sessionId,siteId:e.siteId,origin:e.origin,expiresAt:t.expiresAt,choice:t.choice,receipt:t.receipt,eventId:t.eventId,evidence:"self_reported"}}var D=class{constructor(e){this.config=e;this.originalInert=e.gate?.inert??!1,this.build(),this.lock(),this.ready=this.start(this.version),this.ready.catch(()=>{})}config;ready;host=document.createElement("div");root=this.host.attachShadow({mode:"open"});status=document.createElement("p");retry=document.createElement("button");buttons=[];originalInert;version=0;controller=new AbortController;destroyed=!1;state=null;session=null;key=null;pending=null;retryAction=null;expiry;viewed=null;signalled=null;build(){this.host.dataset.agentIdentification=E;let e=document.createElement("link");e.rel="stylesheet",e.href=this.config.styleUrl;let i=document.createElement("section");i.className="agent-identification",i.setAttribute("aria-label","Visitor identification");let n=document.createElement("h2");n.textContent=this.config.mode==="required"?"Before continuing":"How are you visiting?";let r=document.createElement("p");r.textContent="Choose one option. Your answer is self-reported.";let s=document.createElement("div");s.className="choices";for(let o of Object.keys(O)){let a=document.createElement("button");a.type="button",a.textContent=O[o],a.disabled=!0,a.addEventListener("click",()=>{this.pending={choice:o,requestId:crypto.randomUUID()},this.submit(this.version).catch(()=>{})}),this.buttons.push(a),s.append(a)}this.status.className="status",this.status.setAttribute("role","status"),this.status.setAttribute("aria-live","polite"),this.status.setAttribute("aria-atomic","true"),this.retry.type="button",this.retry.textContent="Retry",this.retry.hidden=!0,this.retry.addEventListener("click",()=>{let o=this.retryAction;o&&o().catch(()=>{})}),i.append(n,r,s,this.status,this.retry),this.root.append(e,i),this.config.container.append(this.host)}active(e){if(this.destroyed||e!==this.version)throw new DOMException("Widget operation was superseded.","AbortError")}lock(){this.config.mode==="required"&&this.config.gate&&(this.config.gate.inert=!0)}busy(e){this.status.textContent=e,this.retry.hidden=!0,this.retryAction=null;for(let i of this.buttons)i.disabled=!0}fail(e,i,n,r){if(this.destroyed||i!==this.version)return;let s=S(e);this.status.textContent=`${r} ${s.message}`,this.retry.hidden=!1,this.retryAction=n,this.notifyError(s)}notifyError(e){try{this.config.onError?.(e)}catch{}}accepted(e){if(this.state=e,clearTimeout(this.expiry),this.expiry=setTimeout(()=>{this.destroyed||this.state?.sessionId!==e.sessionId||(this.state=null,this.lock(),this.busy("Your identification session expired."),this.retry.hidden=!1,this.retryAction=()=>this.restart())},Math.min(Date.parse(e.expiresAt)-Date.now(),2147483647)),this.retry.hidden=!0,this.retryAction=null,e.choice!==null){this.status.textContent=`Response recorded: ${O[e.choice]}.`,this.config.mode==="required"&&this.config.gate&&(this.config.gate.inert=this.originalInert);try{this.config.onRecorded?.({...e})}catch{this.notifyError(new Error("Your onRecorded callback failed after the response was recorded."))}}else this.status.textContent=this.config.mode==="required"?"Select any option to continue.":"You can choose an option or continue without answering.";for(let i of this.buttons)i.disabled=e.choice!==null;return{...e}}load(){return this.key?C(this.key,this.config.storage):null}persist(e){this.key&&T(this.key,this.config.storage,e)}request(e,i,n,r){return A(this.config,this.controller.signal,e,i,n,r)}async start(e){this.busy("Loading identification\u2026"),this.lock();try{let i=typeof this.config.bindingContext=="function"?await this.config.bindingContext():this.config.bindingContext;if(this.active(e),i!==void 0&&(typeof i!="string"||i.length===0||i.length>8192))throw new Error("bindingContext must be a nonempty signed context.");let n=await k(this.config,i);if(this.active(e),this.key=n,this.session=this.load(),this.session)try{let o=await this.request("/v1/sessions/current","GET",void 0,this.session.token);this.active(e);let a=R(o,this.config,this.session.sessionId);return this.session.expiresAt=a.expiresAt,this.persist(this.session),this.view(this.accepted(a))}catch(o){if(this.active(e),!(o instanceof l)||![401,404,410].includes(o.status))throw o;this.persist(null),this.session=null}let r=await this.request("/v1/sessions","POST",{siteId:this.config.siteId,...i===void 0?{}:{bindingContext:i}});this.active(e);let s=R(r,this.config);if(!g(r)||!v(r.sessionToken)||s.choice!==null)throw new Error("Identification service returned an invalid new session.");return this.session={token:r.sessionToken,sessionId:s.sessionId,expiresAt:s.expiresAt},this.persist(this.session),this.view(this.accepted(s))}catch(i){throw this.fail(i,e,()=>this.restart(),"Could not load identification."),i}}async submit(e){if(!this.pending||!this.session)throw new Error("Identification session is not ready.");let i=this.pending,n=this.session;this.busy("Recording your response\u2026");try{let r=await this.request("/v1/declarations","POST",i,n.token);this.active(e);let s=R(r,this.config,n.sessionId,i.choice);return n.expiresAt=s.expiresAt,this.persist(n),this.pending=null,this.accepted(s)}catch(r){throw this.fail(r,e,()=>this.submit(this.version),"Could not record your response."),r}}view(e){return this.config.pageViews&&this.viewed!==e.sessionId&&(this.viewed=e.sessionId,this.pageView({route:this.config.route}).catch(()=>{})),this.config.automationSignals&&this.signalled!==e.sessionId&&(this.signalled=e.sessionId,this.reportSignals().catch(()=>{})),e}async pageView(e={}){if(this.active(this.version),e.route!==void 0&&!m(e.route))throw new Error("route must be a path pattern such as /tickets/:id.");let i={id:crypto.randomUUID(),path:b(window.location.pathname),...e.route?{route:e.route}:{},occurredAt:new Date().toISOString()};await this.deliver("/v1/page-views",{views:[i]},i.id,"page view")}async deliver(e,i,n,r){let s=this.controller.signal;for(let o=0;;o++){let a=this.session;try{if(!a)throw new Error("Identification session is not ready.");let c=await this.request(e,"POST",i,a.token);if(!P(c,n))throw new Error(`Identification service did not confirm the ${r}.`);return}catch(c){if(s.aborted||this.destroyed)throw c;if(!(a!==null&&(!(c instanceof l)||c.status===429||c.status>=500))||o>=2){let y=S(c);throw this.notifyError(y),y}if(await new Promise(y=>setTimeout(y,300*3**o)),s.aborted||this.destroyed)throw c}}}async reportSignals(){for(let e of _()){let i={id:crypto.randomUUID(),...e,occurredAt:new Date().toISOString()};await this.deliver("/v1/browser-signals",{signals:[i]},i.id,"signal")}}restart(){return this.active(this.version),this.controller.abort(),clearTimeout(this.expiry),this.controller=new AbortController,this.version+=1,this.start(this.version)}getState(){return this.state?{...this.state}:null}async reset(){this.active(this.version),this.controller.abort(),clearTimeout(this.expiry),this.controller=new AbortController;let e=++this.version;this.busy("Resetting identification\u2026"),this.state=null,this.pending=null,this.lock();try{this.session&&(await this.request("/v1/sessions/current","DELETE",void 0,this.session.token),this.active(e)),this.persist(null),this.session=null}catch(i){throw this.fail(i,e,()=>this.reset(),"Could not reset identification."),i}return this.start(e)}destroy(){this.destroyed||(this.destroyed=!0,this.version+=1,this.controller.abort(),clearTimeout(this.expiry),this.host.remove(),this.config.mode==="required"&&this.config.gate&&(this.config.gate.inert=this.originalInert,L.delete(this.config.gate)),U.delete(this.config.container),this.retryAction=null)}};function H(t){let e=G(t),i=U.get(e.container);if(i){if(!z(i.config,e))throw new Error("This container already has an identification widget with different options.");return i}if(e.mode==="required"&&e.gate&&L.has(e.gate))throw new Error("This gate is already controlled by another identification widget.");let n=new D(e);return U.set(e.container,n),e.mode==="required"&&e.gate&&L.set(e.gate,n),n}window.AgentIdentification=Object.freeze({init:H});var d=document.currentScript;if(d?.dataset.site){let t=()=>{try{let e;d.dataset.target?e=d.dataset.target:(e=document.createElement("div"),e.dataset.agentIdentificationContainer="",document.body.append(e));let i={container:e,siteId:d.dataset.site,apiUrl:d.dataset.api??new URL(d.src).origin,mode:d.dataset.mode,gate:d.dataset.gate,storage:d.dataset.storage,styleUrl:d.dataset.style,pageViews:d.dataset.pageViews!=="off",route:d.dataset.route,automationSignals:d.dataset.automationSignals==="on",onRecorded:n=>{(typeof e=="string"?document.querySelector(e):e).dispatchEvent(new CustomEvent("agent-identification:recorded",{detail:n,bubbles:!0}))},onError:n=>{document.dispatchEvent(new CustomEvent("agent-identification:error",{detail:{message:n.message}}))}};H(i)}catch(e){let i=e instanceof Error?e.message:"Identification widget could not initialize.",n=document.createElement("p");n.setAttribute("role","alert"),n.textContent=`Visitor identification unavailable. ${i}`,d.insertAdjacentElement("afterend",n),document.dispatchEvent(new CustomEvent("agent-identification:error",{detail:{message:i}}))}};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",t,{once:!0}):t()}})();
|
package/dist/browser/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/protocol.ts
|
|
2
|
-
var VERSION = "0.
|
|
2
|
+
var VERSION = "0.10.0";
|
|
3
3
|
var CHOICES = ["ai_assistant", "person", "prefer_not_to_say"];
|
|
4
4
|
function isChoice(value) {
|
|
5
5
|
return typeof value === "string" && CHOICES.includes(value);
|
|
@@ -191,6 +191,7 @@ function pageLinks() {
|
|
|
191
191
|
}
|
|
192
192
|
}
|
|
193
193
|
var signalledSessions = /* @__PURE__ */ new Set();
|
|
194
|
+
var inputSessions = /* @__PURE__ */ new Set();
|
|
194
195
|
var navigationListeners = /* @__PURE__ */ new WeakMap();
|
|
195
196
|
function watchNavigation(listener) {
|
|
196
197
|
const { history } = window;
|
|
@@ -214,6 +215,42 @@ function watchNavigation(listener) {
|
|
|
214
215
|
listeners.add(listener);
|
|
215
216
|
return () => listeners.delete(listener);
|
|
216
217
|
}
|
|
218
|
+
function bindingContext(value) {
|
|
219
|
+
if (value === void 0 || value === null || value === "") return void 0;
|
|
220
|
+
if (typeof value !== "string" || value.length > 4096) {
|
|
221
|
+
throw new Error("bindingContext must be the signed context your server created.");
|
|
222
|
+
}
|
|
223
|
+
return value;
|
|
224
|
+
}
|
|
225
|
+
function watchInput(found) {
|
|
226
|
+
let lastKey = 0;
|
|
227
|
+
let composedByKey = false;
|
|
228
|
+
const keyPressed = () => performance.now() - lastKey <= 1e3;
|
|
229
|
+
const keydown = () => {
|
|
230
|
+
lastKey = performance.now();
|
|
231
|
+
};
|
|
232
|
+
const compositionstart = () => {
|
|
233
|
+
composedByKey = keyPressed();
|
|
234
|
+
};
|
|
235
|
+
const input = (event) => {
|
|
236
|
+
if (!event.isTrusted || keyPressed()) return;
|
|
237
|
+
const inputType = event.inputType;
|
|
238
|
+
if (inputType === "insertText" || inputType === "insertCompositionText" && !composedByKey) {
|
|
239
|
+
stop();
|
|
240
|
+
found("typing");
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
const options = { capture: true, passive: true };
|
|
244
|
+
window.addEventListener("keydown", keydown, options);
|
|
245
|
+
window.addEventListener("compositionstart", compositionstart, options);
|
|
246
|
+
window.addEventListener("input", input, options);
|
|
247
|
+
function stop() {
|
|
248
|
+
window.removeEventListener("keydown", keydown, options);
|
|
249
|
+
window.removeEventListener("compositionstart", compositionstart, options);
|
|
250
|
+
window.removeEventListener("input", input, options);
|
|
251
|
+
}
|
|
252
|
+
return stop;
|
|
253
|
+
}
|
|
217
254
|
function configure(options) {
|
|
218
255
|
const storage = options.storage ?? "session";
|
|
219
256
|
if (storage !== "session" && storage !== "memory") throw new Error("Invalid storage option.");
|
|
@@ -227,6 +264,7 @@ function configure(options) {
|
|
|
227
264
|
storage,
|
|
228
265
|
navigation: options.navigation ?? true,
|
|
229
266
|
automationSignals: options.automationSignals ?? true,
|
|
267
|
+
bindingContext: bindingContext(options.bindingContext),
|
|
230
268
|
onError: options.onError
|
|
231
269
|
};
|
|
232
270
|
}
|
|
@@ -238,7 +276,10 @@ function sameConfiguration(left, right) {
|
|
|
238
276
|
var Controller = class {
|
|
239
277
|
constructor(config, route) {
|
|
240
278
|
this.config = config;
|
|
241
|
-
this.key = storageKey(config,
|
|
279
|
+
this.key = storageKey(config, config.bindingContext);
|
|
280
|
+
this.stopInput = config.automationSignals ? watchInput((detail) => void this.reportInput(detail).catch(() => {
|
|
281
|
+
})) : () => {
|
|
282
|
+
};
|
|
242
283
|
this.stopWatching = config.navigation ? watchNavigation(() => this.navigated()) : () => {
|
|
243
284
|
};
|
|
244
285
|
this.ready = this.pageView({ route });
|
|
@@ -250,6 +291,7 @@ var Controller = class {
|
|
|
250
291
|
controller = new AbortController();
|
|
251
292
|
key;
|
|
252
293
|
stopWatching;
|
|
294
|
+
stopInput;
|
|
253
295
|
session = null;
|
|
254
296
|
starting = null;
|
|
255
297
|
destroyed = false;
|
|
@@ -309,6 +351,27 @@ var Controller = class {
|
|
|
309
351
|
}
|
|
310
352
|
return true;
|
|
311
353
|
}
|
|
354
|
+
/** Reports injected input once per session, kept with the session like the other signals. */
|
|
355
|
+
async reportInput(detail) {
|
|
356
|
+
const session = await this.current();
|
|
357
|
+
const key = `${await this.key}:input`;
|
|
358
|
+
if (inputSessions.has(session.sessionId)) return;
|
|
359
|
+
inputSessions.add(session.sessionId);
|
|
360
|
+
try {
|
|
361
|
+
if (this.config.storage === "session") {
|
|
362
|
+
if (sessionStorage.getItem(key) === session.sessionId) return;
|
|
363
|
+
sessionStorage.setItem(key, session.sessionId);
|
|
364
|
+
}
|
|
365
|
+
} catch {
|
|
366
|
+
}
|
|
367
|
+
const event = {
|
|
368
|
+
id: crypto.randomUUID(),
|
|
369
|
+
signal: "synthetic_input",
|
|
370
|
+
detail,
|
|
371
|
+
occurredAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
372
|
+
};
|
|
373
|
+
await this.deliver("/v1/browser-signals", { signals: [event] }, event.id, "signal");
|
|
374
|
+
}
|
|
312
375
|
async reportSignals() {
|
|
313
376
|
for (const signal of automationSignals()) {
|
|
314
377
|
const event = { id: crypto.randomUUID(), ...signal, occurredAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
@@ -325,7 +388,8 @@ var Controller = class {
|
|
|
325
388
|
}
|
|
326
389
|
async start(key) {
|
|
327
390
|
const body = await send(this.config, this.controller.signal, "/v1/sessions", "POST", {
|
|
328
|
-
siteId: this.config.siteId
|
|
391
|
+
siteId: this.config.siteId,
|
|
392
|
+
...this.config.bindingContext ? { bindingContext: this.config.bindingContext } : {}
|
|
329
393
|
});
|
|
330
394
|
if (!record(body) || !uuid(body.sessionId) || body.siteId !== this.config.siteId || body.origin !== this.config.origin || !futureDate(body.expiresAt) || !sessionToken(body.sessionToken)) {
|
|
331
395
|
throw new Error("Identification service returned an invalid new session.");
|
|
@@ -386,6 +450,7 @@ var Controller = class {
|
|
|
386
450
|
if (this.destroyed) return;
|
|
387
451
|
this.destroyed = true;
|
|
388
452
|
this.stopWatching();
|
|
453
|
+
this.stopInput();
|
|
389
454
|
this.controller.abort();
|
|
390
455
|
if (trackers.get(this.config.apiUrl + " " + this.config.siteId) === this) {
|
|
391
456
|
trackers.delete(this.config.apiUrl + " " + this.config.siteId);
|
package/dist/react/index.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { useEffect, useRef } from "react";
|
|
5
5
|
|
|
6
6
|
// src/protocol.ts
|
|
7
|
-
var VERSION = "0.
|
|
7
|
+
var VERSION = "0.10.0";
|
|
8
8
|
var CHOICES = ["ai_assistant", "person", "prefer_not_to_say"];
|
|
9
9
|
function isChoice(value) {
|
|
10
10
|
return typeof value === "string" && CHOICES.includes(value);
|
package/dist/server/index.js
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
// src/server/tokens.ts
|
|
2
|
-
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
3
|
-
|
|
4
1
|
// src/protocol.ts
|
|
5
2
|
var CHOICES = ["ai_assistant", "person", "prefer_not_to_say"];
|
|
6
3
|
function isChoice(value) {
|
|
@@ -51,12 +48,42 @@ var AGENT_USER_AGENT_TOKENS = [
|
|
|
51
48
|
"MistralAI-User",
|
|
52
49
|
"DuckAssistBot"
|
|
53
50
|
];
|
|
51
|
+
var CODING_AGENT_PRODUCTS = [
|
|
52
|
+
[/\bclaude-code\/\d/i, "Claude Code"],
|
|
53
|
+
[/\bcodex_cli_rs\/\d/i, "Codex"]
|
|
54
|
+
];
|
|
55
|
+
var AGENT_BROWSER_PRODUCTS = [
|
|
56
|
+
[/(?:^|[\s;(])Claude\/\d/, "Claude desktop"]
|
|
57
|
+
];
|
|
58
|
+
var HTTP_CLIENTS = [
|
|
59
|
+
[/^curl\//i, "curl"],
|
|
60
|
+
[/^Wget\//i, "Wget"],
|
|
61
|
+
[/^python-requests\//i, "Python requests"],
|
|
62
|
+
[/^python-httpx\//i, "Python httpx"],
|
|
63
|
+
[/^Python-urllib\//i, "Python urllib"],
|
|
64
|
+
[/^aiohttp\//i, "Python aiohttp"],
|
|
65
|
+
[/^axios\//i, "axios"],
|
|
66
|
+
[/^node-fetch\b/i, "node-fetch"],
|
|
67
|
+
[/^(node|undici)$/i, "Node.js fetch"],
|
|
68
|
+
[/^Go-http-client\//i, "Go HTTP client"],
|
|
69
|
+
[/^okhttp\//i, "OkHttp"],
|
|
70
|
+
[/^Java-http-client\//i, "Java HTTP client"],
|
|
71
|
+
[/^libwww-perl\//i, "Perl LWP"],
|
|
72
|
+
[/^HTTPie\//i, "HTTPie"],
|
|
73
|
+
[/^Deno\//i, "Deno"],
|
|
74
|
+
[/^Bun\//i, "Bun"]
|
|
75
|
+
];
|
|
54
76
|
function userAgentSignals(userAgent) {
|
|
55
77
|
const found = [];
|
|
56
|
-
const
|
|
78
|
+
const product = [...CODING_AGENT_PRODUCTS, ...AGENT_BROWSER_PRODUCTS].find(
|
|
79
|
+
([pattern]) => pattern.test(userAgent)
|
|
80
|
+
)?.[1];
|
|
81
|
+
const token = product ?? AGENT_USER_AGENT_TOKENS.find(
|
|
57
82
|
(name) => new RegExp(`(^|[^A-Za-z-])${name}(/|;|\\s|$)`).test(userAgent)
|
|
58
83
|
);
|
|
59
84
|
if (token) found.push({ signal: "agent_user_agent", detail: token });
|
|
85
|
+
const client = HTTP_CLIENTS.find(([pattern]) => pattern.test(userAgent.trim()))?.[1];
|
|
86
|
+
if (client && !token) found.push({ signal: "http_client", detail: client });
|
|
60
87
|
if (/HeadlessChrome\//.test(userAgent))
|
|
61
88
|
found.push({ signal: "headless_browser", detail: "HeadlessChrome" });
|
|
62
89
|
return found;
|
|
@@ -69,10 +96,184 @@ function isLink(value) {
|
|
|
69
96
|
return typeof value === "string" && /^[A-Za-z0-9_-]{22}$/.test(value);
|
|
70
97
|
}
|
|
71
98
|
|
|
99
|
+
// src/server/encoding.ts
|
|
100
|
+
var encoder = new TextEncoder();
|
|
101
|
+
var decoder = new TextDecoder("utf-8", { ignoreBOM: true });
|
|
102
|
+
function utf8(text) {
|
|
103
|
+
if (typeof text !== "string") throw new TypeError("Expected a string");
|
|
104
|
+
return encoder.encode(text);
|
|
105
|
+
}
|
|
106
|
+
function fromUtf8(bytes) {
|
|
107
|
+
return decoder.decode(bytes);
|
|
108
|
+
}
|
|
109
|
+
function hex(bytes) {
|
|
110
|
+
let text = "";
|
|
111
|
+
for (const byte of bytes) text += byte.toString(16).padStart(2, "0");
|
|
112
|
+
return text;
|
|
113
|
+
}
|
|
114
|
+
function base64url(bytes) {
|
|
115
|
+
let binary = "";
|
|
116
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
117
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
118
|
+
}
|
|
119
|
+
function fromBase64url(text) {
|
|
120
|
+
if (!/^[A-Za-z0-9_-]*$/.test(text)) throw new Error("Invalid base64url");
|
|
121
|
+
const binary = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
|
|
122
|
+
const bytes = new Uint8Array(binary.length);
|
|
123
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
124
|
+
return bytes;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// src/server/sha256.ts
|
|
128
|
+
var K = new Uint32Array([
|
|
129
|
+
1116352408,
|
|
130
|
+
1899447441,
|
|
131
|
+
3049323471,
|
|
132
|
+
3921009573,
|
|
133
|
+
961987163,
|
|
134
|
+
1508970993,
|
|
135
|
+
2453635748,
|
|
136
|
+
2870763221,
|
|
137
|
+
3624381080,
|
|
138
|
+
310598401,
|
|
139
|
+
607225278,
|
|
140
|
+
1426881987,
|
|
141
|
+
1925078388,
|
|
142
|
+
2162078206,
|
|
143
|
+
2614888103,
|
|
144
|
+
3248222580,
|
|
145
|
+
3835390401,
|
|
146
|
+
4022224774,
|
|
147
|
+
264347078,
|
|
148
|
+
604807628,
|
|
149
|
+
770255983,
|
|
150
|
+
1249150122,
|
|
151
|
+
1555081692,
|
|
152
|
+
1996064986,
|
|
153
|
+
2554220882,
|
|
154
|
+
2821834349,
|
|
155
|
+
2952996808,
|
|
156
|
+
3210313671,
|
|
157
|
+
3336571891,
|
|
158
|
+
3584528711,
|
|
159
|
+
113926993,
|
|
160
|
+
338241895,
|
|
161
|
+
666307205,
|
|
162
|
+
773529912,
|
|
163
|
+
1294757372,
|
|
164
|
+
1396182291,
|
|
165
|
+
1695183700,
|
|
166
|
+
1986661051,
|
|
167
|
+
2177026350,
|
|
168
|
+
2456956037,
|
|
169
|
+
2730485921,
|
|
170
|
+
2820302411,
|
|
171
|
+
3259730800,
|
|
172
|
+
3345764771,
|
|
173
|
+
3516065817,
|
|
174
|
+
3600352804,
|
|
175
|
+
4094571909,
|
|
176
|
+
275423344,
|
|
177
|
+
430227734,
|
|
178
|
+
506948616,
|
|
179
|
+
659060556,
|
|
180
|
+
883997877,
|
|
181
|
+
958139571,
|
|
182
|
+
1322822218,
|
|
183
|
+
1537002063,
|
|
184
|
+
1747873779,
|
|
185
|
+
1955562222,
|
|
186
|
+
2024104815,
|
|
187
|
+
2227730452,
|
|
188
|
+
2361852424,
|
|
189
|
+
2428436474,
|
|
190
|
+
2756734187,
|
|
191
|
+
3204031479,
|
|
192
|
+
3329325298
|
|
193
|
+
]);
|
|
194
|
+
var INITIAL = [
|
|
195
|
+
1779033703,
|
|
196
|
+
3144134277,
|
|
197
|
+
1013904242,
|
|
198
|
+
2773480762,
|
|
199
|
+
1359893119,
|
|
200
|
+
2600822924,
|
|
201
|
+
528734635,
|
|
202
|
+
1541459225
|
|
203
|
+
];
|
|
204
|
+
var BLOCK = 64;
|
|
205
|
+
function rotr(x, n) {
|
|
206
|
+
return x >>> n | x << 32 - n;
|
|
207
|
+
}
|
|
208
|
+
function sha256(data) {
|
|
209
|
+
const padded = new Uint8Array(Math.ceil((data.length + 9) / BLOCK) * BLOCK);
|
|
210
|
+
padded.set(data);
|
|
211
|
+
padded[data.length] = 128;
|
|
212
|
+
const view = new DataView(padded.buffer);
|
|
213
|
+
const bits = data.length * 8;
|
|
214
|
+
view.setUint32(padded.length - 8, Math.floor(bits / 4294967296));
|
|
215
|
+
view.setUint32(padded.length - 4, bits >>> 0);
|
|
216
|
+
const h = new Uint32Array(INITIAL);
|
|
217
|
+
const w = new Uint32Array(64);
|
|
218
|
+
for (let offset = 0; offset < padded.length; offset += BLOCK) {
|
|
219
|
+
for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4);
|
|
220
|
+
for (let i = 16; i < 64; i++) {
|
|
221
|
+
const a2 = w[i - 15];
|
|
222
|
+
const b2 = w[i - 2];
|
|
223
|
+
const s0 = rotr(a2, 7) ^ rotr(a2, 18) ^ a2 >>> 3;
|
|
224
|
+
const s1 = rotr(b2, 17) ^ rotr(b2, 19) ^ b2 >>> 10;
|
|
225
|
+
w[i] = w[i - 16] + s0 + w[i - 7] + s1;
|
|
226
|
+
}
|
|
227
|
+
let a = h[0];
|
|
228
|
+
let b = h[1];
|
|
229
|
+
let c = h[2];
|
|
230
|
+
let d = h[3];
|
|
231
|
+
let e = h[4];
|
|
232
|
+
let f = h[5];
|
|
233
|
+
let g = h[6];
|
|
234
|
+
let k = h[7];
|
|
235
|
+
for (let i = 0; i < 64; i++) {
|
|
236
|
+
const t1 = k + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + (e & f ^ ~e & g) + K[i] + w[i] | 0;
|
|
237
|
+
const t2 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + (a & b ^ a & c ^ b & c) | 0;
|
|
238
|
+
k = g;
|
|
239
|
+
g = f;
|
|
240
|
+
f = e;
|
|
241
|
+
e = d + t1 | 0;
|
|
242
|
+
d = c;
|
|
243
|
+
c = b;
|
|
244
|
+
b = a;
|
|
245
|
+
a = t1 + t2 | 0;
|
|
246
|
+
}
|
|
247
|
+
h[0] += a;
|
|
248
|
+
h[1] += b;
|
|
249
|
+
h[2] += c;
|
|
250
|
+
h[3] += d;
|
|
251
|
+
h[4] += e;
|
|
252
|
+
h[5] += f;
|
|
253
|
+
h[6] += g;
|
|
254
|
+
h[7] += k;
|
|
255
|
+
}
|
|
256
|
+
const digest = new Uint8Array(32);
|
|
257
|
+
const out = new DataView(digest.buffer);
|
|
258
|
+
for (let i = 0; i < 8; i++) out.setUint32(i * 4, h[i]);
|
|
259
|
+
return digest;
|
|
260
|
+
}
|
|
261
|
+
function hmacSha256(key, data) {
|
|
262
|
+
const block = new Uint8Array(BLOCK);
|
|
263
|
+
block.set(key.length > BLOCK ? sha256(key) : key);
|
|
264
|
+
const inner = new Uint8Array(BLOCK + data.length);
|
|
265
|
+
const outer = new Uint8Array(BLOCK + 32);
|
|
266
|
+
for (let i = 0; i < BLOCK; i++) {
|
|
267
|
+
inner[i] = block[i] ^ 54;
|
|
268
|
+
outer[i] = block[i] ^ 92;
|
|
269
|
+
}
|
|
270
|
+
inner.set(data, BLOCK);
|
|
271
|
+
outer.set(sha256(inner), BLOCK);
|
|
272
|
+
return sha256(outer);
|
|
273
|
+
}
|
|
274
|
+
|
|
72
275
|
// src/server/tokens.ts
|
|
73
|
-
var HEADER =
|
|
74
|
-
"base64url"
|
|
75
|
-
);
|
|
276
|
+
var HEADER = base64url(utf8(JSON.stringify({ alg: "HS256", typ: "AID", v: 1 })));
|
|
76
277
|
var HASH = /^[a-f0-9]{64}$/;
|
|
77
278
|
var ID = /^[a-zA-Z0-9_-]{1,100}$/;
|
|
78
279
|
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
@@ -92,7 +293,7 @@ function isAllowedOrigin(value) {
|
|
|
92
293
|
}
|
|
93
294
|
}
|
|
94
295
|
function hashToken(token) {
|
|
95
|
-
return
|
|
296
|
+
return hex(sha256(utf8(token)));
|
|
96
297
|
}
|
|
97
298
|
function hashBinding(siteId, binding) {
|
|
98
299
|
if (typeof binding !== "string" || !binding || binding.length > 2048) {
|
|
@@ -101,10 +302,14 @@ function hashBinding(siteId, binding) {
|
|
|
101
302
|
return hashToken(JSON.stringify(["agent-identification-binding-v1", siteId, binding]));
|
|
102
303
|
}
|
|
103
304
|
function secretEquals(actual, expected) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
);
|
|
305
|
+
const a = sha256(utf8(actual));
|
|
306
|
+
const b = sha256(utf8(expected));
|
|
307
|
+
let difference = 0;
|
|
308
|
+
for (let i = 0; i < a.length; i++) difference |= a[i] ^ b[i];
|
|
309
|
+
return difference === 0;
|
|
310
|
+
}
|
|
311
|
+
function hmac(secret, data) {
|
|
312
|
+
return base64url(hmacSha256(utf8(secret), utf8(data)));
|
|
108
313
|
}
|
|
109
314
|
function seconds(now) {
|
|
110
315
|
if (!Number.isFinite(now.getTime())) throw new Error("Invalid current time");
|
|
@@ -117,10 +322,9 @@ function assertSecret(secret) {
|
|
|
117
322
|
}
|
|
118
323
|
function sign(claims, secret) {
|
|
119
324
|
assertSecret(secret);
|
|
120
|
-
const payload =
|
|
325
|
+
const payload = base64url(utf8(JSON.stringify(claims)));
|
|
121
326
|
const signed = `${HEADER}.${payload}`;
|
|
122
|
-
|
|
123
|
-
return `${signed}.${signature}`;
|
|
327
|
+
return `${signed}.${hmac(secret, signed)}`;
|
|
124
328
|
}
|
|
125
329
|
function verify(token, expected) {
|
|
126
330
|
assertSecret(expected.secret);
|
|
@@ -134,11 +338,11 @@ function verify(token, expected) {
|
|
|
134
338
|
if (parts.length !== 3 || parts[0] !== HEADER || !/^[A-Za-z0-9_-]+$/.test(parts[1]) || !/^[A-Za-z0-9_-]{43}$/.test(parts[2])) {
|
|
135
339
|
throw new Error("Invalid signed token");
|
|
136
340
|
}
|
|
137
|
-
const signature =
|
|
341
|
+
const signature = hmac(expected.secret, `${parts[0]}.${parts[1]}`);
|
|
138
342
|
if (!secretEquals(signature, parts[2])) throw new Error("Invalid signed token");
|
|
139
343
|
let claims;
|
|
140
344
|
try {
|
|
141
|
-
claims = JSON.parse(
|
|
345
|
+
claims = JSON.parse(fromUtf8(fromBase64url(parts[1])));
|
|
142
346
|
} catch {
|
|
143
347
|
throw new Error("Invalid signed token");
|
|
144
348
|
}
|
|
@@ -209,7 +413,8 @@ async function verifyDeclarationOnline(receipt, options) {
|
|
|
209
413
|
},
|
|
210
414
|
credentials: "omit",
|
|
211
415
|
cache: "no-store",
|
|
212
|
-
redirect
|
|
416
|
+
// A redirect isn't ok, so it fails and is never followed. Cloudflare Workers reject "error".
|
|
417
|
+
redirect: "manual",
|
|
213
418
|
signal: AbortSignal.timeout(1e4),
|
|
214
419
|
body: JSON.stringify({
|
|
215
420
|
siteId: options.siteId,
|
|
@@ -274,7 +479,8 @@ async function reportAction(input, options) {
|
|
|
274
479
|
},
|
|
275
480
|
credentials: "omit",
|
|
276
481
|
cache: "no-store",
|
|
277
|
-
redirect
|
|
482
|
+
// A redirect isn't ok, so it fails and is never followed. Cloudflare Workers reject "error".
|
|
483
|
+
redirect: "manual",
|
|
278
484
|
signal: AbortSignal.timeout(5e3),
|
|
279
485
|
body
|
|
280
486
|
});
|
|
@@ -362,7 +568,8 @@ async function reportSignedRequest(request, options) {
|
|
|
362
568
|
},
|
|
363
569
|
credentials: "omit",
|
|
364
570
|
cache: "no-store",
|
|
365
|
-
redirect
|
|
571
|
+
// A redirect isn't ok, so it fails and is never followed. Cloudflare Workers reject "error".
|
|
572
|
+
redirect: "manual",
|
|
366
573
|
signal: AbortSignal.timeout(5e3),
|
|
367
574
|
body: JSON.stringify({
|
|
368
575
|
siteId: options.siteId,
|
|
@@ -388,8 +595,21 @@ async function reportSignedRequest(request, options) {
|
|
|
388
595
|
}
|
|
389
596
|
}
|
|
390
597
|
|
|
598
|
+
// src/server/ip.ts
|
|
599
|
+
var V4_SEGMENT = "(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])";
|
|
600
|
+
var V4 = `(?:${V4_SEGMENT}\\.){3}${V4_SEGMENT}`;
|
|
601
|
+
var V6_SEGMENT = "(?:[0-9a-fA-F]{1,4})";
|
|
602
|
+
var IPV4 = new RegExp(`^${V4}$`);
|
|
603
|
+
var IPV6 = new RegExp(
|
|
604
|
+
`^(?:(?:${V6_SEGMENT}:){7}(?:${V6_SEGMENT}|:)|(?:${V6_SEGMENT}:){6}(?:${V4}|:${V6_SEGMENT}|:)|(?:${V6_SEGMENT}:){5}(?::${V4}|(?::${V6_SEGMENT}){1,2}|:)|(?:${V6_SEGMENT}:){4}(?:(?::${V6_SEGMENT}){0,1}:${V4}|(?::${V6_SEGMENT}){1,3}|:)|(?:${V6_SEGMENT}:){3}(?:(?::${V6_SEGMENT}){0,2}:${V4}|(?::${V6_SEGMENT}){1,4}|:)|(?:${V6_SEGMENT}:){2}(?:(?::${V6_SEGMENT}){0,3}:${V4}|(?::${V6_SEGMENT}){1,5}|:)|(?:${V6_SEGMENT}:){1}(?:(?::${V6_SEGMENT}){0,4}:${V4}|(?::${V6_SEGMENT}){1,6}|:)|(?::(?:(?::${V6_SEGMENT}){0,5}:${V4}|(?::${V6_SEGMENT}){1,7}|:)))(?:%[0-9a-zA-Z-.:]{1,})?$`
|
|
605
|
+
);
|
|
606
|
+
function ipVersion(value) {
|
|
607
|
+
if (IPV4.test(value)) return 4;
|
|
608
|
+
if (IPV6.test(value)) return 6;
|
|
609
|
+
return 0;
|
|
610
|
+
}
|
|
611
|
+
|
|
391
612
|
// src/server/signals.ts
|
|
392
|
-
import { isIP } from "node:net";
|
|
393
613
|
async function reportRequestSignals(request, options) {
|
|
394
614
|
const fail = (error) => {
|
|
395
615
|
(options.onError ?? ((e) => console.warn(`Signal report failed: ${e.message}`)))(error);
|
|
@@ -398,9 +618,15 @@ async function reportRequestSignals(request, options) {
|
|
|
398
618
|
try {
|
|
399
619
|
const headers = headerMap(request);
|
|
400
620
|
const found = userAgentSignals(headers["user-agent"] ?? "");
|
|
401
|
-
|
|
621
|
+
const url = options.url ?? requestUrl(request, headers);
|
|
622
|
+
if (!found.length || isAssetRequest(url, headers)) {
|
|
402
623
|
return { status: "skipped" };
|
|
403
624
|
}
|
|
625
|
+
let path = null;
|
|
626
|
+
try {
|
|
627
|
+
path = routePath(new URL(url, "http://localhost").pathname);
|
|
628
|
+
} catch {
|
|
629
|
+
}
|
|
404
630
|
const api = new URL(options.apiUrl);
|
|
405
631
|
if (!isAllowedOrigin(api.origin) || api.username || api.password || api.search || api.hash) {
|
|
406
632
|
return fail(new Error("Invalid collector API URL"));
|
|
@@ -408,11 +634,12 @@ async function reportRequestSignals(request, options) {
|
|
|
408
634
|
api.pathname = `${api.pathname.replace(/\/$/, "")}/v1/signals`;
|
|
409
635
|
const occurredAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
410
636
|
const ip = typeof options.clientIp === "string" ? options.clientIp.trim() : "";
|
|
411
|
-
const address = ip &&
|
|
637
|
+
const address = ip && ipVersion(ip.replace(/^\[|\]$/g, "").replace(/%.*$/, "")) ? ip : null;
|
|
412
638
|
const signals = found.map((item) => ({
|
|
413
639
|
id: crypto.randomUUID(),
|
|
414
640
|
...item,
|
|
415
641
|
...address && item.signal === "agent_user_agent" ? { ip: address } : {},
|
|
642
|
+
...path ? { path } : {},
|
|
416
643
|
occurredAt,
|
|
417
644
|
...options.sessionBinding ? { sessionBinding: options.sessionBinding } : {},
|
|
418
645
|
...options.link ? { link: options.link } : {}
|
|
@@ -428,7 +655,8 @@ async function reportRequestSignals(request, options) {
|
|
|
428
655
|
},
|
|
429
656
|
credentials: "omit",
|
|
430
657
|
cache: "no-store",
|
|
431
|
-
redirect
|
|
658
|
+
// A redirect isn't ok, so it fails and is never followed. Cloudflare Workers reject "error".
|
|
659
|
+
redirect: "manual",
|
|
432
660
|
signal: AbortSignal.timeout(5e3),
|
|
433
661
|
body: JSON.stringify({ siteId: options.siteId, signals })
|
|
434
662
|
});
|
|
@@ -470,14 +698,101 @@ function reportLink(request, options = {}) {
|
|
|
470
698
|
const signed = Boolean(headers["signature-input"] && headers.signature);
|
|
471
699
|
if (!signed && !userAgentSignals(headers["user-agent"] ?? "").length) return null;
|
|
472
700
|
if (isAssetRequest(options.url ?? requestUrl(request, headers), headers)) return null;
|
|
473
|
-
return
|
|
701
|
+
return base64url(crypto.getRandomValues(new Uint8Array(16)));
|
|
474
702
|
}
|
|
475
703
|
function serverTiming(link) {
|
|
476
704
|
return `${LINK_TIMING};desc="${link}"`;
|
|
477
705
|
}
|
|
706
|
+
|
|
707
|
+
// src/server/setup.ts
|
|
708
|
+
var DEFAULT_COLLECTOR = "https://parallel-agent-identification.vercel.app";
|
|
709
|
+
var warned = /* @__PURE__ */ new Set();
|
|
710
|
+
function agentIdentification(options = {}) {
|
|
711
|
+
const environment = typeof process === "undefined" ? {} : process.env;
|
|
712
|
+
const config = {
|
|
713
|
+
apiUrl: options.apiUrl ?? environment.AGENT_ID_API_URL ?? DEFAULT_COLLECTOR,
|
|
714
|
+
siteId: options.siteId ?? environment.AGENT_ID_SITE_ID ?? "",
|
|
715
|
+
secret: options.secret ?? environment.AGENT_ID_SITE_SECRET ?? "",
|
|
716
|
+
...options.onError ? { onError: options.onError } : {},
|
|
717
|
+
...options.fetch ? { fetch: options.fetch } : {}
|
|
718
|
+
};
|
|
719
|
+
const ready = () => {
|
|
720
|
+
if (config.siteId && config.secret) return true;
|
|
721
|
+
const missing = [!config.siteId && "AGENT_ID_SITE_ID", !config.secret && "AGENT_ID_SITE_SECRET"].filter(Boolean).join(" and ");
|
|
722
|
+
if (!warned.has(missing)) {
|
|
723
|
+
warned.add(missing);
|
|
724
|
+
const error = new Error(`Agent identification is off: set ${missing}.`);
|
|
725
|
+
(options.onError ?? ((failure) => console.warn(failure.message)))(error);
|
|
726
|
+
}
|
|
727
|
+
return false;
|
|
728
|
+
};
|
|
729
|
+
const report = (request, requestOptions = {}) => {
|
|
730
|
+
if (!ready()) return null;
|
|
731
|
+
const link = reportLink(request, requestOptions.url ? { url: requestOptions.url } : {});
|
|
732
|
+
const shared = {
|
|
733
|
+
...config,
|
|
734
|
+
link,
|
|
735
|
+
...requestOptions.url ? { url: requestOptions.url } : {}
|
|
736
|
+
};
|
|
737
|
+
const work = Promise.all([
|
|
738
|
+
reportSignedRequest(request, shared),
|
|
739
|
+
reportRequestSignals(request, {
|
|
740
|
+
...shared,
|
|
741
|
+
clientIp: requestOptions.clientIp ?? null,
|
|
742
|
+
...requestOptions.sessionBinding ? { sessionBinding: requestOptions.sessionBinding } : {}
|
|
743
|
+
})
|
|
744
|
+
]);
|
|
745
|
+
if (requestOptions.waitUntil) requestOptions.waitUntil(work);
|
|
746
|
+
else void work;
|
|
747
|
+
return link ? serverTiming(link) : null;
|
|
748
|
+
};
|
|
749
|
+
return {
|
|
750
|
+
report,
|
|
751
|
+
express() {
|
|
752
|
+
return (request, response, next) => {
|
|
753
|
+
const timing = report(request, { clientIp: request.ip ?? null });
|
|
754
|
+
if (timing) response.append("Server-Timing", timing);
|
|
755
|
+
next();
|
|
756
|
+
};
|
|
757
|
+
},
|
|
758
|
+
next(request, event, response, extra = {}) {
|
|
759
|
+
const url = new URL(request.url);
|
|
760
|
+
const host = request.headers.get("host");
|
|
761
|
+
if (host) {
|
|
762
|
+
try {
|
|
763
|
+
const asked = new URL(`${url.protocol}//${host}`);
|
|
764
|
+
url.hostname = asked.hostname;
|
|
765
|
+
url.port = asked.port;
|
|
766
|
+
} catch {
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
const clientIp = extra.clientIp !== void 0 ? extra.clientIp : environment.VERCEL ? request.headers.get("x-real-ip") : null;
|
|
770
|
+
const timing = report(request, {
|
|
771
|
+
url: url.href,
|
|
772
|
+
clientIp,
|
|
773
|
+
...extra.sessionBinding ? { sessionBinding: extra.sessionBinding } : {},
|
|
774
|
+
waitUntil: (work) => event.waitUntil(work)
|
|
775
|
+
});
|
|
776
|
+
if (timing) response.headers.append("Server-Timing", timing);
|
|
777
|
+
return response;
|
|
778
|
+
},
|
|
779
|
+
action(input) {
|
|
780
|
+
if (!ready()) {
|
|
781
|
+
return Promise.resolve({
|
|
782
|
+
status: "failed",
|
|
783
|
+
id: input.id ?? "",
|
|
784
|
+
error: new Error("Agent identification is off: set its site ID and secret.")
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
return reportAction(input, config);
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
}
|
|
478
791
|
export {
|
|
479
792
|
AGENT_USER_AGENT_TOKENS,
|
|
793
|
+
DEFAULT_COLLECTOR,
|
|
480
794
|
LINK_TIMING,
|
|
795
|
+
agentIdentification,
|
|
481
796
|
createBindingContext,
|
|
482
797
|
isLink,
|
|
483
798
|
reportAction,
|
|
@@ -15,6 +15,11 @@ export interface TrackerOptions {
|
|
|
15
15
|
* session, as heuristic evidence that never labels a visitor an AI agent. Defaults to true.
|
|
16
16
|
*/
|
|
17
17
|
automationSignals?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* 0.9.0: a binding context your server signed with createBindingContext() for this visitor's
|
|
20
|
+
* own session. Actions your server reports with the same sessionBinding then join this visit.
|
|
21
|
+
*/
|
|
22
|
+
bindingContext?: string;
|
|
18
23
|
onError?: (error: Error) => void;
|
|
19
24
|
}
|
|
20
25
|
export interface Tracker {
|
package/dist/types/protocol.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Shared wire protocol. Classifications are always self-reported. */
|
|
2
|
-
export declare const VERSION = "0.
|
|
2
|
+
export declare const VERSION = "0.10.0";
|
|
3
3
|
export declare const CHOICES: readonly ["ai_assistant", "person", "prefer_not_to_say"];
|
|
4
4
|
export type Choice = (typeof CHOICES)[number];
|
|
5
5
|
export type Mode = "optional" | "required";
|
|
@@ -120,7 +120,7 @@ export interface SignatureEvent extends EventBase {
|
|
|
120
120
|
* Heuristic automation signals. Each is evidence, never identity: an automated browser isn't
|
|
121
121
|
* necessarily an AI agent, and a user agent that names one is only a claim anyone can make.
|
|
122
122
|
*/
|
|
123
|
-
export declare const SIGNALS: readonly ["webdriver", "headless_browser", "agent_user_agent"];
|
|
123
|
+
export declare const SIGNALS: readonly ["webdriver", "headless_browser", "agent_user_agent", "http_client", "synthetic_input"];
|
|
124
124
|
export type Signal = (typeof SIGNALS)[number];
|
|
125
125
|
/** The signals a page can observe itself. */
|
|
126
126
|
export declare const BROWSER_SIGNALS: readonly Signal[];
|
|
@@ -137,6 +137,8 @@ export interface SignalEvent extends EventBase {
|
|
|
137
137
|
signal: Signal;
|
|
138
138
|
/** What triggered it, such as the user agent's ChatGPT-User token. */
|
|
139
139
|
detail: string | null;
|
|
140
|
+
/** 0.9.0: for a server's report, the masked path of the page requested. Null from a page. */
|
|
141
|
+
path: string | null;
|
|
140
142
|
/**
|
|
141
143
|
* For a server-reported agent user agent sent with the visitor's address, whether that address
|
|
142
144
|
* is on the agent's operator's published list. Null when it wasn't checked.
|
|
@@ -162,6 +164,18 @@ export declare function isSignal(value: unknown): value is Signal;
|
|
|
162
164
|
* request claims to be that agent, which anyone can do, so it's a signal, never verification.
|
|
163
165
|
*/
|
|
164
166
|
export declare const AGENT_USER_AGENT_TOKENS: readonly ["Claude-Agent", "ChatGPT-User", "OAI-SearchBot", "GPTBot", "Claude-User", "Claude-SearchBot", "ClaudeBot", "Perplexity-User", "PerplexityBot", "MistralAI-User", "DuckAssistBot"];
|
|
167
|
+
/**
|
|
168
|
+
* Coding agents that fetch pages from the developer's own machine, named by a product token in
|
|
169
|
+
* their user agent. Claude Code sends "Claude-User (claude-code/2.1.317; …)" from wherever it
|
|
170
|
+
* runs, so it's named on its own rather than checked against Anthropic's server addresses.
|
|
171
|
+
*/
|
|
172
|
+
export declare const CODING_AGENT_PRODUCTS: readonly [RegExp, string][];
|
|
173
|
+
/**
|
|
174
|
+
* Browsers built into AI agent apps, named by a product token in their user agent. Claude
|
|
175
|
+
* desktop's built-in browser sends "Claude/<version>" among Chrome's tokens. Claude drives it, and
|
|
176
|
+
* so can the person using the app, so it says where a visit happened, not who drove it (0.10.0).
|
|
177
|
+
*/
|
|
178
|
+
export declare const AGENT_BROWSER_PRODUCTS: readonly [RegExp, string][];
|
|
165
179
|
/** The signals a user agent string carries on its own. */
|
|
166
180
|
export declare function userAgentSignals(userAgent: string): {
|
|
167
181
|
signal: Signal;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function utf8(text: string): Uint8Array;
|
|
2
|
+
export declare function fromUtf8(bytes: Uint8Array): string;
|
|
3
|
+
export declare function hex(bytes: Uint8Array): string;
|
|
4
|
+
export declare function base64url(bytes: Uint8Array): string;
|
|
5
|
+
/** Throws on text that isn't base64url. */
|
|
6
|
+
export declare function fromBase64url(text: string): Uint8Array;
|
|
@@ -7,6 +7,8 @@ export type { ActionReport, ActionReportOptions, ReportedAction } from "./activi
|
|
|
7
7
|
export { reportSignedRequest } from "./web-bot-auth.js";
|
|
8
8
|
export { reportRequestSignals } from "./signals.js";
|
|
9
9
|
export { reportLink, serverTiming } from "./links.js";
|
|
10
|
+
export { agentIdentification, DEFAULT_COLLECTOR } from "./setup.js";
|
|
11
|
+
export type { AgentIdentification, AgentIdentificationOptions, RequestReportOptions, } from "./setup.js";
|
|
10
12
|
export type { SignalReport, SignalReportOptions } from "./signals.js";
|
|
11
13
|
export type { ReportableRequest, SignatureReport, SignatureReportOptions } from "./web-bot-auth.js";
|
|
12
14
|
export { routePath, userAgentSignals, isLink, AGENT_USER_AGENT_TOKENS, LINK_TIMING, } from "../protocol.js";
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type ActionReport, type ReportedAction } from "./activity.js";
|
|
2
|
+
import { type ReportableRequest } from "./web-bot-auth.js";
|
|
3
|
+
/** The hosted collector, used unless you pass apiUrl or set AGENT_ID_API_URL. */
|
|
4
|
+
export declare const DEFAULT_COLLECTOR = "https://parallel-agent-identification.vercel.app";
|
|
5
|
+
export interface AgentIdentificationOptions {
|
|
6
|
+
/** Your public site ID. Defaults to the AGENT_ID_SITE_ID environment variable. */
|
|
7
|
+
siteId?: string;
|
|
8
|
+
/** Your server secret. Defaults to AGENT_ID_SITE_SECRET. Never put it in browser code. */
|
|
9
|
+
secret?: string;
|
|
10
|
+
/** The collector. Defaults to AGENT_ID_API_URL, then the hosted collector. */
|
|
11
|
+
apiUrl?: string;
|
|
12
|
+
/** Called when a report fails. Defaults to a console warning without any secret. */
|
|
13
|
+
onError?: (error: Error) => void;
|
|
14
|
+
fetch?: typeof globalThis.fetch;
|
|
15
|
+
}
|
|
16
|
+
export interface RequestReportOptions {
|
|
17
|
+
/** The URL the visitor asked for, if your framework rewrites the request's own. */
|
|
18
|
+
url?: string;
|
|
19
|
+
/**
|
|
20
|
+
* The visitor's IP address as your platform gives it, never from a header visitors can set.
|
|
21
|
+
* It's sent only with a request that names an agent, to check it against the agent's
|
|
22
|
+
* operator's published addresses, and the collector keeps only the result.
|
|
23
|
+
*/
|
|
24
|
+
clientIp?: string | null;
|
|
25
|
+
/** Your server's own session binding, as in the page script's binding context. */
|
|
26
|
+
sessionBinding?: string;
|
|
27
|
+
/** Keeps the reports running after the response, such as Next.js's event.waitUntil. */
|
|
28
|
+
waitUntil?: (work: Promise<unknown>) => void;
|
|
29
|
+
}
|
|
30
|
+
export interface AgentIdentification {
|
|
31
|
+
/**
|
|
32
|
+
* Reports one request: a signed agent's signature, and an agent or automation its user agent
|
|
33
|
+
* names. Ordinary requests and assets are skipped without a network call. Returns the
|
|
34
|
+
* Server-Timing value to add to this page's response, or null, so the page script can join the
|
|
35
|
+
* reports to its visit. It never throws and never waits for the collector.
|
|
36
|
+
*/
|
|
37
|
+
report(request: ReportableRequest, options?: RequestReportOptions): string | null;
|
|
38
|
+
/** Express or Connect middleware that reports every request. */
|
|
39
|
+
express(): (request: ReportableRequest & {
|
|
40
|
+
ip?: string;
|
|
41
|
+
}, response: {
|
|
42
|
+
append(name: string, value: string): unknown;
|
|
43
|
+
}, next: () => void) => void;
|
|
44
|
+
/**
|
|
45
|
+
* For a Next.js proxy: reports the request and adds the link to your response. On Vercel it
|
|
46
|
+
* passes the visitor's address from x-real-ip, which Vercel sets; elsewhere pass clientIp.
|
|
47
|
+
*/
|
|
48
|
+
next<T extends {
|
|
49
|
+
headers: Headers;
|
|
50
|
+
}>(request: Request, event: {
|
|
51
|
+
waitUntil(work: Promise<unknown>): void;
|
|
52
|
+
}, response: T, options?: Pick<RequestReportOptions, "clientIp" | "sessionBinding">): T;
|
|
53
|
+
/** Reports an action your server handled, such as an order placed. Never throws. */
|
|
54
|
+
action(input: ReportedAction): Promise<ActionReport>;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Sets up agent identification in one call, from AGENT_ID_SITE_ID and AGENT_ID_SITE_SECRET unless
|
|
58
|
+
* you pass them. Use .express() as middleware, or .next() in a Next.js proxy.
|
|
59
|
+
*/
|
|
60
|
+
export declare function agentIdentification(options?: AgentIdentificationOptions): AgentIdentification;
|
|
@@ -17,6 +17,11 @@ export interface SignalReportOptions {
|
|
|
17
17
|
* address.
|
|
18
18
|
*/
|
|
19
19
|
clientIp?: string | null;
|
|
20
|
+
/**
|
|
21
|
+
* The URL the visitor asked for, when your framework changes the request's own (Next.js uses
|
|
22
|
+
* localhost). Its masked path goes with the report, so you can see which pages agents fetched.
|
|
23
|
+
*/
|
|
24
|
+
url?: string;
|
|
20
25
|
/** Called when a report fails. Defaults to a console warning without any secret. */
|
|
21
26
|
onError?: (error: Error) => void;
|
|
22
27
|
fetch?: typeof globalThis.fetch;
|
|
@@ -38,6 +38,7 @@ export declare function isAllowedOrigin(value: unknown): value is string;
|
|
|
38
38
|
export declare function validateSite(site: SiteConfig): void;
|
|
39
39
|
export declare function hashToken(token: string): string;
|
|
40
40
|
export declare function hashBinding(siteId: string, binding: string): string;
|
|
41
|
+
/** Compares two values in constant time, through their hashes, so neither length shows. */
|
|
41
42
|
export declare function secretEquals(actual: string, expected: string): boolean;
|
|
42
43
|
/** Mint on your server only, using its current session rather than browser input. */
|
|
43
44
|
export declare function createBindingContext(options: BindingOptions): string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@useparallel/agent-identification",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "See which AI agents use your website: report agents from your server, record visits with a page script that draws nothing, and verify signed agents and operators' published addresses.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
"test:e2e": "playwright test",
|
|
64
64
|
"smoke:hosted": "node scripts/smoke-hosted.mjs",
|
|
65
65
|
"test:package": "node scripts/test-package.mjs",
|
|
66
|
+
"test:edge-runtimes": "node --import tsx scripts/test-edge-runtimes.mjs",
|
|
66
67
|
"check": "npm run build && npm run typecheck && npm run lint && npm run format:check && npm test && npm run test:package",
|
|
67
68
|
"dev:collector": "node --import tsx scripts/dev-server.ts",
|
|
68
69
|
"site:create": "node scripts/site-config.mjs",
|
|
@@ -73,6 +74,7 @@
|
|
|
73
74
|
"access": "public"
|
|
74
75
|
},
|
|
75
76
|
"devDependencies": {
|
|
77
|
+
"@edge-runtime/vm": "5.0.0",
|
|
76
78
|
"@electric-sql/pglite": "0.5.10",
|
|
77
79
|
"@electric-sql/pglite-socket": "0.2.13",
|
|
78
80
|
"@eslint/js": "10.0.1",
|