@heedeo/sdk 0.0.0-stage → 0.1.0-pilot.12

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Heedeo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,184 @@
1
- # Temporary Holding Version
1
+ # Heedeo package quickstart
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Pilot package for Node.js 20.18+, Next.js App Router, and Cloudflare Workers with `nodejs_compat`. It handles browser loading, same-domain forwarding, event requests, timeouts and one retry. Heedeo returns signals; your app still owns signup and authentication.
4
+
5
+ ## 1. Install
6
+
7
+ ```sh
8
+ npm install @heedeo/sdk
9
+ ```
10
+
11
+ The package is published on npm as `@heedeo/sdk`. It has no runtime dependencies.
12
+
13
+ Get your keys at https://app.heedeo.com/review/sign-up. Save the secret in your server's environment settings, including your deployed environment. Never put it in a `NEXT_PUBLIC_` variable.
14
+
15
+ ```sh
16
+ HEEDEO_SECRET_KEY=sk_your_secret_key
17
+ NEXT_PUBLIC_HEEDEO_KEY=pk_your_publishable_key
18
+ ```
19
+
20
+ ## 2. Configure the server once
21
+
22
+ ```ts
23
+ // lib/heedeo.ts: import only from server files
24
+ import { Heedeo } from '@heedeo/sdk';
25
+ import { trustedClientIp } from './trusted-client-ip';
26
+
27
+ export const heedeo = new Heedeo({
28
+ secretKey: process.env.HEEDEO_SECRET_KEY,
29
+ clientIp: trustedClientIp,
30
+ });
31
+ ```
32
+
33
+ `trustedClientIp(request)` is the one hosting-specific piece. It must return the real visitor's IP from a source your server trusts, or `null` when unavailable. Use the same function for the proxy and signup. The package deliberately does not guess whether an incoming forwarded header is trustworthy.
34
+
35
+ For local testing only:
36
+
37
+ ```ts
38
+ // lib/trusted-client-ip.ts
39
+ export function trustedClientIp(_request: Request): string | null {
40
+ if (process.env.NODE_ENV === 'development') return '127.0.0.1';
41
+ return null; // Configure your host's trusted IP source before deploying.
42
+ }
43
+ ```
44
+
45
+ For a Cloudflare-proxied app, `cf-connecting-ip` is an option only when your origin rejects requests that bypass that trusted proxy. Do not assume that a public Railway origin is protected just because the custom domain uses Cloudflare. Have your coding assistant inspect the actual deployment and configure this function. A missing IP returns a setup error; it does not silently group all production visitors under localhost.
46
+
47
+ ## 3. Add one route, or use your endpoint
48
+
49
+ Your app forwards Heedeo's two paths itself. If the Domains page (https://app.heedeo.com/review/domains) already serves Heedeo from your domain, skip this step and pass its endpoint instead (step 4).
50
+
51
+ ```ts
52
+ // app/hd/[...path]/route.ts
53
+ import { heedeo } from '../../../lib/heedeo';
54
+
55
+ export const runtime = 'nodejs';
56
+ export const GET = heedeo.proxy;
57
+ export const POST = heedeo.proxy;
58
+ ```
59
+
60
+ This assumes `lib` is at the project root, as in the runnable example. Keep `/hd/i.js` and `/hd/v1/identify` reachable before sign-in. Existing authentication middleware must allow those two paths. Only these endpoints are forwarded; this route cannot forward event submissions or arbitrary URLs. Cookies and request headers unrelated to Heedeo are not sent upstream.
61
+
62
+ ## 4. Identify just before signup
63
+
64
+ ```ts
65
+ // lib/heedeo-browser.ts: safe to import in a client component
66
+ import { createHeedeo } from '@heedeo/sdk/browser';
67
+
68
+ export const heedeo = createHeedeo({
69
+ publishableKey: process.env.NEXT_PUBLIC_HEEDEO_KEY,
70
+ });
71
+ ```
72
+
73
+ In your existing submit handler:
74
+
75
+ ```ts
76
+ const { data } = await heedeo.identify();
77
+ await signup({ email, heedeoToken: data?.token });
78
+ ```
79
+
80
+ Keep your existing `signup` function and its validation, loading state and error handling. The package loads the script once, waits for it, and returns a new receipt for each call. No script tag or global type declaration is needed. Identification happens at the action, not on every page load. If identification fails, `data` is `null` and `error` explains the problem; continue signup without signals. Do not mix the package with an existing Heedeo script tag.
81
+
82
+ With an endpoint from the Domains page, pass it as `endpoint` in place of `path` and add no route, for example `createHeedeo({ publishableKey, endpoint: 'https://www.customer.com/k9m2x' })`. `endpoint` needs 0.1.0-pilot.12 or later (`npm install @heedeo/sdk@latest`). It is an HTTPS origin with at most one path segment and no trailing slash; the script loads from `${endpoint}/i.js`. Passing both `path` and `endpoint` throws. Installed with the route before the domain was live? Change only this call; the server's `event` call stays the same, with its `clientIp`, and the route can go once the endpoint answers.
83
+
84
+ ## 5. Record the event on your server
85
+
86
+ In the existing signup route, after validating the submitted fields:
87
+
88
+ ```ts
89
+ const { data, error } = await heedeo.event({
90
+ request,
91
+ token: input.heedeoToken,
92
+ action: 'signup',
93
+ email: input.email,
94
+ });
95
+
96
+ if (data) console.info('Heedeo event', data.event_id);
97
+ if (error) console.warn('Heedeo setup', error.code);
98
+ // Continue your existing signup. Observe only during the pilot.
99
+ ```
100
+
101
+ `request` is the original Web Request received by a Next.js route handler; it preserves the actual URL, method and headers. The call does not read or consume its body. Route handlers are the supported Next.js path for this first package release. Keep an existing server-action integration on the manual guide until an adapter has been tested; do not invent a request URL or replace authentication to use the package.
102
+
103
+ What your backend knows about the person goes in `evidence`, every field optional: a verified email, the phone and its line type, the sign-in provider's claims, your account's age and plan, the card from Stripe, a check you ran. The phone number and card fingerprint are hashed on arrival and never stored raw. The integration guide has the one-liner for Stripe, Twilio Lookup, Google, Apple and GitHub.
104
+
105
+ ```ts
106
+ await heedeo.event({
107
+ request, token, action: 'signup', email, userId,
108
+ evidence: {
109
+ phone: { number: '+14155550123', verified: true, line_type: 'mobile' },
110
+ account: { created_at: user.createdAt, plan: 'paid', paid_since: user.paidSince },
111
+ },
112
+ });
113
+ ```
114
+
115
+ `data.suggested` is `allow`, `verify`, `review`, or `block` once you set Block at score in Settings. Trust reasons in `data.reasons` carry a negative weight.
116
+
117
+ The SDK generates a request ID once and reuses the same body on its one retry. If your application retries the entire action, provide its stable ID as `requestId`. A new user action needs a new identification and ID. Do not send two events with the same receipt to attach a user ID later.
118
+
119
+ ## 6. Report what you learn
120
+
121
+ When you find out what a signup really was, say so. An outcome changes no flag and no label; it is the labelled set Heedeo uses to tune your weights later, and the more you report, the sooner your own numbers replace the defaults.
122
+
123
+ ```ts
124
+ await heedeo.outcome({ eventId: data.event_id, outcome: 'abuse', label: 'trial_abuse' });
125
+ // or { visitorId }, or { userId } once the account exists. outcome is 'abuse' or 'legit'.
126
+ ```
127
+
128
+ A paid conversion is a cheap `legit`; a refund, a chargeback or a third free trial is `abuse`. `label` is your own short word, `note` one line for your reviewers; both are optional. One outcome per event, visitor or account: a later report replaces the earlier one. The same `{ data, error }` shape comes back, with `kind`, `event_id` or `visitor_id`, `outcome`, `label`, `source`, `note` and `created_at`. Reviewers can report the same thing on the flags page by resolving a flag as Abuse or Legit.
129
+
130
+ ## 7. Ask about a visitor
131
+
132
+ Between events, read a browser's standing score, after trust, in your bands:
133
+
134
+ ```ts
135
+ const { data } = await heedeo.visitor(visitorId);
136
+ if (data?.suggested === 'review') holdPayout();
137
+ ```
138
+
139
+ ## Check that it works
140
+
141
+ Run a test signup and check the server result: an `event_id` and a non-null `visitor` confirm a usable identification. `flag: false` is normal; an empty flags screen is not evidence of failure. Check that signup still completes with the script blocked or Heedeo unavailable. Never expose the full signals response or internal reasons to an end user.
142
+
143
+ Use HTTPS for deployed testing, including Safari's secure-cookie behavior. Review the privacy wording in https://app.heedeo.com/docs#for-your-privacy-policy before going live.
144
+
145
+ ## Options and errors
146
+
147
+ Server options: `secretKey`, required `clientIp(request)`, optional `path` (default `/hd`), `timeoutMs` (default 3000 total across event retries), `baseUrl` (default `https://app.heedeo.com`), and `fetch` for testing. Custom origins require HTTPS, except localhost. Browser options: `publishableKey`, matching `path` or an `endpoint` on your domain, and `timeoutMs` (default 3000 total for script loading and identification).
148
+
149
+ Configuration mistakes throw when you construct the client, so missing keys are caught at setup. Operational failures return `{ data: null, error: { code, message, status? } }`; checking `error` narrows `data` to the success value. `code` is a `HeedeoErrorCode`, so a `switch` over it is checked by TypeScript:
150
+
151
+ - Server: `missing_token`, `missing_client_ip`, `invalid_key`, `api_error`, `invalid_response`, `network_error`, `timeout_or_aborted`, `configuration_error`.
152
+ - Browser: `browser_required`, `timeout`, `invalid_response`, `identification_unavailable`.
153
+
154
+ Neither entry point logs credentials, request bodies or upstream error bodies.
155
+
156
+ Browser timeouts stop waiting, but cannot cancel an already-running collector call in this pilot version. Always pass the returned token explicitly; never fall back to an older cookie token after a timeout. Browser results are not security decisions. Secret keys and event signals stay on your server.
157
+
158
+ ## Pilot boundaries
159
+
160
+ This package is an ESM package (use `import`) for Node.js servers. The browser entry is separate and imports no server code. The server entry supports Node.js and Cloudflare Workers with `nodejs_compat`. Other edge runtimes have not been verified. No change to your hosting, database or sign-in provider is required. React hooks, server-action adapters, Express-specific wrappers and an automatic installer are not included in this release.
161
+
162
+ ## Cloudflare Workers
163
+
164
+ Enable `nodejs_compat` in Wrangler. Create the client inside the request handler so keys come from Worker secrets:
165
+
166
+ ```ts
167
+ import { Heedeo } from '@heedeo/sdk';
168
+
169
+ export default {
170
+ async fetch(request, env) {
171
+ const heedeo = new Heedeo({
172
+ secretKey: env.HEEDEO_SECRET_KEY,
173
+ clientIp: request => request.headers.get('cf-connecting-ip'),
174
+ });
175
+ if (new URL(request.url).pathname.startsWith('/hd/')) {
176
+ return heedeo.proxy(request);
177
+ }
178
+ // In your signup handler, use heedeo.event() as above.
179
+ return env.ASSETS.fetch(request);
180
+ },
181
+ };
182
+ ```
183
+
184
+ This IP resolver is for a Worker receiving requests through Cloudflare, not an unprotected origin. If you use static assets, set `assets.run_worker_first` for `/hd/*` and your signup route. Store `HEEDEO_SECRET_KEY` with `wrangler secret put`; only the publishable key belongs in the browser.
@@ -0,0 +1,13 @@
1
+ import { type Identification, type Result } from "./shared.js";
2
+ export type { HeedeoError, HeedeoErrorCode, Identification, Result } from "./shared.js";
3
+ /** Create once per app. Calling this function alone makes no identification request. */
4
+ export declare function createHeedeo(options: {
5
+ publishableKey: string | undefined;
6
+ /** Your own proxy route on this origin. Default: /hd. */
7
+ path?: string;
8
+ /** A host on your domain that serves Heedeo, instead of path. */
9
+ endpoint?: string;
10
+ timeoutMs?: number;
11
+ }): {
12
+ identify(): Promise<Result<Identification>>;
13
+ };
@@ -0,0 +1,85 @@
1
+ import { failure, isIdentification, pathPrefix, timeout, } from "./shared.js";
2
+ let loading;
3
+ function load(url, timeoutMs) {
4
+ if (loading) {
5
+ return loading.url === url
6
+ ? loading.promise
7
+ : Promise.reject(new Error("conflicting_configuration"));
8
+ }
9
+ const promise = new Promise((resolve, reject) => {
10
+ if (window.heedeo) {
11
+ reject(new Error("existing_collector"));
12
+ return;
13
+ }
14
+ const script = document.createElement("script");
15
+ script.src = url;
16
+ script.async = true;
17
+ const timer = setTimeout(() => finish(false), timeoutMs);
18
+ function finish(ok) {
19
+ clearTimeout(timer);
20
+ script.onload = null;
21
+ script.onerror = null;
22
+ if (ok && window.heedeo)
23
+ resolve();
24
+ else {
25
+ script.remove();
26
+ reject(new Error("script_unavailable"));
27
+ }
28
+ }
29
+ script.onload = () => finish(true);
30
+ script.onerror = () => finish(false);
31
+ document.head.append(script);
32
+ });
33
+ loading = { url, promise };
34
+ void promise.catch(() => {
35
+ if (loading?.promise === promise)
36
+ loading = undefined;
37
+ });
38
+ return promise;
39
+ }
40
+ /** Create once per app. Calling this function alone makes no identification request. */
41
+ export function createHeedeo(options) {
42
+ if (!options.publishableKey?.startsWith("pk_"))
43
+ throw new Error("Use a Heedeo publishable key in the browser.");
44
+ if (options.path !== undefined && options.endpoint !== undefined)
45
+ throw new Error("Give Heedeo either path or endpoint, not both.");
46
+ const publishableKey = options.publishableKey;
47
+ // A host on your domain that serves Heedeo, such as https://www.customer.com/k9m2x: an HTTPS
48
+ // origin and at most one path segment, which the script and identify sit under.
49
+ const endpoint = options.endpoint ?? null;
50
+ if (endpoint !== null &&
51
+ (!/^https:\/\/[^/?#]+(\/[a-zA-Z0-9_-]+)?$/.test(endpoint) || !URL.canParse(endpoint)))
52
+ throw new Error("Heedeo endpoint must be an HTTPS origin with one optional path segment.");
53
+ const path = pathPrefix(options.path ?? "/hd");
54
+ const budget = timeout(options.timeoutMs ?? 3000);
55
+ return {
56
+ async identify() {
57
+ if (typeof window === "undefined")
58
+ return failure("browser_required", "Call identify in a browser action handler.");
59
+ let timer;
60
+ const deadline = new Promise((resolve) => {
61
+ timer = setTimeout(() => resolve(failure("timeout", "Identification timed out. Continue without signals.")), budget);
62
+ });
63
+ const identify = async () => {
64
+ try {
65
+ const url = new URL(`${endpoint ?? window.location.origin + path}/i.js`);
66
+ url.searchParams.set("key", publishableKey);
67
+ await load(url.href, budget);
68
+ const data = await window.heedeo?.identify();
69
+ return isIdentification(data)
70
+ ? { data, error: null }
71
+ : failure("invalid_response", "Identification returned an unexpected response.");
72
+ }
73
+ catch {
74
+ return failure("identification_unavailable", "Check the public key and proxy route. Continue signup without signals.");
75
+ }
76
+ };
77
+ try {
78
+ return await Promise.race([identify(), deadline]);
79
+ }
80
+ finally {
81
+ clearTimeout(timer);
82
+ }
83
+ },
84
+ };
85
+ }
@@ -0,0 +1,160 @@
1
+ type Counts = {
2
+ "24h": number;
3
+ "7d": number;
4
+ all: number;
5
+ };
6
+ declare const LINKS: readonly ["shared_account", "shared_email", "shared_principal", "shared_phone", "shared_card", "probable", "ip"];
7
+ declare const BANDS: readonly ["allow", "verify", "review", "block"];
8
+ /** How two browsers are related. */
9
+ export type Link = (typeof LINKS)[number];
10
+ export type EventData = {
11
+ event_id: string;
12
+ flag: boolean;
13
+ score: number;
14
+ threshold: number;
15
+ /** The thresholds that applied under Settings: verify, review (the flag) and block (null without a block band; absent on answers stored before it), and the action whose own thresholds applied (null: the workspace's; absent before it). Absent on answers stored before bands. */
16
+ thresholds?: Thresholds & {
17
+ action?: string | null;
18
+ };
19
+ /** The band the score falls in, from those numbers alone: allow below verify, verify from there, review from the flag threshold, block from the block threshold. Heedeo enforces nothing. */
20
+ suggested?: Band;
21
+ /** When the customer's weights last changed (ISO 8601), null while the defaults are in use: the weight table that scored this answer. Absent on answers stored before it existed. */
22
+ version?: string | null;
23
+ /** Each reason with the weight your table gave it and base, its weight at the defaults (absent on answers stored before it existed). A trust reason (verified_phone, card_on_file, wallet_card, real_user, tenure, paid_history, verified_id, legit_outcome, email_tenure) lowers the score: its weight and base are negative. */
24
+ reasons: {
25
+ code: string;
26
+ text: string;
27
+ weight: number;
28
+ base?: number;
29
+ }[];
30
+ /** Browsers related to this one, one entry per edge, the strongest first: a shared account, email, principal, phone or card (weight 1), a probable match (its score), or an address shared in the last 7 days (`ip`: exact for IPv4, the /64 for IPv6; 0.5 a home line, 0.2 hosting). weight is how much the link proves; identities the other browser's own. Absent on answers stored before it existed. */
31
+ related?: {
32
+ visitor_id: string;
33
+ link: Link;
34
+ weight: number;
35
+ identities: number;
36
+ }[];
37
+ /** The account-side read, when the event sent a user_id: the browsers that presented this account in each window, this one included, and whether this browser is new to it; null without a user_id. Absent on answers stored before it existed. */
38
+ account?: {
39
+ visitors: Counts;
40
+ new_browser: boolean;
41
+ } | null;
42
+ visitor: {
43
+ id: string;
44
+ confidence: number;
45
+ first_seen: string;
46
+ last_seen: string;
47
+ emails: Counts;
48
+ accounts: Counts;
49
+ } | null;
50
+ counts: {
51
+ "24h": number;
52
+ "7d": number;
53
+ };
54
+ listed: "allow" | "block" | null;
55
+ activity: {
56
+ last_hour: number;
57
+ hourly_norm: number;
58
+ burst: boolean;
59
+ };
60
+ email: {
61
+ disposable: boolean;
62
+ alias_of_existing: boolean;
63
+ pattern: boolean;
64
+ /** The domain is a free mail provider's (Gmail, Outlook, Yahoo, iCloud, Proton and the like). Absent on answers stored before it existed. */
65
+ free_provider?: boolean;
66
+ /** The local part names a role (admin@, info@, billing@), not a person. Absent before it existed. */
67
+ role_address?: boolean;
68
+ /** The young_domain reason fired: the domain was registered under 30 days ago. Absent before it existed. */
69
+ young_domain?: boolean;
70
+ /** When the email's registrable domain was registered (ISO 8601), by RDAP; null when not asked (a free provider's) or the registry could not say. Absent before it existed. */
71
+ domain_registered?: string | null;
72
+ /** The registry was asked and could not say how old the domain is: no RDAP server for its TLD, no answer in time, or no registration date. Never a reason. Absent before it existed. */
73
+ domain_age_unknown?: boolean;
74
+ /** Have I Been Pwned's earliest breach holding the address (`first`, YYYY-MM-DD; null in none); null when breach history is off. Absent before it existed. */
75
+ breach?: {
76
+ first: string | null;
77
+ } | null;
78
+ } | null;
79
+ network: {
80
+ /** What a shared address proves: 0.5 a home or office line, 0.2 hosting, 0 a carrier, Private Relay or Tor. Absent on answers stored before it existed. */
81
+ link_weight?: number;
82
+ datacenter: boolean;
83
+ vpn: boolean | null;
84
+ proxy: boolean | null;
85
+ tor: boolean;
86
+ private_relay: boolean;
87
+ anonymous: boolean | null;
88
+ network_type: string | null;
89
+ asn: string | null;
90
+ };
91
+ agent: {
92
+ operator: string | null;
93
+ verified: boolean;
94
+ declared: string | null;
95
+ operator_network: string | null;
96
+ automation: string | null;
97
+ } | null;
98
+ /** What the identification's browser showed of itself (collector 1.2.0), null without an identification: `tampered` its own readings disagree (`tamper` names the checks that failed; null when the collector made none), `virtual_machine` a virtual or software GPU with two supporting tells (`vm` names the renderer and the tells), `private_mode` a private window (null when unknown). Absent on answers stored before it existed. */
99
+ environment?: Environment | null;
100
+ };
101
+ export type Environment = {
102
+ tampered: boolean | null;
103
+ tamper: string[];
104
+ virtual_machine: boolean;
105
+ vm: string[];
106
+ private_mode: boolean | null;
107
+ };
108
+ /** The four bands: allow, verify, review and block. */
109
+ export type Band = (typeof BANDS)[number];
110
+ type Thresholds = {
111
+ verify: number;
112
+ review: number;
113
+ block?: number | null;
114
+ };
115
+ /** What GET /v1/visitors/:id answers: the browser's standing score now, after trust, in your bands. */
116
+ export type VisitorData = {
117
+ visitor: {
118
+ id: string;
119
+ first_seen: string;
120
+ last_seen: string;
121
+ emails: Counts;
122
+ accounts: Counts;
123
+ };
124
+ listed: "allow" | "block" | null;
125
+ score: number;
126
+ thresholds: Thresholds;
127
+ suggested: Band;
128
+ related: {
129
+ visitor_id: string;
130
+ link: Link;
131
+ weight: number;
132
+ identities: number;
133
+ }[];
134
+ /** The trust the visitor's evidence earns, each with a negative weight; only the strongest counts. Absent before trust existed. */
135
+ trust?: {
136
+ code: string;
137
+ text: string;
138
+ weight: number;
139
+ }[];
140
+ outcomes: {
141
+ kind: string;
142
+ outcome: string;
143
+ label: string | null;
144
+ created_at: string;
145
+ }[];
146
+ };
147
+ export type OutcomeData = {
148
+ kind: "event" | "visitor" | "account";
149
+ event_id?: string;
150
+ visitor_id: string | null;
151
+ outcome: "abuse" | "legit";
152
+ label: string | null;
153
+ source: string;
154
+ note: string | null;
155
+ created_at: string;
156
+ };
157
+ export declare function isVisitorData(value: unknown): value is VisitorData;
158
+ export declare function isOutcomeData(value: unknown): value is OutcomeData;
159
+ export declare function isEventData(value: unknown): value is EventData;
160
+ export {};
@@ -0,0 +1,150 @@
1
+ const LINKS = [
2
+ "shared_account",
3
+ "shared_email",
4
+ "shared_principal",
5
+ "shared_phone",
6
+ "shared_card",
7
+ "probable",
8
+ "ip",
9
+ ];
10
+ const BANDS = ["allow", "verify", "review", "block"];
11
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
12
+ const number = (value) => typeof value === "number" && Number.isFinite(value);
13
+ const string = (value) => typeof value === "string";
14
+ const boolean = (value) => typeof value === "boolean";
15
+ const nullable = (check) => (value) => value === null || check(value);
16
+ // A field added after answers were first stored: absent, or as checked.
17
+ const optional = (check) => (value) => value === undefined || check(value);
18
+ const counts = (value) => object(value) && number(value["24h"]) && number(value["7d"]);
19
+ const windows = (value) => counts(value) && number(value.all);
20
+ const related = (value) => Array.isArray(value) &&
21
+ value.every((r) => object(r) &&
22
+ string(r.visitor_id) &&
23
+ LINKS.includes(r.link) &&
24
+ number(r.weight) &&
25
+ number(r.identities));
26
+ const thresholds = (value) => object(value) &&
27
+ number(value.verify) &&
28
+ number(value.review) &&
29
+ (value.block === undefined || nullable(number)(value.block)) &&
30
+ (value.action === undefined || nullable(string)(value.action));
31
+ const band = (value) => BANDS.includes(value);
32
+ const strings = (value) => Array.isArray(value) && value.every(string);
33
+ const environment = (value) => object(value) &&
34
+ nullable(boolean)(value.tampered) &&
35
+ strings(value.tamper) &&
36
+ boolean(value.virtual_machine) &&
37
+ strings(value.vm) &&
38
+ nullable(boolean)(value.private_mode);
39
+ export function isVisitorData(value) {
40
+ if (!object(value) || !object(value.visitor))
41
+ return false;
42
+ const v = value.visitor;
43
+ return (string(v.id) &&
44
+ string(v.first_seen) &&
45
+ string(v.last_seen) &&
46
+ windows(v.emails) &&
47
+ windows(v.accounts) &&
48
+ (value.listed === null || value.listed === "allow" || value.listed === "block") &&
49
+ number(value.score) &&
50
+ thresholds(value.thresholds) &&
51
+ band(value.suggested) &&
52
+ related(value.related) &&
53
+ (value.trust === undefined ||
54
+ (Array.isArray(value.trust) &&
55
+ value.trust.every((t) => object(t) && string(t.code) && number(t.weight)))) &&
56
+ Array.isArray(value.outcomes) &&
57
+ value.outcomes.every((o) => object(o) && string(o.kind) && string(o.outcome)));
58
+ }
59
+ export function isOutcomeData(value) {
60
+ return (object(value) &&
61
+ ["event", "visitor", "account"].includes(value.kind) &&
62
+ (value.event_id === undefined || string(value.event_id)) &&
63
+ nullable(string)(value.visitor_id) &&
64
+ (value.outcome === "abuse" || value.outcome === "legit") &&
65
+ nullable(string)(value.label) &&
66
+ string(value.source) &&
67
+ nullable(string)(value.note) &&
68
+ string(value.created_at));
69
+ }
70
+ export function isEventData(value) {
71
+ if (!object(value) ||
72
+ !string(value.event_id) ||
73
+ !boolean(value.flag) ||
74
+ !number(value.score) ||
75
+ !number(value.threshold) ||
76
+ !counts(value.counts) ||
77
+ (value.listed !== null && value.listed !== "allow" && value.listed !== "block") ||
78
+ !Array.isArray(value.reasons) ||
79
+ !value.reasons.every((reason) => object(reason) &&
80
+ string(reason.code) &&
81
+ string(reason.text) &&
82
+ number(reason.weight) &&
83
+ (reason.base === undefined || number(reason.base))) ||
84
+ (value.thresholds !== undefined && !thresholds(value.thresholds)) ||
85
+ (value.suggested !== undefined && !band(value.suggested)) ||
86
+ (value.version !== undefined && !nullable(string)(value.version)) ||
87
+ (value.related !== undefined && !related(value.related)) ||
88
+ (value.account !== undefined &&
89
+ value.account !== null &&
90
+ (!object(value.account) ||
91
+ !counts(value.account.visitors) ||
92
+ !number(value.account.visitors.all) ||
93
+ !boolean(value.account.new_browser))))
94
+ return false;
95
+ if (value.visitor !== null) {
96
+ const visitor = value.visitor;
97
+ if (!object(visitor) ||
98
+ !string(visitor.id) ||
99
+ !number(visitor.confidence) ||
100
+ !string(visitor.first_seen) ||
101
+ !string(visitor.last_seen) ||
102
+ !object(visitor.emails) ||
103
+ !counts(visitor.emails) ||
104
+ !number(visitor.emails.all) ||
105
+ !object(visitor.accounts) ||
106
+ !counts(visitor.accounts) ||
107
+ !number(visitor.accounts.all))
108
+ return false;
109
+ }
110
+ const activity = value.activity;
111
+ const network = value.network;
112
+ if (!object(activity) ||
113
+ !number(activity.last_hour) ||
114
+ !number(activity.hourly_norm) ||
115
+ !boolean(activity.burst) ||
116
+ !object(network) ||
117
+ (network.link_weight !== undefined && !number(network.link_weight)) ||
118
+ !boolean(network.datacenter) ||
119
+ !boolean(network.tor) ||
120
+ !boolean(network.private_relay) ||
121
+ ![network.vpn, network.proxy, network.anonymous].every(nullable(boolean)) ||
122
+ ![network.network_type, network.asn].every(nullable(string)))
123
+ return false;
124
+ const email = value.email;
125
+ if (email !== null &&
126
+ (!object(email) ||
127
+ ![email.disposable, email.alias_of_existing, email.pattern].every(boolean) ||
128
+ ![
129
+ email.free_provider,
130
+ email.role_address,
131
+ email.young_domain,
132
+ email.domain_age_unknown,
133
+ ].every(optional(boolean)) ||
134
+ !optional(nullable(string))(email.domain_registered) ||
135
+ !optional(nullable((b) => object(b) && nullable(string)(b.first)))(email.breach)))
136
+ return false;
137
+ if (value.agent !== null &&
138
+ (!object(value.agent) ||
139
+ !boolean(value.agent.verified) ||
140
+ ![
141
+ value.agent.operator,
142
+ value.agent.declared,
143
+ value.agent.operator_network,
144
+ value.agent.automation,
145
+ ].every(nullable(string))))
146
+ return false;
147
+ if (value.environment !== undefined && !nullable(environment)(value.environment))
148
+ return false;
149
+ return true;
150
+ }
@@ -0,0 +1,101 @@
1
+ import { type EventData, type OutcomeData, type VisitorData } from "./response.js";
2
+ export type { Band, EventData, Link, OutcomeData, VisitorData } from "./response.js";
3
+ import { type Result } from "./shared.js";
4
+ export type { HeedeoError, HeedeoErrorCode, Result } from "./shared.js";
5
+ export type HeedeoOptions = {
6
+ secretKey: string | undefined;
7
+ /** Resolve only an IP supplied by your trusted hosting layer. Used for BOTH requests. */
8
+ clientIp: (request: Request) => string | null;
9
+ baseUrl?: string;
10
+ path?: string;
11
+ /** Total event budget, including the retry. Default: 3000 ms. */
12
+ timeoutMs?: number;
13
+ fetch?: typeof fetch;
14
+ };
15
+ export type EventInput = {
16
+ request: Request;
17
+ token?: string | null;
18
+ action: string;
19
+ email?: string;
20
+ userId?: string;
21
+ /** For an agent acting for one of your users: that user's account id (the principal). Hashed on arrival. */
22
+ actingFor?: string;
23
+ /** Reuse across application-level retries of the same logical action. */
24
+ requestId?: string;
25
+ /** What your platform knows about the person, server to server. Every field is optional; the phone number and the card fingerprint are hashed on arrival and never stored raw. */
26
+ evidence?: Evidence;
27
+ };
28
+ /** ISO 8601, such as new Date(seconds * 1000).toISOString(). */
29
+ type Timestamp = string;
30
+ /** Ownership evidence: see the integration guide for the one-liner from each source. */
31
+ export type Evidence = {
32
+ email?: {
33
+ verified?: boolean;
34
+ verified_at?: Timestamp;
35
+ };
36
+ /** number in E.164 (+14155550123), or your own hash of it as hash: one of the two. line_type from Twilio Lookup's line type intelligence. */
37
+ phone?: {
38
+ number?: string;
39
+ hash?: string;
40
+ verified?: boolean;
41
+ line_type?: "mobile" | "landline" | "fixed_voip" | "non_fixed_voip" | "toll_free" | "prepaid" | "unknown";
42
+ country?: string;
43
+ };
44
+ /** From the provider's ID token or user endpoint. real_user_status is Apple's (0, 1, 2). */
45
+ oauth?: {
46
+ provider?: "google" | "apple" | "github" | "microsoft" | "other";
47
+ email_verified?: boolean;
48
+ hosted_domain?: string;
49
+ real_user_status?: 0 | 1 | 2;
50
+ private_relay?: boolean;
51
+ account_created_at?: Timestamp;
52
+ two_factor?: boolean;
53
+ };
54
+ /** Your own account record. */
55
+ account?: {
56
+ created_at?: Timestamp;
57
+ plan?: "free" | "trial" | "paid";
58
+ paid_since?: Timestamp;
59
+ };
60
+ /** From Stripe's PaymentMethod (card.funding, card.fingerprint, card.checks.cvc_check, card.wallet, card.country) and its charge. */
61
+ payment?: {
62
+ funding?: "credit" | "debit" | "prepaid";
63
+ card_fingerprint?: string;
64
+ cvc_check?: "pass" | "fail";
65
+ wallet?: "apple_pay" | "google_pay" | "link" | "none";
66
+ three_ds?: "authenticated" | "failed" | "not_attempted";
67
+ country?: string;
68
+ radar_risk_level?: Risk;
69
+ free_trial_abuse_risk?: Risk;
70
+ };
71
+ /** A check you ran on the person. */
72
+ verification?: {
73
+ kind?: "otp" | "email_link" | "3ds" | "id_document" | "liveness" | "captcha";
74
+ result?: "passed" | "failed";
75
+ at?: Timestamp;
76
+ };
77
+ };
78
+ type Risk = "normal" | "elevated" | "highest" | "not_assessed" | "unknown";
79
+ /** What you learned about one event, visitor or account: exactly one id, abuse or legit. */
80
+ export type OutcomeInput = {
81
+ eventId?: string;
82
+ visitorId?: string;
83
+ userId?: string;
84
+ outcome: "abuse" | "legit";
85
+ /** Your own short word for it: trial_abuse, chargeback, paid. */
86
+ label?: string;
87
+ /** One line for your reviewers. */
88
+ note?: string;
89
+ };
90
+ export declare class Heedeo {
91
+ #private;
92
+ constructor(options: HeedeoOptions);
93
+ /** Signals only. Errors are values; this method never decides whether signup is allowed. */
94
+ event: (input: EventInput) => Promise<Result<EventData>>;
95
+ /** Report what you learned about an event, visitor or account. Ground truth for tuning weights, never policy. */
96
+ outcome: (input: OutcomeInput) => Promise<Result<OutcomeData>>;
97
+ /** A browser's standing score now, after trust, in your bands: GET /v1/visitors/:id. Errors are values; an unknown visitor is a 404 api_error. */
98
+ visitor: (visitorId: string) => Promise<Result<VisitorData>>;
99
+ /** Mount at /hd/[...path]. Only the public script and identification endpoint are forwarded. */
100
+ proxy: (request: Request) => Promise<Response>;
101
+ }
package/dist/server.js ADDED
@@ -0,0 +1,265 @@
1
+ // Node's built-in import also prevents accidentally bundling this entry into browser code.
2
+ import { isIP } from "node:net";
3
+ import { isEventData, isOutcomeData, isVisitorData, } from "./response.js";
4
+ import { failure, pathPrefix, timeout } from "./shared.js";
5
+ function origin(value) {
6
+ const url = new URL(value);
7
+ const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
8
+ if ((url.protocol !== "https:" && !(local && url.protocol === "http:")) ||
9
+ url.username ||
10
+ url.password ||
11
+ url.search ||
12
+ url.hash ||
13
+ url.pathname !== "/") {
14
+ throw new Error("Heedeo baseUrl must be an HTTPS origin (HTTP is allowed only on localhost).");
15
+ }
16
+ return url.origin;
17
+ }
18
+ // Send just the request context used for signals and signed-agent verification, never app secrets.
19
+ const EVENT_HEADERS = [
20
+ "user-agent",
21
+ "accept-language",
22
+ "signature",
23
+ "signature-input",
24
+ "signature-agent",
25
+ "content-digest",
26
+ "sec-ch-ua",
27
+ "sec-ch-ua-platform",
28
+ "sec-ch-ua-mobile",
29
+ // The end user's country as your edge saw it, which card_mismatch reads without an IPinfo answer.
30
+ "cf-ipcountry",
31
+ "x-vercel-ip-country",
32
+ ];
33
+ // What the identify proxy forwards: the browser's own description of itself, its languages and its
34
+ // client hints, low and high entropy, which the matcher reads beside the bundle; and origin, since a
35
+ // workspace with allowed origins checks the page's. Never a cookie but Heedeo's own (below), never
36
+ // an app secret.
37
+ const IDENTIFY_HEADERS = [
38
+ "user-agent",
39
+ "accept-language",
40
+ "content-type",
41
+ "origin",
42
+ "sec-ch-ua",
43
+ "sec-ch-ua-platform",
44
+ "sec-ch-ua-mobile",
45
+ "sec-ch-ua-platform-version",
46
+ "sec-ch-ua-model",
47
+ "sec-ch-ua-arch",
48
+ "sec-ch-ua-bitness",
49
+ "sec-ch-ua-full-version-list",
50
+ ];
51
+ class PayloadTooLarge extends Error {
52
+ }
53
+ async function limitedBody(request, signal) {
54
+ const reader = request.body?.getReader();
55
+ if (!reader)
56
+ return new Uint8Array();
57
+ const chunks = [];
58
+ let size = 0;
59
+ const abort = () => {
60
+ void reader.cancel().catch(() => { });
61
+ };
62
+ signal.addEventListener("abort", abort, { once: true });
63
+ try {
64
+ while (true) {
65
+ signal.throwIfAborted();
66
+ const { done, value } = await reader.read();
67
+ signal.throwIfAborted();
68
+ if (done)
69
+ break;
70
+ size += value.byteLength;
71
+ if (size > 65536)
72
+ throw new PayloadTooLarge();
73
+ chunks.push(value);
74
+ }
75
+ const body = new Uint8Array(size);
76
+ let offset = 0;
77
+ for (const chunk of chunks) {
78
+ body.set(chunk, offset);
79
+ offset += chunk.byteLength;
80
+ }
81
+ return body;
82
+ }
83
+ finally {
84
+ signal.removeEventListener("abort", abort);
85
+ await reader.cancel().catch(() => { });
86
+ reader.releaseLock();
87
+ }
88
+ }
89
+ export class Heedeo {
90
+ #key;
91
+ #origin;
92
+ #path;
93
+ #timeout;
94
+ #fetch;
95
+ #clientIp;
96
+ constructor(options) {
97
+ if (!options.secretKey?.startsWith("sk_")) {
98
+ throw new Error("Set HEEDEO_SECRET_KEY on your server. Never use a publishable key here.");
99
+ }
100
+ this.#key = options.secretKey;
101
+ this.#origin = origin(options.baseUrl ?? "https://app.heedeo.com");
102
+ this.#path = pathPrefix(options.path ?? "/hd");
103
+ this.#timeout = timeout(options.timeoutMs ?? 3000);
104
+ // Workers requires the global receiver; do not call native fetch as a class method.
105
+ const send = options.fetch ?? globalThis.fetch.bind(globalThis);
106
+ this.#fetch = (input, init) => send(input, init);
107
+ this.#clientIp = options.clientIp;
108
+ }
109
+ #ip(request) {
110
+ const value = this.#clientIp(request);
111
+ const normalized = value?.replace(/^::ffff:/, "").trim();
112
+ return normalized && isIP(normalized) ? normalized : null;
113
+ }
114
+ /** Signals only. Errors are values; this method never decides whether signup is allowed. */
115
+ event = async (input) => {
116
+ if (!input.token)
117
+ return failure("missing_token", "Identification was unavailable. Continue without signals.");
118
+ try {
119
+ const ip = this.#ip(input.request);
120
+ if (!ip)
121
+ return failure("missing_client_ip", "Configure clientIp for your trusted hosting layer.");
122
+ const url = new URL(input.request.url);
123
+ const headers = {
124
+ host: url.host,
125
+ ":method": input.request.method,
126
+ ":path": url.pathname + url.search,
127
+ };
128
+ for (const name of EVENT_HEADERS) {
129
+ const value = input.request.headers.get(name);
130
+ if (value !== null)
131
+ headers[name] = value;
132
+ }
133
+ const body = JSON.stringify({
134
+ token: input.token,
135
+ action: input.action,
136
+ email: input.email,
137
+ user_id: input.userId,
138
+ acting_for: input.actingFor,
139
+ evidence: input.evidence,
140
+ client_ip: ip,
141
+ headers,
142
+ request_id: input.requestId ?? crypto.randomUUID(),
143
+ });
144
+ const signal = AbortSignal.any([AbortSignal.timeout(this.#timeout), input.request.signal]);
145
+ return await this.#send("/v1/events", body, signal, isEventData, "event");
146
+ }
147
+ catch {
148
+ return failure("configuration_error", "Check the Heedeo request and clientIp configuration.");
149
+ }
150
+ };
151
+ /** Report what you learned about an event, visitor or account. Ground truth for tuning weights, never policy. */
152
+ outcome = async (input) => {
153
+ const body = JSON.stringify({
154
+ event_id: input.eventId,
155
+ visitor_id: input.visitorId,
156
+ user_id: input.userId,
157
+ outcome: input.outcome,
158
+ label: input.label,
159
+ note: input.note,
160
+ });
161
+ return this.#send("/v1/outcomes", body, AbortSignal.timeout(this.#timeout), isOutcomeData, "outcome");
162
+ };
163
+ /** A browser's standing score now, after trust, in your bands: GET /v1/visitors/:id. Errors are values; an unknown visitor is a 404 api_error. */
164
+ visitor = async (visitorId) => this.#send(`/v1/visitors/${encodeURIComponent(visitorId)}`, null, AbortSignal.timeout(this.#timeout), isVisitorData, "visitor lookup");
165
+ // One POST (a GET without a body) with one retry on a 5xx or a network error. Errors are
166
+ // values, never thrown.
167
+ async #send(path, body, signal, valid, what) {
168
+ for (let attempt = 0; attempt < 2; attempt++) {
169
+ try {
170
+ const response = await this.#fetch(`${this.#origin}${path}`, {
171
+ method: body === null ? "GET" : "POST",
172
+ redirect: "manual",
173
+ signal,
174
+ headers: {
175
+ authorization: `Bearer ${this.#key}`,
176
+ ...(body === null ? {} : { "content-type": "application/json" }),
177
+ },
178
+ ...(body === null ? {} : { body }),
179
+ });
180
+ if (response.status >= 500 && attempt === 0 && !signal.aborted) {
181
+ await response.body?.cancel();
182
+ continue;
183
+ }
184
+ if (!response.ok) {
185
+ // Do not reflect response bodies: upstream errors can contain sensitive input.
186
+ const code = response.status === 401 || response.status === 403 ? "invalid_key" : "api_error";
187
+ return failure(code, `Heedeo could not record this ${what}. Check the server configuration.`, response.status);
188
+ }
189
+ const data = await response.json();
190
+ if (!valid(data)) {
191
+ return failure("invalid_response", "Heedeo returned an unexpected response.");
192
+ }
193
+ return { data, error: null };
194
+ }
195
+ catch {
196
+ if (signal.aborted)
197
+ return failure("timeout_or_aborted", "Heedeo did not finish within the request budget.");
198
+ if (attempt === 1)
199
+ return failure("network_error", "Heedeo is unavailable. Continue without signals.");
200
+ }
201
+ }
202
+ return failure("network_error", "Heedeo is unavailable. Continue without signals.");
203
+ }
204
+ /** Mount at /hd/[...path]. Only the public script and identification endpoint are forwarded. */
205
+ proxy = async (request) => {
206
+ const url = new URL(request.url);
207
+ const script = url.pathname === `${this.#path}/i.js` && request.method === "GET";
208
+ const identify = url.pathname === `${this.#path}/v1/identify` && request.method === "POST";
209
+ if (!script && !identify)
210
+ return new Response("Not found", { status: 404 });
211
+ try {
212
+ const ip = this.#ip(request);
213
+ if (!ip)
214
+ return new Response("Heedeo clientIp is not configured", { status: 503 });
215
+ const target = new URL(script ? "/i.js" : "/v1/identify", this.#origin);
216
+ const key = url.searchParams.get("key");
217
+ if (!key?.startsWith("pk_"))
218
+ return new Response("A publishable key is required", { status: 400 });
219
+ target.searchParams.set("key", key);
220
+ const headers = new Headers({ "x-heedeo-client-ip": ip });
221
+ for (const name of IDENTIFY_HEADERS) {
222
+ const value = request.headers.get(name);
223
+ if (value)
224
+ headers.set(name, value);
225
+ }
226
+ const cookie = request.headers
227
+ .get("cookie")
228
+ ?.split(";")
229
+ .map((v) => v.trim())
230
+ .find((v) => v.startsWith("_idd="));
231
+ if (cookie)
232
+ headers.set("cookie", cookie);
233
+ const signal = AbortSignal.any([AbortSignal.timeout(this.#timeout), request.signal]);
234
+ const body = identify ? await limitedBody(request, signal) : undefined;
235
+ const response = await this.#fetch(target, {
236
+ method: request.method,
237
+ headers,
238
+ body,
239
+ signal,
240
+ redirect: "manual",
241
+ });
242
+ if (response.status >= 300 && response.status < 400) {
243
+ await response.body?.cancel();
244
+ throw new Error("Unexpected upstream redirect");
245
+ }
246
+ const outgoing = new Headers({ "cache-control": "no-store" });
247
+ for (const name of ["content-type", "accept-ch"]) {
248
+ const value = response.headers.get(name);
249
+ if (value)
250
+ outgoing.set(name, value);
251
+ }
252
+ for (const value of response.headers.getSetCookie())
253
+ outgoing.append("set-cookie", value);
254
+ return new Response(response.body, { status: response.status, headers: outgoing });
255
+ }
256
+ catch (error) {
257
+ if (error instanceof PayloadTooLarge)
258
+ return new Response("Payload too large", { status: 413 });
259
+ return new Response("Heedeo is unavailable", {
260
+ status: 502,
261
+ headers: { "cache-control": "no-store" },
262
+ });
263
+ }
264
+ };
265
+ }
@@ -0,0 +1,22 @@
1
+ export type HeedeoErrorCode = "missing_token" | "missing_client_ip" | "invalid_key" | "api_error" | "invalid_response" | "network_error" | "timeout_or_aborted" | "configuration_error" | "browser_required" | "timeout" | "identification_unavailable";
2
+ export type HeedeoError = {
3
+ code: HeedeoErrorCode;
4
+ message: string;
5
+ status?: number;
6
+ };
7
+ export type Result<T> = {
8
+ data: T;
9
+ error: null;
10
+ } | {
11
+ data: null;
12
+ error: HeedeoError;
13
+ };
14
+ export declare function failure(code: HeedeoErrorCode, message: string, status?: number): Result<never>;
15
+ export declare function timeout(value: number): number;
16
+ export declare function pathPrefix(value: string): string;
17
+ export type Identification = {
18
+ token: string;
19
+ visitor_id: string;
20
+ confidence: number;
21
+ };
22
+ export declare function isIdentification(value: unknown): value is Identification;
package/dist/shared.js ADDED
@@ -0,0 +1,24 @@
1
+ export function failure(code, message, status) {
2
+ return { data: null, error: { code, message, ...(status === undefined ? {} : { status }) } };
3
+ }
4
+ export function timeout(value) {
5
+ if (!Number.isInteger(value) || value < 1 || value > 30_000) {
6
+ throw new Error("Heedeo timeoutMs must be an integer between 1 and 30000.");
7
+ }
8
+ return value;
9
+ }
10
+ export function pathPrefix(value) {
11
+ if (!/^\/[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/.test(value)) {
12
+ throw new Error("Heedeo path must be a local prefix such as /hd.");
13
+ }
14
+ return value;
15
+ }
16
+ export function isIdentification(value) {
17
+ if (!value || typeof value !== "object")
18
+ return false;
19
+ const id = value;
20
+ return (typeof id.token === "string" &&
21
+ id.token.length > 0 &&
22
+ typeof id.visitor_id === "string" &&
23
+ typeof id.confidence === "number");
24
+ }
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "@heedeo/sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-pilot.12",
4
+ "description": "Browser identification and server-side signup signals for Heedeo",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=20.18"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/server.d.ts",
17
+ "import": "./dist/server.js"
18
+ },
19
+ "./browser": {
20
+ "types": "./dist/browser.d.ts",
21
+ "import": "./dist/browser.js"
22
+ }
23
+ },
24
+ "scripts": {
25
+ "build": "tsc",
26
+ "test": "npm run build && node --test test/*.test.mjs",
27
+ "prepack": "npm run build",
28
+ "lint": "biome check ."
29
+ },
30
+ "devDependencies": {
31
+ "@biomejs/biome": "2.5.15",
32
+ "@types/node": "24.19.2",
33
+ "typescript": "7.0.2"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public",
37
+ "registry": "https://registry.npmjs.org/",
38
+ "tag": "latest"
39
+ },
40
+ "homepage": "https://app.heedeo.com/docs/sdk",
41
+ "license": "MIT"
42
+ }