@noirtrack/sdk 0.1.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/LICENSE +21 -0
- package/README.md +104 -0
- package/dist/core/client-ip.d.ts +11 -0
- package/dist/core/client-ip.js +14 -0
- package/dist/core/endpoint.d.ts +17 -0
- package/dist/core/endpoint.js +32 -0
- package/dist/core/http.d.ts +10 -0
- package/dist/core/http.js +43 -0
- package/dist/core/ids.d.ts +2 -0
- package/dist/core/ids.js +10 -0
- package/dist/core/ingest.d.ts +78 -0
- package/dist/core/ingest.js +117 -0
- package/dist/core/secret.d.ts +26 -0
- package/dist/core/secret.js +60 -0
- package/dist/core/types.d.ts +113 -0
- package/dist/core/types.js +5 -0
- package/dist/express.d.ts +15 -0
- package/dist/express.js +52 -0
- package/dist/fetch.d.ts +16 -0
- package/dist/fetch.js +48 -0
- package/dist/form-shield.d.ts +39 -0
- package/dist/form-shield.js +41 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +56 -0
- package/dist/next.d.ts +16 -0
- package/dist/next.js +49 -0
- package/dist/react-native.d.ts +24 -0
- package/dist/react-native.js +78 -0
- package/dist/web.d.ts +30 -0
- package/dist/web.js +271 -0
- package/package.json +68 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NoirTrack
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# @noirtrack/sdk
|
|
2
|
+
|
|
3
|
+
One NoirTrack SDK for the browser, React Native, and your server. You set your keys once with `createClient`, then call methods. The client routes each call: analytics use the public key, trusted operations (firewall, server goals, amount-based revenue) use the secret key.
|
|
4
|
+
|
|
5
|
+
Works in any runtime with a global `fetch`: Node 18+, Next.js, Cloudflare Workers, Deno, Bun, the browser, and React Native. ESM only.
|
|
6
|
+
|
|
7
|
+
**Docs:** [noirtrack.com/docs](https://noirtrack.com/docs) — [NPM SDK guide](https://noirtrack.com/docs/npm-sdk) · [Server SDK reference](https://noirtrack.com/docs/server-sdk)
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm i @noirtrack/sdk
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Two keys, both on your site's Install page. The public key (`pk_live_...`) is safe in browser and app code. The secret key (`sk_live_...`) is server-side only.
|
|
16
|
+
|
|
17
|
+
## Browser
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { createClient } from '@noirtrack/sdk/web';
|
|
21
|
+
|
|
22
|
+
const noir = createClient({ publicKey: process.env.NEXT_PUBLIC_NOIRTRACK_KEY!, autoPageviews: true });
|
|
23
|
+
|
|
24
|
+
noir.event('signup', { plan: 'pro' });
|
|
25
|
+
noir.identify('user_123', { email });
|
|
26
|
+
noir.revenue({ checkoutId: 'cs_test_123' });
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Pageviews and SPA route changes are automatic. Events are batched and sent with `sendBeacon`. Set `cookieless: true` for a no-cookie mode. Safe to import in SSR (no-op without a window). Also: `view(path?)`, `reset()`, `flush()`, `links.decorate(url)`.
|
|
30
|
+
|
|
31
|
+
## React Native
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
35
|
+
import { createClient } from '@noirtrack/sdk/react-native';
|
|
36
|
+
|
|
37
|
+
const noir = await createClient({ publicKey: process.env.EXPO_PUBLIC_NOIRTRACK_KEY!, storage: AsyncStorage });
|
|
38
|
+
noir.view('Home');
|
|
39
|
+
noir.event('signup', { plan: 'pro' });
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Same methods as the browser. `createClient` is async because storage is async. Screen views are manual.
|
|
43
|
+
|
|
44
|
+
## Server
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { createClient } from '@noirtrack/sdk';
|
|
48
|
+
|
|
49
|
+
const noir = createClient({
|
|
50
|
+
publicKey: process.env.NOIRTRACK_PUBLIC_KEY!,
|
|
51
|
+
secretKey: process.env.NOIRTRACK_SECRET_KEY!,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
await noir.event('signup', { plan: 'pro' }, { visitorId });
|
|
55
|
+
await noir.goal('subscription_activated', { visitorId });
|
|
56
|
+
await noir.revenue({ amount: 49.0, currency: 'USD', visitorId }); // explicit amount (secret)
|
|
57
|
+
await noir.revenue({ checkoutId: 'cs_test_123' }); // capture by id (server verifies)
|
|
58
|
+
await noir.identify({ visitorId, name: 'Jane', attributes: { plan: 'pro' } });
|
|
59
|
+
await noir.shield({ email, honeypot: body._noir_hp, ip, ua });
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`visitorId` is the `noir_vid` cookie the tracker sets. Read it on your server. Every method fails open.
|
|
63
|
+
|
|
64
|
+
### Block traffic
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
const verdict = await noir.decide({ ip, ua, path });
|
|
68
|
+
if (verdict?.action === 'block') {
|
|
69
|
+
/* deny */
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Or use an adapter and pass the client:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
// Next.js: middleware.ts
|
|
77
|
+
import { guard } from '@noirtrack/sdk/next';
|
|
78
|
+
export const middleware = guard(noir, { onBlock: 'redirect', blockedPage: '/blocked' });
|
|
79
|
+
export const config = { matcher: ['/((?!_next/|favicon.ico).*)'] };
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`@noirtrack/sdk/express` (`guard(noir, { onBlock })`) and `@noirtrack/sdk/fetch` (`createGuard(noir, { onBlock })`) work the same way. You can also pass `{ publicKey, secretKey, onBlock }` instead of a client.
|
|
83
|
+
|
|
84
|
+
The guards resolve the visitor IP from `CF-Connecting-IP`, `True-Client-IP`, then the left-most `X-Forwarded-For`. Run them **behind a trusted proxy/CDN that sets those headers** — if requests can reach your app directly, a client can spoof `X-Forwarded-For` to forge its IP. Keep your secret key server-side only; it is never needed in the browser or app bundle.
|
|
85
|
+
|
|
86
|
+
## Options
|
|
87
|
+
|
|
88
|
+
| Option | Default | Where | Description |
|
|
89
|
+
| ----------------------------------------- | ---------------------- | -------- | ------------------------------------------------------- |
|
|
90
|
+
| `publicKey` | required | all | Public key (`pk_live_...`). |
|
|
91
|
+
| `secretKey` | required | server | Secret key (`sk_live_...`). Not accepted in browser/RN. |
|
|
92
|
+
| `endpoint` | hosted | all | Base URL. Self-hosted only. |
|
|
93
|
+
| `timeoutMs` | `800` | all | Abort a call after this, then fail open. |
|
|
94
|
+
| `autoPageviews` | `true` | web | Capture initial + SPA pageviews. |
|
|
95
|
+
| `cookieless` | `false` | web | No cookies; ids in sessionStorage. |
|
|
96
|
+
| `flushIntervalMs` / `maxQueueSize` | `5000` / `10` | web, RN | Batch flush tuning. |
|
|
97
|
+
| `storage` | in-memory | RN | Async store (pass AsyncStorage). |
|
|
98
|
+
| `onBlock` / `blockStatus` / `blockedPage` | `block` / `403` / none | adapters | Block handling. |
|
|
99
|
+
|
|
100
|
+
Full reference: the [Server SDK page](https://noirtrack.com/docs/server-sdk) in the NoirTrack docs.
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the real end-user IP from a request's headers, for the firewall + geo. Prefers
|
|
3
|
+
* Cloudflare's authoritative `CF-Connecting-IP` (it overwrites any client-supplied value), then
|
|
4
|
+
* `True-Client-IP` (CF Enterprise / Akamai), then the left-most `X-Forwarded-For` entry, then a
|
|
5
|
+
* transport fallback (e.g. socket.remoteAddress). Header names are matched case-insensitively by
|
|
6
|
+
* the caller's getter.
|
|
7
|
+
*
|
|
8
|
+
* Pass a getter that reads one header by (lowercase) name; it differs per runtime (Node's
|
|
9
|
+
* `req.headers[name]` vs the Fetch `Headers.get(name)`), so the seam stays tiny and shared.
|
|
10
|
+
*/
|
|
11
|
+
export declare function clientIp(get: (name: string) => string | null | undefined, fallback?: string | null): string;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the real end-user IP from a request's headers, for the firewall + geo. Prefers
|
|
3
|
+
* Cloudflare's authoritative `CF-Connecting-IP` (it overwrites any client-supplied value), then
|
|
4
|
+
* `True-Client-IP` (CF Enterprise / Akamai), then the left-most `X-Forwarded-For` entry, then a
|
|
5
|
+
* transport fallback (e.g. socket.remoteAddress). Header names are matched case-insensitively by
|
|
6
|
+
* the caller's getter.
|
|
7
|
+
*
|
|
8
|
+
* Pass a getter that reads one header by (lowercase) name; it differs per runtime (Node's
|
|
9
|
+
* `req.headers[name]` vs the Fetch `Headers.get(name)`), so the seam stays tiny and shared.
|
|
10
|
+
*/
|
|
11
|
+
export function clientIp(get, fallback) {
|
|
12
|
+
const first = (value) => (value ?? '').split(',')[0]?.trim() ?? '';
|
|
13
|
+
return first(get('cf-connecting-ip')) || first(get('true-client-ip')) || first(get('x-forwarded-for')) || (fallback ?? '').trim() || '';
|
|
14
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Endpoint resolution and the blocked-page helper, shared by every entry (server, web, RN).
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Default NoirTrack API base. Resolution order: the `endpoint` option, then the
|
|
6
|
+
* `NOIRTRACK_ENDPOINT` env var (server only), then this constant. Self-hosted / white-label
|
|
7
|
+
* installs set the env var or pass `endpoint`; users on the hosted service set nothing.
|
|
8
|
+
*/
|
|
9
|
+
export declare const DEFAULT_ENDPOINT = "https://noirtrack.com";
|
|
10
|
+
/** Resolve the base URL: explicit option, else NOIRTRACK_ENDPOINT env, else DEFAULT_ENDPOINT. */
|
|
11
|
+
export declare function resolveEndpoint(explicit?: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* True when the current request path is the blocked page itself. Adapters use this to skip
|
|
14
|
+
* enforcement on the blocked page so a still-blocked visitor isn't redirected to it forever.
|
|
15
|
+
* `page` may be a path ("/blocked") or an absolute URL ("https://site.com/blocked").
|
|
16
|
+
*/
|
|
17
|
+
export declare function isBlockedPagePath(path: string, page: string | null | undefined): boolean;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Endpoint resolution and the blocked-page helper, shared by every entry (server, web, RN).
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Default NoirTrack API base. Resolution order: the `endpoint` option, then the
|
|
6
|
+
* `NOIRTRACK_ENDPOINT` env var (server only), then this constant. Self-hosted / white-label
|
|
7
|
+
* installs set the env var or pass `endpoint`; users on the hosted service set nothing.
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_ENDPOINT = 'https://noirtrack.com';
|
|
10
|
+
/** Resolve the base URL: explicit option, else NOIRTRACK_ENDPOINT env, else DEFAULT_ENDPOINT. */
|
|
11
|
+
export function resolveEndpoint(explicit) {
|
|
12
|
+
const g = globalThis;
|
|
13
|
+
const fromEnv = g.process?.env?.NOIRTRACK_ENDPOINT;
|
|
14
|
+
return (explicit ?? fromEnv ?? DEFAULT_ENDPOINT).replace(/\/+$/, '');
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* True when the current request path is the blocked page itself. Adapters use this to skip
|
|
18
|
+
* enforcement on the blocked page so a still-blocked visitor isn't redirected to it forever.
|
|
19
|
+
* `page` may be a path ("/blocked") or an absolute URL ("https://site.com/blocked").
|
|
20
|
+
*/
|
|
21
|
+
export function isBlockedPagePath(path, page) {
|
|
22
|
+
if (!page)
|
|
23
|
+
return false;
|
|
24
|
+
const trim = (p) => p.replace(/\/+$/, '') || '/';
|
|
25
|
+
try {
|
|
26
|
+
const target = new URL(page, 'http://noirtrack.invalid').pathname;
|
|
27
|
+
return trim(path) === trim(target);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny fetch helpers with a timeout, used by every client. All callers fail open: a timeout or
|
|
3
|
+
* network error resolves to the safe default instead of throwing.
|
|
4
|
+
*/
|
|
5
|
+
/** POST JSON and return the parsed body, or null on non-2xx / timeout / error. */
|
|
6
|
+
export declare function postJson<T>(url: string, body: unknown, headers: Record<string, string>, timeoutMs: number): Promise<T | null>;
|
|
7
|
+
/** POST JSON and return whether the server accepted it (2xx). */
|
|
8
|
+
export declare function postOk(url: string, body: unknown, headers: Record<string, string>, timeoutMs: number): Promise<boolean>;
|
|
9
|
+
/** Bearer header for the secret-key endpoints. */
|
|
10
|
+
export declare function bearer(secretKey: string): Record<string, string>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny fetch helpers with a timeout, used by every client. All callers fail open: a timeout or
|
|
3
|
+
* network error resolves to the safe default instead of throwing.
|
|
4
|
+
*/
|
|
5
|
+
/** POST JSON and return the parsed body, or null on non-2xx / timeout / error. */
|
|
6
|
+
export async function postJson(url, body, headers, timeoutMs) {
|
|
7
|
+
const res = await rawPost(url, body, headers, timeoutMs);
|
|
8
|
+
if (!res || !res.ok)
|
|
9
|
+
return null;
|
|
10
|
+
try {
|
|
11
|
+
return (await res.json());
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** POST JSON and return whether the server accepted it (2xx). */
|
|
18
|
+
export async function postOk(url, body, headers, timeoutMs) {
|
|
19
|
+
const res = await rawPost(url, body, headers, timeoutMs);
|
|
20
|
+
return res?.ok ?? false;
|
|
21
|
+
}
|
|
22
|
+
async function rawPost(url, body, headers, timeoutMs) {
|
|
23
|
+
const controller = new AbortController();
|
|
24
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
25
|
+
try {
|
|
26
|
+
return await fetch(url, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
29
|
+
body: JSON.stringify(body),
|
|
30
|
+
signal: controller.signal,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Bearer header for the secret-key endpoints. */
|
|
41
|
+
export function bearer(secretKey) {
|
|
42
|
+
return { Authorization: `Bearer ${secretKey}` };
|
|
43
|
+
}
|
package/dist/core/ids.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Visitor/session id generation, shared by the browser and React Native clients. */
|
|
2
|
+
export function uuid() {
|
|
3
|
+
const c = globalThis.crypto;
|
|
4
|
+
if (c?.randomUUID)
|
|
5
|
+
return c.randomUUID();
|
|
6
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
|
|
7
|
+
const r = (Math.random() * 16) | 0;
|
|
8
|
+
return (ch === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
|
9
|
+
});
|
|
10
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Props, RevenueInput, ShieldInput, ShieldResult, Verdict } from './types.js';
|
|
2
|
+
/** Request context for an event payload. The platform builds it (from `location`, or defaults). */
|
|
3
|
+
export interface IngestContext {
|
|
4
|
+
host: string | null;
|
|
5
|
+
/** Path plus query string. */
|
|
6
|
+
path: string;
|
|
7
|
+
referrer: string | null;
|
|
8
|
+
screen: string | null;
|
|
9
|
+
utm: {
|
|
10
|
+
source: string | null;
|
|
11
|
+
medium: string | null;
|
|
12
|
+
campaign: string | null;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/** What each platform plugs into the shared ingest core. */
|
|
16
|
+
export interface IngestPlatform {
|
|
17
|
+
publicKey: string;
|
|
18
|
+
endpoint: string;
|
|
19
|
+
timeoutMs: number;
|
|
20
|
+
/** Queue events and flush in batches (browser, RN). When false, deliver immediately (server). */
|
|
21
|
+
batch: boolean;
|
|
22
|
+
flushIntervalMs: number;
|
|
23
|
+
maxQueueSize: number;
|
|
24
|
+
visitorId(): string | null;
|
|
25
|
+
sessionId(): string | null;
|
|
26
|
+
/** Drop visitor + session ids so the next event starts a fresh identity. */
|
|
27
|
+
resetIds(): void;
|
|
28
|
+
context(pathOverride?: string): IngestContext;
|
|
29
|
+
/** True when tracking should be silently skipped (bot, localhost, iframe, disabled). */
|
|
30
|
+
disabled(): boolean;
|
|
31
|
+
/** Deliver one JSON payload to `url` (sendBeacon in the browser, fetch elsewhere). */
|
|
32
|
+
deliver(url: string, body: object): void;
|
|
33
|
+
/** Optional: wire periodic + final flush (browser: interval + pagehide). */
|
|
34
|
+
scheduleFlush?(flush: () => void): void;
|
|
35
|
+
}
|
|
36
|
+
export interface Ingest {
|
|
37
|
+
/** Record a pageview. Pass a path to override the current one (SPAs). */
|
|
38
|
+
view(path?: string): void;
|
|
39
|
+
/** Record a custom event. */
|
|
40
|
+
event(name: string, props?: Props): void;
|
|
41
|
+
/**
|
|
42
|
+
* Attach customer info to the current visitor. Pass one object — `userId`, `name`, `email`, and any
|
|
43
|
+
* custom fields — matching the snippet's `noir('identify', {…})`. The legacy `(userId, traits)` form
|
|
44
|
+
* still works.
|
|
45
|
+
*/
|
|
46
|
+
identify(user: string | (Props & {
|
|
47
|
+
userId?: string;
|
|
48
|
+
name?: string;
|
|
49
|
+
email?: string;
|
|
50
|
+
}), traits?: Props): void;
|
|
51
|
+
/** Capture revenue by checkout id (the public, no-amount path). */
|
|
52
|
+
revenue(input: Extract<RevenueInput, {
|
|
53
|
+
checkoutId: string;
|
|
54
|
+
}>): void;
|
|
55
|
+
/** Forget the current visitor (e.g. on logout); the next event starts fresh. */
|
|
56
|
+
reset(): void;
|
|
57
|
+
/** Send everything queued right now. */
|
|
58
|
+
flush(): void;
|
|
59
|
+
/**
|
|
60
|
+
* Presence heartbeat: mark the current visitor as live right now. The realtime count is a
|
|
61
|
+
* 5-minute window, so a browser client pings on a timer (while visible) to stay in it. This
|
|
62
|
+
* hits a presence-only endpoint — no event row, no event-quota cost.
|
|
63
|
+
*/
|
|
64
|
+
ping(): void;
|
|
65
|
+
/** Cross-domain link helpers. */
|
|
66
|
+
links: {
|
|
67
|
+
params(): {
|
|
68
|
+
noir_vid: string;
|
|
69
|
+
noir_sid: string;
|
|
70
|
+
};
|
|
71
|
+
decorate(url: string): string;
|
|
72
|
+
};
|
|
73
|
+
/** Spam/bot check for a form, with the public key. */
|
|
74
|
+
shield(input: ShieldInput): Promise<ShieldResult>;
|
|
75
|
+
/** Soft firewall check for the current request; returns the verdict (or null on error). */
|
|
76
|
+
check(path?: string): Promise<Verdict | null>;
|
|
77
|
+
}
|
|
78
|
+
export declare function createIngest(platform: IngestPlatform): Ingest;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public-key ingest API: analytics that is safe to run anywhere (browser, React Native, or
|
|
3
|
+
* server). Shared by every entry. The platform seam supplies what differs per environment:
|
|
4
|
+
* request context, visitor/session storage, and how a payload is delivered.
|
|
5
|
+
*/
|
|
6
|
+
import { postJson } from './http.js';
|
|
7
|
+
export function createIngest(platform) {
|
|
8
|
+
const { endpoint, publicKey, timeoutMs } = platform;
|
|
9
|
+
const eventUrl = `${endpoint}/api/v1/event`;
|
|
10
|
+
const queue = [];
|
|
11
|
+
let timer = null;
|
|
12
|
+
function payload(event, pathOverride, meta) {
|
|
13
|
+
const ctx = platform.context(pathOverride);
|
|
14
|
+
const base = {
|
|
15
|
+
site_key: publicKey,
|
|
16
|
+
host: ctx.host,
|
|
17
|
+
path: ctx.path,
|
|
18
|
+
referrer: ctx.referrer,
|
|
19
|
+
screen: ctx.screen,
|
|
20
|
+
visitor_id: platform.visitorId(),
|
|
21
|
+
session_id: platform.sessionId(),
|
|
22
|
+
utm: ctx.utm,
|
|
23
|
+
event,
|
|
24
|
+
};
|
|
25
|
+
if (meta)
|
|
26
|
+
base.meta = meta;
|
|
27
|
+
return base;
|
|
28
|
+
}
|
|
29
|
+
function flush() {
|
|
30
|
+
if (timer) {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
timer = null;
|
|
33
|
+
}
|
|
34
|
+
const batch = queue.splice(0);
|
|
35
|
+
for (const body of batch)
|
|
36
|
+
platform.deliver(eventUrl, body);
|
|
37
|
+
}
|
|
38
|
+
function enqueue(body) {
|
|
39
|
+
if (platform.disabled())
|
|
40
|
+
return;
|
|
41
|
+
if (!platform.batch) {
|
|
42
|
+
platform.deliver(eventUrl, body);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
queue.push(body);
|
|
46
|
+
if (queue.length >= platform.maxQueueSize) {
|
|
47
|
+
flush();
|
|
48
|
+
}
|
|
49
|
+
else if (!timer) {
|
|
50
|
+
timer = setTimeout(flush, platform.flushIntervalMs);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (platform.batch)
|
|
54
|
+
platform.scheduleFlush?.(flush);
|
|
55
|
+
function view(path) {
|
|
56
|
+
enqueue(payload('pageview', path));
|
|
57
|
+
}
|
|
58
|
+
function event(name, props) {
|
|
59
|
+
enqueue(payload(name, undefined, props));
|
|
60
|
+
}
|
|
61
|
+
function identify(user, traits = {}) {
|
|
62
|
+
// One object — { userId, name, email, …custom } — matches the snippet. Legacy (userId, traits)
|
|
63
|
+
// is normalised to the same shape so both forms send an identical `userId` field.
|
|
64
|
+
const meta = typeof user === 'string' ? { ...traits, userId: user } : { ...user };
|
|
65
|
+
enqueue(payload('$identify', undefined, meta));
|
|
66
|
+
}
|
|
67
|
+
function revenue(input) {
|
|
68
|
+
if (platform.disabled())
|
|
69
|
+
return;
|
|
70
|
+
const provider = input.provider ?? 'stripe';
|
|
71
|
+
platform.deliver(`${endpoint}/api/v1/payment/capture`, {
|
|
72
|
+
site_key: publicKey,
|
|
73
|
+
provider,
|
|
74
|
+
external_id: input.checkoutId,
|
|
75
|
+
visitor_id: input.visitorId ?? platform.visitorId(),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
function reset() {
|
|
79
|
+
platform.resetIds();
|
|
80
|
+
}
|
|
81
|
+
function ping() {
|
|
82
|
+
if (platform.disabled())
|
|
83
|
+
return;
|
|
84
|
+
// Presence-only: deliver immediately (never queued), so "visitors now" updates without a flush.
|
|
85
|
+
platform.deliver(`${endpoint}/api/v1/ping`, { site_key: publicKey, visitor_id: platform.visitorId() });
|
|
86
|
+
}
|
|
87
|
+
const links = {
|
|
88
|
+
params() {
|
|
89
|
+
return { noir_vid: platform.visitorId() ?? '', noir_sid: platform.sessionId() ?? '' };
|
|
90
|
+
},
|
|
91
|
+
decorate(url) {
|
|
92
|
+
const { noir_vid, noir_sid } = links.params();
|
|
93
|
+
if (!noir_vid && !noir_sid)
|
|
94
|
+
return url;
|
|
95
|
+
try {
|
|
96
|
+
const u = new URL(url);
|
|
97
|
+
if (noir_vid)
|
|
98
|
+
u.searchParams.set('noir_vid', noir_vid);
|
|
99
|
+
if (noir_sid)
|
|
100
|
+
u.searchParams.set('noir_sid', noir_sid);
|
|
101
|
+
return u.toString();
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return url;
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
function shield(input) {
|
|
109
|
+
return postJson(`${endpoint}/api/v1/form-shield`, { site_key: publicKey, ...input }, {}, Math.max(timeoutMs, 1500)).then((data) => (data ? { ok: data.ok !== false, reason: data.reason ?? null } : { ok: true, reason: null }));
|
|
110
|
+
}
|
|
111
|
+
function check(path) {
|
|
112
|
+
// /api/v1/check responds with { block, reason, blocked_page } — not the { action } shape that
|
|
113
|
+
// /decide uses — so normalise it into a Verdict before returning.
|
|
114
|
+
return postJson(`${endpoint}/api/v1/check`, payload('pageview', path), {}, timeoutMs).then((r) => r ? { action: r.block ? 'block' : 'allow', reason: r.reason ?? null, blocked_page: r.blocked_page ?? null, ttl: 0 } : null);
|
|
115
|
+
}
|
|
116
|
+
return { view, event, identify, revenue, reset, flush, ping, links, shield, check };
|
|
117
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { DecideInput, GoalInput, IdentifyInput, ShieldInput, ShieldResult, Verdict } from './types.js';
|
|
2
|
+
export interface SecretConfig {
|
|
3
|
+
secretKey: string;
|
|
4
|
+
endpoint: string;
|
|
5
|
+
timeoutMs: number;
|
|
6
|
+
/** Cap how long a per-IP verdict is cached, ms. Default 60000. */
|
|
7
|
+
cacheTtlMs?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface SecretApi {
|
|
10
|
+
decide(input: DecideInput): Promise<Verdict | null>;
|
|
11
|
+
goal(name: string, options?: Omit<GoalInput, 'name'>): Promise<boolean>;
|
|
12
|
+
/** Record revenue/lifecycle with an explicit amount or status (secret-key path of `revenue`). */
|
|
13
|
+
payment(input: {
|
|
14
|
+
amount?: number;
|
|
15
|
+
currency?: string;
|
|
16
|
+
visitorId?: string;
|
|
17
|
+
event?: string;
|
|
18
|
+
status?: 'paid' | 'refunded' | 'cancelled' | 'subscription_ended';
|
|
19
|
+
refund?: boolean;
|
|
20
|
+
}): Promise<boolean>;
|
|
21
|
+
identify(input: IdentifyInput & {
|
|
22
|
+
visitorId: string;
|
|
23
|
+
}): Promise<boolean>;
|
|
24
|
+
shield(input: ShieldInput): Promise<ShieldResult>;
|
|
25
|
+
}
|
|
26
|
+
export declare function createSecretApi(config: SecretConfig): SecretApi;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The secret-key API: trusted server-to-server methods. Authenticated with the secret key
|
|
3
|
+
* (Bearer). Every method fails open. Used by the server client and the firewall adapters.
|
|
4
|
+
*/
|
|
5
|
+
import { bearer, postJson, postOk } from './http.js';
|
|
6
|
+
export function createSecretApi(config) {
|
|
7
|
+
const { secretKey, endpoint } = config;
|
|
8
|
+
const timeoutMs = config.timeoutMs;
|
|
9
|
+
const cacheTtlMs = config.cacheTtlMs ?? 60_000;
|
|
10
|
+
const head = bearer(secretKey);
|
|
11
|
+
// Per-IP+UA verdict cache so most requests make no network call.
|
|
12
|
+
const cache = new Map();
|
|
13
|
+
async function decide(input) {
|
|
14
|
+
// Recording analytics means every hit must reach the server, so skip the verdict cache and
|
|
15
|
+
// send the full pageview context (host/referrer/visitor) alongside the decision inputs.
|
|
16
|
+
if (input.record) {
|
|
17
|
+
return postJson(`${endpoint}/api/v1/decide`, { ip: input.ip, ua: input.ua, path: input.path, host: input.host, referrer: input.referrer, visitor_id: input.visitorId, record: true }, head, timeoutMs);
|
|
18
|
+
}
|
|
19
|
+
const key = `${input.ip ?? ''}|${input.ua ?? ''}`;
|
|
20
|
+
const now = Date.now();
|
|
21
|
+
const hit = cache.get(key);
|
|
22
|
+
if (hit && hit.expires > now)
|
|
23
|
+
return hit.verdict;
|
|
24
|
+
const verdict = await postJson(`${endpoint}/api/v1/decide`, { ip: input.ip, ua: input.ua, path: input.path }, head, timeoutMs);
|
|
25
|
+
if (verdict)
|
|
26
|
+
cache.set(key, { verdict, expires: now + Math.min(cacheTtlMs, (verdict.ttl ?? 60) * 1000) });
|
|
27
|
+
return verdict;
|
|
28
|
+
}
|
|
29
|
+
function goal(name, options = {}) {
|
|
30
|
+
return postOk(`${endpoint}/api/v1/goals`, { name, visitor_id: options.visitorId, path: options.path, metadata: options.metadata }, head, timeoutMs);
|
|
31
|
+
}
|
|
32
|
+
function payment(input) {
|
|
33
|
+
return postOk(`${endpoint}/api/v1/payment`, {
|
|
34
|
+
amount: input.amount,
|
|
35
|
+
currency: input.currency,
|
|
36
|
+
visitor_id: input.visitorId,
|
|
37
|
+
event: input.event,
|
|
38
|
+
status: input.status,
|
|
39
|
+
refund: input.refund, // legacy; the server normalises it to status:refunded
|
|
40
|
+
}, head, timeoutMs);
|
|
41
|
+
}
|
|
42
|
+
function identify(input) {
|
|
43
|
+
// Fold the convenience fields into attributes so userId/email land as the same `userId`/`email`
|
|
44
|
+
// keys the snippet and browser SDK send — one consistent profile shape across every surface.
|
|
45
|
+
const attributes = { ...input.attributes };
|
|
46
|
+
if (input.userId !== undefined)
|
|
47
|
+
attributes.userId = input.userId;
|
|
48
|
+
if (input.email !== undefined)
|
|
49
|
+
attributes.email = input.email;
|
|
50
|
+
return postOk(`${endpoint}/api/v1/identify`, { visitor_id: input.visitorId, name: input.name, attributes }, head, timeoutMs);
|
|
51
|
+
}
|
|
52
|
+
async function shield(input) {
|
|
53
|
+
// Form Shield wants a bit longer than a firewall decision; never below 1500ms.
|
|
54
|
+
const data = await postJson(`${endpoint}/api/v1/form-shield`, input, head, Math.max(timeoutMs, 1500));
|
|
55
|
+
if (!data)
|
|
56
|
+
return { ok: true, reason: null }; // fail open
|
|
57
|
+
return { ok: data.ok !== false, reason: data.reason ?? null };
|
|
58
|
+
}
|
|
59
|
+
return { decide, goal, payment, identify, shield };
|
|
60
|
+
}
|