@noirtrack/sdk 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@noirtrack/sdk` are documented here. This project follows semantic versioning (while pre-1.0, a minor bump may include breaking changes).
4
+
5
+ ## 0.2.1
6
+
7
+ ### Fixed
8
+
9
+ - **Scroll goals on tall sections.** `data-noir-scroll-threshold` is the fraction of the *element* visible at once, so a section taller than the viewport could never reach it and the goal never fired. The effective threshold is now capped to what's reachable for the element's height, so a tall section fires once it has essentially filled the screen. Short elements are unaffected. The same fix ships in the hosted snippet.
10
+
11
+ ## 0.2.0
12
+
13
+ ### Changed
14
+
15
+ - **Breaking (web client):** `block` is now a boolean. Turn on the firewall with `createClient({ block: true })` instead of `block: 'redirect' | 'overlay'`. Whether it monitors or blocks, and how a block looks (redirect or overlay), are now set per site in Firewall settings, with no code change. If you passed `block: 'redirect'` or `block: 'overlay'`, change it to `block: true`.
16
+
17
+ ### Added
18
+
19
+ - `Verdict.style` (`'redirect' | 'overlay' | null`): the block style returned by the server. The web client applies it automatically, so the snippet and SDK no longer hardcode the style.
20
+
21
+ ## 0.1.0
22
+
23
+ - Initial release. Framework-agnostic core with browser, React Native, Next.js, Express, and fetch/edge adapters. Analytics, firewall decisions, goals, revenue, identify, and Form Shield, all from one client.
package/README.md CHANGED
@@ -4,7 +4,7 @@ One NoirTrack SDK for the browser, React Native, and your server. You set your k
4
4
 
5
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
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)
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
8
 
9
9
  ## Install
10
10
 
@@ -26,7 +26,7 @@ noir.identify('user_123', { email });
26
26
  noir.revenue({ checkoutId: 'cs_test_123' });
27
27
  ```
28
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)`.
29
+ Pageviews and route changes are automatic. Events are batched and sent with `sendBeacon`. Set `cookieless: true` for a no-cookie mode, or `block: true` to turn on the firewall. Whether it monitors or blocks, and how a block looks (redirect or overlay), are set in your Firewall settings, with no code change. Safe to import in SSR (it does nothing without a window). Also: `view(path?)`, `reset()`, `flush()`, `links.decorate(url)`.
30
30
 
31
31
  ## React Native
32
32
 
@@ -81,7 +81,9 @@ export const config = { matcher: ['/((?!_next/|favicon.ico).*)'] };
81
81
 
82
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
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.
84
+ **Monitor or Block is set in the dashboard.** Adding a guard turns the firewall on, but whether it blocks or just monitors is your Firewall Mode, not the code. New sites start in **Monitor** (detect and log, don't block anyone) until you switch to **Block** in Firewall settings. `onBlock` only changes how a block is delivered once you are in Block mode.
85
+
86
+ 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
87
 
86
88
  ## Options
87
89
 
@@ -93,6 +95,7 @@ The guards resolve the visitor IP from `CF-Connecting-IP`, `True-Client-IP`, the
93
95
  | `timeoutMs` | `800` | all | Abort a call after this, then fail open. |
94
96
  | `autoPageviews` | `true` | web | Capture initial + SPA pageviews. |
95
97
  | `cookieless` | `false` | web | No cookies; ids in sessionStorage. |
98
+ | `block` | `false` | web | Turn on the firewall (Monitor/Block set in dashboard). |
96
99
  | `flushIntervalMs` / `maxQueueSize` | `5000` / `10` | web, RN | Batch flush tuning. |
97
100
  | `storage` | in-memory | RN | Async store (pass AsyncStorage). |
98
101
  | `onBlock` / `blockStatus` / `blockedPage` | `block` / `403` / none | adapters | Block handling. |
@@ -111,7 +111,15 @@ export function createIngest(platform) {
111
111
  function check(path) {
112
112
  // /api/v1/check responds with { block, reason, blocked_page } — not the { action } shape that
113
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);
114
+ return postJson(`${endpoint}/api/v1/check`, payload('pageview', path), {}, timeoutMs).then((r) => r
115
+ ? {
116
+ action: r.block ? 'block' : 'allow',
117
+ reason: r.reason ?? null,
118
+ blocked_page: r.blocked_page ?? null,
119
+ style: r.style ?? null,
120
+ ttl: 0,
121
+ }
122
+ : null);
115
123
  }
116
124
  return { view, event, identify, revenue, reset, flush, ping, links, shield, check };
117
125
  }
@@ -9,6 +9,8 @@ export interface Verdict {
9
9
  action: 'allow' | 'block' | 'challenge';
10
10
  reason: string | null;
11
11
  blocked_page: string | null;
12
+ /** How to present a block (set in Firewall settings): redirect to a page or show an overlay. */
13
+ style?: 'redirect' | 'overlay' | null;
12
14
  ttl: number;
13
15
  }
14
16
  /** Input to a hard firewall decision (server, secret key). */
@@ -52,7 +54,11 @@ export type RevenueInput = {
52
54
  provider?: 'stripe' | 'polar' | 'lemonsqueezy';
53
55
  visitorId?: string;
54
56
  } | {
55
- /** Required for `paid`/`refunded`; omit for `cancelled`/`subscription_ended` (no money moves). */
57
+ /**
58
+ * Required for `paid`/`refunded`; omit for `cancelled`/`subscription_ended` (no money moves).
59
+ * The server rejects a `paid`/`refunded` call with no amount (422) and the SDK fails open
60
+ * silently — the revenue is dropped, not thrown — so always pass it for those two statuses.
61
+ */
56
62
  amount?: number;
57
63
  currency?: string;
58
64
  visitorId?: string;
package/dist/index.js CHANGED
@@ -31,6 +31,9 @@ export function createClient(options) {
31
31
  // cancellation — goes through the secret-key payment endpoint.
32
32
  if (!('checkoutId' in input))
33
33
  return secret.payment(input);
34
+ // Capture is authenticated by the PUBLIC key (`site.key` middleware). A guard-only client built
35
+ // without `publicKey` sends `site_key: undefined`, so the server rejects it and this fails open
36
+ // silently — set `publicKey` on the client if you capture revenue by checkout id.
34
37
  const provider = input.provider ?? 'stripe';
35
38
  return postOk(`${endpoint}/api/v1/payment/capture`, { site_key: options.publicKey, provider, external_id: input.checkoutId, visitor_id: input.visitorId }, {}, timeoutMs);
36
39
  }
package/dist/web.d.ts CHANGED
@@ -11,9 +11,10 @@ export interface WebClientOptions {
11
11
  cookieless?: boolean;
12
12
  /** Track on localhost too (off by default, like the hosted script). */
13
13
  allowLocalhost?: boolean;
14
- /** Soft, client-side firewall on the first view. Off by default. */
15
- block?: 'redirect' | 'overlay';
16
- /** Where to send blocked visitors when `block` is `redirect`. */
14
+ /** Turn on the firewall on the first view. Whether it monitors or blocks, and how a block looks
15
+ * (redirect or overlay), are set per site in Firewall settings. Off by default. */
16
+ block?: boolean;
17
+ /** Optional redirect target if none is set in Firewall settings. */
17
18
  blockedPage?: string;
18
19
  /** Self-hosted base URL. Defaults to the hosted service. */
19
20
  endpoint?: string;
package/dist/web.js CHANGED
@@ -70,6 +70,11 @@ function initDeclarativeGoals(fire) {
70
70
  }, true);
71
71
  if (!('IntersectionObserver' in window))
72
72
  return;
73
+ // Granular thresholds so the callback runs across the whole scroll range; the actual trigger is
74
+ // computed live per element (see below), so we can't rely on a single fixed threshold.
75
+ const STEPS = [];
76
+ for (let s = 0; s <= 1.0001; s += 0.05)
77
+ STEPS.push(Math.round(s * 100) / 100);
73
78
  const observed = new WeakSet();
74
79
  const observeScroll = (el) => {
75
80
  if (observed.has(el))
@@ -78,15 +83,25 @@ function initDeclarativeGoals(fire) {
78
83
  const name = el.getAttribute('data-noir-scroll');
79
84
  if (!name)
80
85
  return;
81
- let threshold = parseFloat(el.getAttribute('data-noir-scroll-threshold') ?? '');
82
- if (!(threshold > 0 && threshold <= 1))
83
- threshold = 0.5;
86
+ let requested = parseFloat(el.getAttribute('data-noir-scroll-threshold') ?? '');
87
+ if (!(requested > 0 && requested <= 1))
88
+ requested = 0.5;
84
89
  const delay = parseInt(el.getAttribute('data-noir-scroll-delay') ?? '', 10) || 0;
85
90
  const obs = new IntersectionObserver((entries) => {
86
91
  for (const entry of entries) {
87
- if (entry.isIntersecting && entry.intersectionRatio >= threshold) {
92
+ if (!entry.isIntersecting)
93
+ continue;
94
+ // A section taller than the viewport can never expose `requested` of itself at once —
95
+ // the most ever visible is viewportHeight/elementHeight. Compare against that reachable
96
+ // max (×0.9) so a tall element still fires once it has essentially filled the screen,
97
+ // instead of never firing. Short elements keep their requested threshold. // why
98
+ const elHeight = entry.boundingClientRect.height;
99
+ const viewport = entry.rootBounds?.height || window.innerHeight || 0;
100
+ const reachable = elHeight > 0 && viewport > 0 ? (viewport / elHeight) * 0.9 : 1;
101
+ const effective = Math.min(requested, reachable);
102
+ if (entry.intersectionRatio >= effective) {
88
103
  obs.disconnect(); // once per element per page
89
- const meta = { scroll_percentage: scrollPercent(), threshold };
104
+ const meta = { scroll_percentage: scrollPercent(), threshold: requested };
90
105
  if (delay > 0)
91
106
  setTimeout(() => fire(name, meta), delay);
92
107
  else
@@ -94,7 +109,7 @@ function initDeclarativeGoals(fire) {
94
109
  return;
95
110
  }
96
111
  }
97
- }, { threshold });
112
+ }, { threshold: STEPS });
98
113
  obs.observe(el);
99
114
  };
100
115
  const scan = () => document.querySelectorAll('[data-noir-scroll]').forEach(observeScroll);
@@ -230,7 +245,10 @@ export function createClient(options) {
230
245
  if (verdict?.action !== 'block')
231
246
  return;
232
247
  const page = verdict.blocked_page ?? options.blockedPage;
233
- if (options.block === 'redirect' && page)
248
+ // Style comes from Firewall settings (redirect or overlay); fall back to redirect when
249
+ // a page exists, else overlay.
250
+ const style = verdict.style ?? (page ? 'redirect' : 'overlay');
251
+ if (style === 'redirect' && page)
234
252
  location.replace(page);
235
253
  else
236
254
  overlay();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noirtrack/sdk",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "NoirTrack server SDK. Block bots and bad traffic before your app renders, and record goals, revenue, and identify from your backend with one secret key. Framework-agnostic core plus Next.js, Express, and fetch/edge adapters.",
5
5
  "homepage": "https://noirtrack.com/docs",
6
6
  "bugs": {
@@ -41,9 +41,10 @@
41
41
  "import": "./dist/form-shield.js"
42
42
  }
43
43
  },
44
- "files": ["dist"],
44
+ "files": ["dist", "README.md", "CHANGELOG.md"],
45
45
  "scripts": {
46
46
  "build": "tsc",
47
+ "prepublishOnly": "tsc",
47
48
  "test:live": "node ../docs/live-test.mjs"
48
49
  },
49
50
  "keywords": ["noirtrack", "firewall", "bot-detection", "waf", "revenue", "goals", "middleware", "nextjs", "express", "edge"],