@noirtrack/sdk 0.5.0 → 0.6.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/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
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
4
 
5
+ ## 0.6.0
6
+
7
+ ### Added
8
+
9
+ - **IndexNow key file.** With Indexing on (Site → Settings → Indexing), the `next`, `express` and `fetch` guards answer `/{key}.txt` with your site's IndexNow key, so Bing, Yandex and other search engines accept the new pages NoirTrack sends them. Nothing to configure. `createClient()` also gains `indexNowKey()`.
10
+ - **UTM content and term (web client).** `utm_content` and `utm_term` are sent with page views and events, so the dashboard's Campaigns menu breaks traffic down by them too.
11
+
5
12
  ## 0.5.0
6
13
 
7
14
  ### Changed
package/README.md CHANGED
@@ -87,6 +87,10 @@ On Cloudflare Workers, read the key from the `env` argument inside `fetch` (`cre
87
87
 
88
88
  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.
89
89
 
90
+ ### IndexNow key file
91
+
92
+ With Indexing on (Site → Settings → Indexing), the `next`, `express` and `fetch` guards also answer `/{key}.txt` with your site's IndexNow key, so Bing, Yandex and other search engines accept the new pages NoirTrack sends them. There is nothing to configure.
93
+
90
94
  ## Options
91
95
 
92
96
  | Option | Default | Where | Description |
@@ -6,5 +6,7 @@
6
6
  export declare function postJson<T>(url: string, body: unknown, headers: Record<string, string>, timeoutMs: number): Promise<T | null>;
7
7
  /** POST JSON and return whether the server accepted it (2xx). */
8
8
  export declare function postOk(url: string, body: unknown, headers: Record<string, string>, timeoutMs: number): Promise<boolean>;
9
+ /** GET JSON and return the parsed body, or null on non-2xx / timeout / error. */
10
+ export declare function getJson<T>(url: string, headers: Record<string, string>, timeoutMs: number): Promise<T | null>;
9
11
  /** Bearer header for the secret-key endpoints. */
10
12
  export declare function bearer(secretKey: string): Record<string, string>;
package/dist/core/http.js CHANGED
@@ -37,6 +37,21 @@ async function rawPost(url, body, headers, timeoutMs) {
37
37
  clearTimeout(timer);
38
38
  }
39
39
  }
40
+ /** GET JSON and return the parsed body, or null on non-2xx / timeout / error. */
41
+ export async function getJson(url, headers, timeoutMs) {
42
+ const controller = new AbortController();
43
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
44
+ try {
45
+ const res = await fetch(url, { headers, signal: controller.signal });
46
+ return res.ok ? (await res.json()) : null;
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ finally {
52
+ clearTimeout(timer);
53
+ }
54
+ }
40
55
  /** Bearer header for the secret-key endpoints. */
41
56
  export function bearer(secretKey) {
42
57
  return { Authorization: `Bearer ${secretKey}` };
@@ -0,0 +1,2 @@
1
+ /** The body for a key-file request, or null when the path is not this site's key file. */
2
+ export declare function indexNowKeyFile(pathname: string, lookup: () => Promise<string | null>): Promise<string | null>;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The IndexNow key file. Search engines confirm a site sent them URLs by fetching https://{host}/{key}.txt, so the
3
+ * server guards answer that one path with the site's key from NoirTrack (Settings → Indexing). Nothing is served while
4
+ * Indexing is off, and any other .txt file passes through untouched.
5
+ */
6
+ const KEY_FILE = /^\/([a-f0-9]{32})\.txt$/;
7
+ /** The body for a key-file request, or null when the path is not this site's key file. */
8
+ export async function indexNowKeyFile(pathname, lookup) {
9
+ const match = KEY_FILE.exec(pathname);
10
+ if (!match)
11
+ return null;
12
+ const key = await lookup();
13
+ return key !== null && key === match[1] ? key : null;
14
+ }
@@ -12,6 +12,8 @@ export interface IngestContext {
12
12
  source: string | null;
13
13
  medium: string | null;
14
14
  campaign: string | null;
15
+ content: string | null;
16
+ term: string | null;
15
17
  };
16
18
  }
17
19
  /** What each platform plugs into the shared ingest core. */
@@ -26,5 +26,7 @@ export interface SecretApi {
26
26
  visitorId: string;
27
27
  }): Promise<boolean>;
28
28
  shield(input: ShieldInput): Promise<ShieldResult>;
29
+ /** The site's IndexNow key, or null while Indexing is off. Cached for an hour. */
30
+ indexNowKey(): Promise<string | null>;
29
31
  }
30
32
  export declare function createSecretApi(config: SecretConfig): SecretApi;
@@ -2,7 +2,7 @@
2
2
  * The secret-key API: trusted server-to-server methods. Authenticated with the secret key
3
3
  * (Bearer). Every method fails open. Used by the server client and the firewall adapters.
4
4
  */
5
- import { bearer, postJson, postOk } from './http.js';
5
+ import { bearer, getJson, postJson, postOk } from './http.js';
6
6
  export function createSecretApi(config) {
7
7
  const { secretKey, endpoint } = config;
8
8
  const timeoutMs = config.timeoutMs;
@@ -60,5 +60,16 @@ export function createSecretApi(config) {
60
60
  return { ok: true, reason: null }; // fail open
61
61
  return { ok: data.ok !== false, reason: data.reason ?? null };
62
62
  }
63
- return { decide, goal, payment, identify, shield };
63
+ // Only asked for when a search engine fetches /{key}.txt, and the key almost never changes, so one answer an hour is
64
+ // plenty. A failed lookup is cached for a minute, so an outage can't turn every key-file request into a network call.
65
+ let keyCache = null;
66
+ async function indexNowKey() {
67
+ const now = Date.now();
68
+ if (keyCache && keyCache.expires > now)
69
+ return keyCache.key;
70
+ const data = await getJson(`${endpoint}/api/v1/indexnow-key`, head, Math.max(timeoutMs, 1500));
71
+ keyCache = { key: data?.key ?? null, expires: now + (data ? 3_600_000 : 60_000) };
72
+ return keyCache.key;
73
+ }
74
+ return { decide, goal, payment, identify, shield, indexNowKey };
64
75
  }
package/dist/express.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { clientIp } from './core/client-ip.js';
2
2
  import { blockMode, isBlockedPagePath } from './core/endpoint.js';
3
+ import { indexNowKeyFile } from './core/indexnow.js';
3
4
  import { resolveGuard } from './index.js';
4
5
  /**
5
6
  * import { guard } from '@noirtrack/sdk/express';
@@ -13,6 +14,12 @@ import { resolveGuard } from './index.js';
13
14
  export function guard(target, blockOptions) {
14
15
  const { client, block } = resolveGuard(target, blockOptions);
15
16
  return async function (request, response, next) {
17
+ // With Indexing on, search engines read the IndexNow key here (docs: Indexing).
18
+ const keyFile = await indexNowKeyFile(request.path, () => client.indexNowKey());
19
+ if (keyFile) {
20
+ response.type('text/plain').send(keyFile);
21
+ return;
22
+ }
16
23
  const ip = clientIp((name) => {
17
24
  const value = request.headers[name];
18
25
  return Array.isArray(value) ? value[0] : value;
package/dist/fetch.d.ts CHANGED
@@ -14,7 +14,7 @@ import { type GuardTarget } from './index.js';
14
14
  * };
15
15
  *
16
16
  * Monitor or Block, and where a blocked visitor goes, come from Firewall settings. Images, styles, scripts, fonts
17
- * and media pass straight through without a check.
17
+ * and media pass straight through without a check. With Indexing on, `/{key}.txt` answers with your IndexNow key.
18
18
  *
19
19
  * Fails open: a NoirTrack error/timeout returns null (let the request through).
20
20
  */
package/dist/fetch.js CHANGED
@@ -1,6 +1,7 @@
1
1
  /** @noirtrack/sdk/fetch — guard for standard Request/Response runtimes (Hono, Cloudflare Workers, Deno). */
2
2
  import { clientIp } from './core/client-ip.js';
3
3
  import { blockMode, isBlockedPagePath } from './core/endpoint.js';
4
+ import { indexNowKeyFile } from './core/indexnow.js';
4
5
  import { resolveGuard } from './index.js';
5
6
  // A Worker in front of a site runs for every file a page loads. Checking those would record each image and stylesheet as
6
7
  // a page view and spend a firewall check on it, so they skip the guard, like the Next.js matcher skips Next's own files. // why
@@ -19,7 +20,7 @@ const STATIC_FILE = /\.(?:css|js|mjs|map|png|jpe?g|gif|svg|webp|avif|ico|woff2?|
19
20
  * };
20
21
  *
21
22
  * Monitor or Block, and where a blocked visitor goes, come from Firewall settings. Images, styles, scripts, fonts
22
- * and media pass straight through without a check.
23
+ * and media pass straight through without a check. With Indexing on, `/{key}.txt` answers with your IndexNow key.
23
24
  *
24
25
  * Fails open: a NoirTrack error/timeout returns null (let the request through).
25
26
  */
@@ -27,6 +28,9 @@ export function createGuard(target, blockOptions) {
27
28
  const { client, block } = resolveGuard(target, blockOptions);
28
29
  return async function guard(request) {
29
30
  const url = new URL(request.url);
31
+ const keyFile = await indexNowKeyFile(url.pathname, () => client.indexNowKey());
32
+ if (keyFile)
33
+ return new Response(keyFile, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
30
34
  if (STATIC_FILE.test(url.pathname))
31
35
  return null;
32
36
  const ip = clientIp((name) => request.headers.get(name));
package/dist/index.d.ts CHANGED
@@ -40,6 +40,8 @@ export interface ServerClient {
40
40
  goal(name: string, options?: Omit<GoalInput, 'name'>): Promise<boolean>;
41
41
  /** Check a form submission for spam and bots. */
42
42
  shield(input: ShieldInput): Promise<ShieldResult>;
43
+ /** The site's IndexNow key, or null while Indexing is off. The guards use it to serve `/{key}.txt`. */
44
+ indexNowKey(): Promise<string | null>;
43
45
  }
44
46
  export declare function createClient(options: ServerClientOptions): ServerClient;
45
47
  /** What a firewall adapter accepts: an existing client, or options to build one. */
package/dist/index.js CHANGED
@@ -45,6 +45,7 @@ export function createClient(options) {
45
45
  decide: secret.decide,
46
46
  goal: secret.goal,
47
47
  shield: secret.shield,
48
+ indexNowKey: secret.indexNowKey,
48
49
  };
49
50
  }
50
51
  /**
package/dist/next.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { NextResponse } from 'next/server';
3
3
  import { clientIp } from './core/client-ip.js';
4
4
  import { blockMode, isBlockedPagePath } from './core/endpoint.js';
5
+ import { indexNowKeyFile } from './core/indexnow.js';
5
6
  import { resolveGuard } from './index.js';
6
7
  /**
7
8
  * // middleware.ts
@@ -17,6 +18,10 @@ import { resolveGuard } from './index.js';
17
18
  export function guard(target, blockOptions) {
18
19
  const { client, block } = resolveGuard(target, blockOptions);
19
20
  return async function middleware(request) {
21
+ // With Indexing on, search engines read the IndexNow key here (docs: Indexing).
22
+ const keyFile = await indexNowKeyFile(request.nextUrl.pathname, () => client.indexNowKey());
23
+ if (keyFile)
24
+ return new NextResponse(keyFile, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
20
25
  const ip = clientIp((name) => request.headers.get(name));
21
26
  const ua = request.headers.get('user-agent') ?? '';
22
27
  const path = request.nextUrl.pathname;
@@ -42,7 +42,7 @@ export async function createClient(options) {
42
42
  path: pathOverride ?? '/',
43
43
  referrer: null,
44
44
  screen: null,
45
- utm: { source: null, medium: null, campaign: null },
45
+ utm: { source: null, medium: null, campaign: null, content: null, term: null },
46
46
  };
47
47
  }
48
48
  const platform = {
package/dist/web.js CHANGED
@@ -193,6 +193,8 @@ export function createClient(options) {
193
193
  source: params.get('utm_source'),
194
194
  medium: params.get('utm_medium'),
195
195
  campaign: params.get('utm_campaign'),
196
+ content: params.get('utm_content'),
197
+ term: params.get('utm_term'),
196
198
  },
197
199
  };
198
200
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noirtrack/sdk",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
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": {