@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 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. The page script and widget run in any
28
- current browser.
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 these calls where your server handles requests. They
33
- skip ordinary requests without a network call, and none of them throws or slows the
34
- response. Keep the site's secret in `AGENT_ID_SITE_SECRET` on the server only.
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
- const agentId = {
45
- apiUrl: "https://YOUR-COLLECTOR",
46
- siteId: "your-public-site-id",
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
- // Express:
51
- app.use((req, res, next) => {
52
- // Only for a request it reports, so the page script can join the reports to its visit.
53
- const link = reportLink(req);
54
- if (link) res.append("Server-Timing", serverTiming(link));
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.8.2/agent-id-track.js"
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.8.2/agent-id.js"
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.8.2/agent-id.js"
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.8.2/integrity.json`.
252
+ Versioned asset hashes are at `/sdk/v0.10.0/integrity.json`.
260
253
 
261
254
  ## What's collected
262
255
 
@@ -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()}})();
@@ -1,5 +1,5 @@
1
1
  // src/protocol.ts
2
- var VERSION = "0.8.2";
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, void 0);
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);
@@ -4,7 +4,7 @@
4
4
  import { useEffect, useRef } from "react";
5
5
 
6
6
  // src/protocol.ts
7
- var VERSION = "0.8.2";
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);
@@ -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 token = AGENT_USER_AGENT_TOKENS.find(
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 = Buffer.from(JSON.stringify({ alg: "HS256", typ: "AID", v: 1 })).toString(
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 createHash("sha256").update(token).digest("hex");
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
- return timingSafeEqual(
105
- Buffer.from(hashToken(actual), "hex"),
106
- Buffer.from(hashToken(expected), "hex")
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 = Buffer.from(JSON.stringify(claims)).toString("base64url");
325
+ const payload = base64url(utf8(JSON.stringify(claims)));
121
326
  const signed = `${HEADER}.${payload}`;
122
- const signature = createHmac("sha256", secret).update(signed).digest("base64url");
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 = createHmac("sha256", expected.secret).update(`${parts[0]}.${parts[1]}`).digest("base64url");
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(Buffer.from(parts[1], "base64url").toString("utf8"));
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: "error",
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: "error",
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: "error",
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
- if (!found.length || isAssetRequest(requestUrl(request, headers), headers)) {
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 && isIP(ip.replace(/^\[|\]$/g, "").replace(/%.*$/, "")) ? ip : null;
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: "error",
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 Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString("base64url");
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 {
@@ -1,5 +1,5 @@
1
1
  /** Shared wire protocol. Classifications are always self-reported. */
2
- export declare const VERSION = "0.8.2";
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,2 @@
1
+ /** 4 for an IPv4 address, 6 for IPv6, else 0, as net.isIP() answers. */
2
+ export declare function ipVersion(value: string): 0 | 4 | 6;
@@ -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;
@@ -0,0 +1,2 @@
1
+ export declare function sha256(data: Uint8Array): Uint8Array;
2
+ export declare function hmacSha256(key: Uint8Array, data: Uint8Array): Uint8Array;
@@ -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.8.2",
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",