@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.
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Shared types for every NoirTrack SDK entry. Kept platform-neutral so the web, React Native,
3
+ * and server clients all speak the same shapes.
4
+ */
5
+ /** Event/identify metadata. Flat record of primitives, like the ingest API accepts. */
6
+ export type Props = Record<string, string | number | boolean>;
7
+ /** A firewall verdict from the decision engine. */
8
+ export interface Verdict {
9
+ action: 'allow' | 'block' | 'challenge';
10
+ reason: string | null;
11
+ blocked_page: string | null;
12
+ ttl: number;
13
+ }
14
+ /** Input to a hard firewall decision (server, secret key). */
15
+ export interface DecideInput {
16
+ ip?: string;
17
+ ua?: string;
18
+ path?: string;
19
+ /** The page's host (for the analytics pageview), when recording. */
20
+ host?: string;
21
+ /** Referrer header, when recording. */
22
+ referrer?: string | null;
23
+ /** Visitor id from the `noir_vid` cookie, when recording. */
24
+ visitorId?: string;
25
+ /**
26
+ * Also record this hit as an analytics pageview (stamped with the verdict), so a hard-block
27
+ * install needs no separate snippet. Bypasses the verdict cache (one call per hit). Default off
28
+ * on `decide()`; the framework guards turn it on unless `analytics: false`.
29
+ */
30
+ record?: boolean;
31
+ }
32
+ /** A server-side goal (secret key). */
33
+ export interface GoalInput {
34
+ /** Goal name: lowercase `a-z 0-9 _ - :`, up to 64 chars (the server lowercases it). */
35
+ name: string;
36
+ /** The visitor's id, read from your `noir_vid` cookie on the server. Anonymous if omitted. */
37
+ visitorId?: string;
38
+ /** Path the goal happened on. Defaults to `/` server-side. */
39
+ path?: string;
40
+ /** Optional context, up to 10 keys. */
41
+ metadata?: Props;
42
+ }
43
+ /**
44
+ * Revenue. Two safe shapes, auto-routed by the client:
45
+ * - `{ checkoutId, provider }` captures by checkout id; the server reads the amount from the
46
+ * provider, so it works with the public key and can't be forged from the browser.
47
+ * - `{ amount, currency? }` records an explicit amount; the server can't verify it, so it
48
+ * requires the secret key (server only). `refund: true` subtracts from revenue.
49
+ */
50
+ export type RevenueInput = {
51
+ checkoutId: string;
52
+ provider?: 'stripe' | 'polar' | 'lemonsqueezy';
53
+ visitorId?: string;
54
+ } | {
55
+ /** Required for `paid`/`refunded`; omit for `cancelled`/`subscription_ended` (no money moves). */
56
+ amount?: number;
57
+ currency?: string;
58
+ visitorId?: string;
59
+ event?: string;
60
+ /** Lifecycle status. Default `paid`. */
61
+ status?: 'paid' | 'refunded' | 'cancelled' | 'subscription_ended';
62
+ /** @deprecated Legacy alias for `status: 'refunded'`. */
63
+ refund?: boolean;
64
+ };
65
+ /** Attach customer info to a visitor: your user id, name, email, and any custom attributes. */
66
+ export interface IdentifyInput {
67
+ visitorId?: string;
68
+ /** Your app's user id — stored as a `userId` attribute, the same field the snippet/browser send. */
69
+ userId?: string;
70
+ name?: string;
71
+ email?: string;
72
+ /** Any extra custom fields to attach to the visitor's profile. */
73
+ attributes?: Props;
74
+ }
75
+ /** A form-shield check. */
76
+ export interface ShieldInput {
77
+ /** Submitted email (for the disposable-domain check). */
78
+ email?: string;
79
+ /** Value of the hidden `_noir_hp` honeypot field; any value means bot. */
80
+ honeypot?: string;
81
+ /** The submitter's IP. Pass it so checks run on the visitor, not your server. */
82
+ ip?: string;
83
+ /** The submitter's user-agent. */
84
+ ua?: string;
85
+ /** The form's path, for the report. */
86
+ path?: string;
87
+ }
88
+ export interface ShieldResult {
89
+ ok: boolean;
90
+ reason: string | null;
91
+ }
92
+ /** Block-handling shared by the firewall adapters. */
93
+ export interface BlockOptions {
94
+ /** What to do on a block verdict. Default 'block' (return an HTTP error). */
95
+ onBlock?: 'redirect' | 'rewrite' | 'block';
96
+ /** Page to redirect/rewrite to (falls back to the verdict's blocked_page). */
97
+ blockedPage?: string;
98
+ /** HTTP status for the 'block' action. Default 403. */
99
+ blockStatus?: number;
100
+ /**
101
+ * Record each request as an analytics pageview (stamped with the verdict) in the same call —
102
+ * so a hard-block install populates the dashboard with no separate snippet. Default true. Set
103
+ * false for a pure firewall (cached decisions, no analytics) when you track analytics elsewhere.
104
+ */
105
+ analytics?: boolean;
106
+ }
107
+ /** Tuning shared by every client. */
108
+ export interface ClientTuning {
109
+ /** NoirTrack base URL. Self-hosted only; defaults to the hosted service. */
110
+ endpoint?: string;
111
+ /** Abort a network call after this many ms, then fail open. Default 800. */
112
+ timeoutMs?: number;
113
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Shared types for every NoirTrack SDK entry. Kept platform-neutral so the web, React Native,
3
+ * and server clients all speak the same shapes.
4
+ */
5
+ export {};
@@ -0,0 +1,15 @@
1
+ /** @noirtrack/sdk/express — firewall middleware for Express / Connect-style apps. */
2
+ import type { RequestHandler } from 'express';
3
+ import type { BlockOptions } from './core/types.js';
4
+ import { type GuardTarget } from './index.js';
5
+ /**
6
+ * import { createClient } from '@noirtrack/sdk';
7
+ * import { guard } from '@noirtrack/sdk/express';
8
+ *
9
+ * const noir = createClient({ publicKey: '...', secretKey: process.env.NOIRTRACK_SECRET! });
10
+ * app.use(guard(noir, { onBlock: 'redirect', blockedPage: '/blocked' }));
11
+ *
12
+ * Or pass options: `guard({ publicKey, secretKey, onBlock: 'block' })`.
13
+ * Fails open: a NoirTrack error/timeout calls next() and never breaks your app.
14
+ */
15
+ export declare function guard(target: GuardTarget, blockOptions?: BlockOptions): RequestHandler;
@@ -0,0 +1,52 @@
1
+ import { clientIp } from './core/client-ip.js';
2
+ import { isBlockedPagePath } from './core/endpoint.js';
3
+ import { resolveGuard } from './index.js';
4
+ /**
5
+ * import { createClient } from '@noirtrack/sdk';
6
+ * import { guard } from '@noirtrack/sdk/express';
7
+ *
8
+ * const noir = createClient({ publicKey: '...', secretKey: process.env.NOIRTRACK_SECRET! });
9
+ * app.use(guard(noir, { onBlock: 'redirect', blockedPage: '/blocked' }));
10
+ *
11
+ * Or pass options: `guard({ publicKey, secretKey, onBlock: 'block' })`.
12
+ * Fails open: a NoirTrack error/timeout calls next() and never breaks your app.
13
+ */
14
+ export function guard(target, blockOptions) {
15
+ const { client, block } = resolveGuard(target, blockOptions);
16
+ return async function (request, response, next) {
17
+ const ip = clientIp((name) => {
18
+ const value = request.headers[name];
19
+ return Array.isArray(value) ? value[0] : value;
20
+ }, request.socket.remoteAddress);
21
+ const ua = request.headers['user-agent'] ?? '';
22
+ const path = request.path;
23
+ // Record the hit as an analytics pageview in the same call (default), so the firewall
24
+ // doubles as analytics with no separate snippet. Set `analytics: false` for a pure filter.
25
+ const record = block.analytics !== false;
26
+ const cookie = (name) => request.headers.cookie?.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`))?.[1];
27
+ const verdict = await client.decide({
28
+ ip,
29
+ ua,
30
+ path: record ? (request.originalUrl ?? path) : path,
31
+ host: record ? request.headers.host : undefined,
32
+ referrer: record ? (request.headers.referer ?? null) : undefined,
33
+ visitorId: record ? cookie('noir_vid') : undefined,
34
+ record,
35
+ });
36
+ if (verdict?.action !== 'block') {
37
+ next();
38
+ return;
39
+ }
40
+ const page = verdict.blocked_page ?? block.blockedPage;
41
+ if ((block.onBlock ?? 'block') === 'redirect' && page) {
42
+ // Don't redirect the blocked page to itself — that loops forever.
43
+ if (isBlockedPagePath(path, page)) {
44
+ next();
45
+ return;
46
+ }
47
+ response.redirect(page);
48
+ return;
49
+ }
50
+ response.status(block.blockStatus ?? 403).send('Forbidden');
51
+ };
52
+ }
@@ -0,0 +1,16 @@
1
+ import type { BlockOptions } from './core/types.js';
2
+ import { type GuardTarget } from './index.js';
3
+ /**
4
+ * Returns a guard that, given a standard Request, resolves to a blocking Response, or null when
5
+ * the request should continue.
6
+ *
7
+ * import { createClient } from '@noirtrack/sdk';
8
+ * import { createGuard } from '@noirtrack/sdk/fetch';
9
+ *
10
+ * const noir = createClient({ publicKey: '...', secretKey: env.NOIRTRACK_SECRET });
11
+ * const guard = createGuard(noir, { onBlock: 'redirect', blockedPage: '/blocked' });
12
+ * export default { async fetch(req) { return (await guard(req)) ?? fetch(req); } };
13
+ *
14
+ * Fails open: a NoirTrack error/timeout returns null (let the request through).
15
+ */
16
+ export declare function createGuard(target: GuardTarget, blockOptions?: BlockOptions): (request: Request) => Promise<Response | null>;
package/dist/fetch.js ADDED
@@ -0,0 +1,48 @@
1
+ /** @noirtrack/sdk/fetch — guard for standard Request/Response runtimes (Hono, Cloudflare Workers, Deno). */
2
+ import { clientIp } from './core/client-ip.js';
3
+ import { isBlockedPagePath } from './core/endpoint.js';
4
+ import { resolveGuard } from './index.js';
5
+ /**
6
+ * Returns a guard that, given a standard Request, resolves to a blocking Response, or null when
7
+ * the request should continue.
8
+ *
9
+ * import { createClient } from '@noirtrack/sdk';
10
+ * import { createGuard } from '@noirtrack/sdk/fetch';
11
+ *
12
+ * const noir = createClient({ publicKey: '...', secretKey: env.NOIRTRACK_SECRET });
13
+ * const guard = createGuard(noir, { onBlock: 'redirect', blockedPage: '/blocked' });
14
+ * export default { async fetch(req) { return (await guard(req)) ?? fetch(req); } };
15
+ *
16
+ * Fails open: a NoirTrack error/timeout returns null (let the request through).
17
+ */
18
+ export function createGuard(target, blockOptions) {
19
+ const { client, block } = resolveGuard(target, blockOptions);
20
+ return async function guard(request) {
21
+ const url = new URL(request.url);
22
+ const ip = clientIp((name) => request.headers.get(name));
23
+ const ua = request.headers.get('user-agent') ?? '';
24
+ const path = url.pathname;
25
+ // Record the hit as an analytics pageview in the same call (default), so the firewall
26
+ // doubles as analytics with no separate snippet. Set `analytics: false` for a pure filter.
27
+ const record = block.analytics !== false;
28
+ const verdict = await client.decide({
29
+ ip,
30
+ ua,
31
+ path: record ? path + url.search : path,
32
+ host: record ? (request.headers.get('host') ?? url.host) : undefined,
33
+ referrer: record ? request.headers.get('referer') : undefined,
34
+ visitorId: record ? request.headers.get('cookie')?.match(/(?:^|;\s*)noir_vid=([^;]+)/)?.[1] : undefined,
35
+ record,
36
+ });
37
+ if (verdict?.action !== 'block')
38
+ return null;
39
+ const page = verdict.blocked_page ?? block.blockedPage;
40
+ if ((block.onBlock ?? 'block') === 'redirect' && page) {
41
+ // Already on the blocked page → let it through, don't loop.
42
+ if (isBlockedPagePath(path, page))
43
+ return null;
44
+ return Response.redirect(new URL(page, request.url), 302);
45
+ }
46
+ return new Response('Forbidden', { status: block.blockStatus ?? 403 });
47
+ };
48
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @noirtrack/sdk/form-shield — spam/bot check for form submissions.
3
+ *
4
+ * Authenticated by your SECRET key (sk_live_…), like the rest of the SDK. Call it from your
5
+ * form's POST handler and reject when `ok` is false. Email/phone are checked in-request on
6
+ * NoirTrack's side and never stored.
7
+ *
8
+ * import { createFormShield } from '@noirtrack/sdk/form-shield';
9
+ * const formShield = createFormShield({ apiKey: process.env.NOIRTRACK_API_KEY! });
10
+ * const { ok, reason } = await formShield.check({ email, honeypot, ip, ua });
11
+ * if (!ok) return res.status(422).json({ error: 'blocked', reason });
12
+ */
13
+ export interface FormShieldOptions {
14
+ /** Secret API key (sk_live_…). Keep it server-side. */
15
+ apiKey: string;
16
+ /** NoirTrack base URL. Defaults to the NOIRTRACK_ENDPOINT env var, then DEFAULT_ENDPOINT. */
17
+ endpoint?: string;
18
+ /** Abort the check after this many ms, then fail open. Default 1500. */
19
+ timeoutMs?: number;
20
+ }
21
+ export interface FormShieldInput {
22
+ /** Submitted email (for the disposable-domain check). */
23
+ email?: string;
24
+ /** Value of the hidden honeypot field (`_noir_hp`); any value = bot. */
25
+ honeypot?: string;
26
+ /** The submitter's IP — pass it server-side so checks run on the visitor, not your server. */
27
+ ip?: string;
28
+ /** The submitter's user-agent. */
29
+ ua?: string;
30
+ /** The form's path, for the report. */
31
+ path?: string;
32
+ }
33
+ export interface FormShieldResult {
34
+ ok: boolean;
35
+ reason: string | null;
36
+ }
37
+ export declare function createFormShield(options: FormShieldOptions): {
38
+ check: (input: FormShieldInput) => Promise<FormShieldResult>;
39
+ };
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @noirtrack/sdk/form-shield — spam/bot check for form submissions.
3
+ *
4
+ * Authenticated by your SECRET key (sk_live_…), like the rest of the SDK. Call it from your
5
+ * form's POST handler and reject when `ok` is false. Email/phone are checked in-request on
6
+ * NoirTrack's side and never stored.
7
+ *
8
+ * import { createFormShield } from '@noirtrack/sdk/form-shield';
9
+ * const formShield = createFormShield({ apiKey: process.env.NOIRTRACK_API_KEY! });
10
+ * const { ok, reason } = await formShield.check({ email, honeypot, ip, ua });
11
+ * if (!ok) return res.status(422).json({ error: 'blocked', reason });
12
+ */
13
+ import { resolveEndpoint } from './index.js';
14
+ export function createFormShield(options) {
15
+ const endpoint = resolveEndpoint(options.endpoint);
16
+ const timeoutMs = options.timeoutMs ?? 1500;
17
+ /** Returns { ok }; fails open (ok:true) on timeout/error so a NoirTrack outage never blocks real users. */
18
+ async function check(input) {
19
+ const controller = new AbortController();
20
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
21
+ try {
22
+ const res = await fetch(`${endpoint}/api/v1/form-shield`, {
23
+ method: 'POST',
24
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${options.apiKey}` },
25
+ body: JSON.stringify(input),
26
+ signal: controller.signal,
27
+ });
28
+ if (!res.ok)
29
+ return { ok: true, reason: null }; // fail open
30
+ const data = (await res.json());
31
+ return { ok: data.ok !== false, reason: data.reason ?? null };
32
+ }
33
+ catch {
34
+ return { ok: true, reason: null };
35
+ }
36
+ finally {
37
+ clearTimeout(timer);
38
+ }
39
+ }
40
+ return { check };
41
+ }
@@ -0,0 +1,54 @@
1
+ import type { BlockOptions, DecideInput, GoalInput, IdentifyInput, Props, RevenueInput, ShieldInput, ShieldResult, Verdict } from './core/types.js';
2
+ export { DEFAULT_ENDPOINT, isBlockedPagePath, resolveEndpoint } from './core/endpoint.js';
3
+ export type { BlockOptions, ClientTuning, DecideInput, GoalInput, IdentifyInput, Props, RevenueInput, ShieldInput, ShieldResult, Verdict, } from './core/types.js';
4
+ export interface ServerClientOptions {
5
+ /**
6
+ * Public key (`pk_live_...`). Only needed for analytics ingest from this client — `view`,
7
+ * `event`, and `revenue` by checkout id. The firewall guard and every secret-key method
8
+ * (`decide`, `goal`, amount-based `revenue`, `identify`, `shield`) don't use it, so a guard-only
9
+ * setup can leave it out.
10
+ */
11
+ publicKey?: string;
12
+ /** Secret key (`sk_live_...`). Used for firewall, goals, revenue, and identify. Server-side only. */
13
+ secretKey: string;
14
+ /** NoirTrack base URL. Self-hosted only; defaults to the hosted service. */
15
+ endpoint?: string;
16
+ /** Abort a network call after this many ms, then fail open. Default 800. */
17
+ timeoutMs?: number;
18
+ /** Cap how long a per-IP verdict is cached, ms. Default 60000. */
19
+ cacheTtlMs?: number;
20
+ }
21
+ export interface ServerClient {
22
+ /** Record a pageview (rarely used server-side). */
23
+ view(path?: string, opts?: {
24
+ visitorId?: string;
25
+ }): Promise<boolean>;
26
+ /** Record a custom event. */
27
+ event(name: string, props?: Props, opts?: {
28
+ visitorId?: string;
29
+ path?: string;
30
+ }): Promise<boolean>;
31
+ /** Record revenue. `{ amount }` uses the secret key; `{ checkoutId }` captures via the provider. */
32
+ revenue(input: RevenueInput): Promise<boolean>;
33
+ /** Attach a name + attributes to a visitor. */
34
+ identify(input: IdentifyInput & {
35
+ visitorId: string;
36
+ }): Promise<boolean>;
37
+ /** A hard firewall decision. Returns the verdict, or null on timeout/error (fail open). */
38
+ decide(input: DecideInput): Promise<Verdict | null>;
39
+ /** Fire a server-side goal. */
40
+ goal(name: string, options?: Omit<GoalInput, 'name'>): Promise<boolean>;
41
+ /** Check a form submission for spam and bots. */
42
+ shield(input: ShieldInput): Promise<ShieldResult>;
43
+ }
44
+ export declare function createClient(options: ServerClientOptions): ServerClient;
45
+ /** What a firewall adapter accepts: an existing client, or options to build one. */
46
+ export type GuardTarget = ServerClient | (ServerClientOptions & BlockOptions);
47
+ /**
48
+ * Resolve a guard's `(target, block?)` arguments into a client plus its block options, so every
49
+ * adapter supports both `guard(noir, { onBlock })` and `guard({ publicKey, secretKey, onBlock })`.
50
+ */
51
+ export declare function resolveGuard(target: GuardTarget, block?: BlockOptions): {
52
+ client: ServerClient;
53
+ block: BlockOptions;
54
+ };
package/dist/index.js ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @noirtrack/sdk — server entry (Node, edge, Deno, Bun).
3
+ *
4
+ * `createClient({ secretKey, publicKey? })` returns one client for everything your backend does.
5
+ * The secret key powers trusted operations (firewall, server goals, amount-based revenue, identify)
6
+ * and is all the firewall guard needs. The public key is optional — add it only to also send
7
+ * analytics ingest (view/event/checkout-id revenue) from this client. The client routes each call
8
+ * to the right key. Browser and React Native have their own entries (`@noirtrack/sdk/web`,
9
+ * `@noirtrack/sdk/react-native`) that take the public key only.
10
+ */
11
+ import { resolveEndpoint } from './core/endpoint.js';
12
+ import { postOk } from './core/http.js';
13
+ import { createSecretApi } from './core/secret.js';
14
+ export { DEFAULT_ENDPOINT, isBlockedPagePath, resolveEndpoint } from './core/endpoint.js';
15
+ export function createClient(options) {
16
+ const endpoint = resolveEndpoint(options.endpoint);
17
+ const timeoutMs = options.timeoutMs ?? 800;
18
+ const secret = createSecretApi({ secretKey: options.secretKey, endpoint, timeoutMs, cacheTtlMs: options.cacheTtlMs });
19
+ function eventBody(name, meta, opts) {
20
+ return { site_key: options.publicKey, path: opts?.path ?? '/', event: name, meta, visitor_id: opts?.visitorId };
21
+ }
22
+ function view(path, opts) {
23
+ return postOk(`${endpoint}/api/v1/event`, eventBody('pageview', undefined, { visitorId: opts?.visitorId, path }), {}, timeoutMs);
24
+ }
25
+ function event(name, props, opts) {
26
+ return postOk(`${endpoint}/api/v1/event`, eventBody(name, props, opts), {}, timeoutMs);
27
+ }
28
+ function revenue(input) {
29
+ // Only a bare `{ checkoutId }` uses the public capture path (the server reads the amount from
30
+ // the provider). Everything else — amount, status, or refund, including an amount-less
31
+ // cancellation — goes through the secret-key payment endpoint.
32
+ if (!('checkoutId' in input))
33
+ return secret.payment(input);
34
+ const provider = input.provider ?? 'stripe';
35
+ return postOk(`${endpoint}/api/v1/payment/capture`, { site_key: options.publicKey, provider, external_id: input.checkoutId, visitor_id: input.visitorId }, {}, timeoutMs);
36
+ }
37
+ return {
38
+ view,
39
+ event,
40
+ revenue,
41
+ identify: secret.identify,
42
+ decide: secret.decide,
43
+ goal: secret.goal,
44
+ shield: secret.shield,
45
+ };
46
+ }
47
+ /**
48
+ * Resolve a guard's `(target, block?)` arguments into a client plus its block options, so every
49
+ * adapter supports both `guard(noir, { onBlock })` and `guard({ publicKey, secretKey, onBlock })`.
50
+ */
51
+ export function resolveGuard(target, block = {}) {
52
+ if (typeof target.decide === 'function') {
53
+ return { client: target, block };
54
+ }
55
+ return { client: createClient(target), block: target };
56
+ }
package/dist/next.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ /** @noirtrack/sdk/next — firewall middleware for Next.js (Edge runtime). */
2
+ import { NextRequest, NextResponse } from 'next/server';
3
+ import { type GuardTarget } from './index.js';
4
+ /**
5
+ * // middleware.ts
6
+ * import { createClient } from '@noirtrack/sdk';
7
+ * import { guard } from '@noirtrack/sdk/next';
8
+ *
9
+ * const noir = createClient({ publicKey: '...', secretKey: process.env.NOIRTRACK_SECRET! });
10
+ * export const middleware = guard(noir, { onBlock: 'redirect', blockedPage: '/blocked' });
11
+ * export const config = { matcher: ['/((?!_next/|favicon.ico).*)'] };
12
+ *
13
+ * You can also pass options directly: `guard({ publicKey, secretKey, onBlock: 'redirect' })`.
14
+ * Fails open: a NoirTrack error/timeout never blocks or breaks your site.
15
+ */
16
+ export declare function guard(target: GuardTarget, blockOptions?: import('./core/types.js').BlockOptions): (request: NextRequest) => Promise<NextResponse>;
package/dist/next.js ADDED
@@ -0,0 +1,49 @@
1
+ /** @noirtrack/sdk/next — firewall middleware for Next.js (Edge runtime). */
2
+ import { NextResponse } from 'next/server';
3
+ import { clientIp } from './core/client-ip.js';
4
+ import { isBlockedPagePath } from './core/endpoint.js';
5
+ import { resolveGuard } from './index.js';
6
+ /**
7
+ * // middleware.ts
8
+ * import { createClient } from '@noirtrack/sdk';
9
+ * import { guard } from '@noirtrack/sdk/next';
10
+ *
11
+ * const noir = createClient({ publicKey: '...', secretKey: process.env.NOIRTRACK_SECRET! });
12
+ * export const middleware = guard(noir, { onBlock: 'redirect', blockedPage: '/blocked' });
13
+ * export const config = { matcher: ['/((?!_next/|favicon.ico).*)'] };
14
+ *
15
+ * You can also pass options directly: `guard({ publicKey, secretKey, onBlock: 'redirect' })`.
16
+ * Fails open: a NoirTrack error/timeout never blocks or breaks your site.
17
+ */
18
+ export function guard(target, blockOptions) {
19
+ const { client, block } = resolveGuard(target, blockOptions);
20
+ return async function middleware(request) {
21
+ const ip = clientIp((name) => request.headers.get(name));
22
+ const ua = request.headers.get('user-agent') ?? '';
23
+ const path = request.nextUrl.pathname;
24
+ // Record the hit as an analytics pageview in the same call (default), so the firewall
25
+ // doubles as analytics with no separate snippet. Set `analytics: false` for a pure filter.
26
+ const record = block.analytics !== false;
27
+ const verdict = await client.decide({
28
+ ip,
29
+ ua,
30
+ path: record ? path + request.nextUrl.search : path,
31
+ host: record ? (request.headers.get('host') ?? request.nextUrl.host) : undefined,
32
+ referrer: record ? request.headers.get('referer') : undefined,
33
+ visitorId: record ? request.cookies.get('noir_vid')?.value : undefined,
34
+ record,
35
+ });
36
+ if (verdict?.action !== 'block')
37
+ return NextResponse.next();
38
+ const mode = block.onBlock ?? 'block';
39
+ const page = verdict.blocked_page ?? block.blockedPage;
40
+ // The blocked page must stay reachable, or a still-blocked visitor loops forever
41
+ // when that page also runs this middleware.
42
+ if ((mode === 'redirect' || mode === 'rewrite') && page) {
43
+ if (isBlockedPagePath(path, page))
44
+ return NextResponse.next();
45
+ return mode === 'redirect' ? NextResponse.redirect(new URL(page, request.url)) : NextResponse.rewrite(new URL(page, request.url));
46
+ }
47
+ return new NextResponse('Forbidden', { status: block.blockStatus ?? 403 });
48
+ };
49
+ }
@@ -0,0 +1,24 @@
1
+ import { type Ingest } from './core/ingest.js';
2
+ /** Minimal async key/value store. `@react-native-async-storage/async-storage` satisfies it. */
3
+ export interface RNStorage {
4
+ getItem(key: string): Promise<string | null>;
5
+ setItem(key: string, value: string): Promise<void>;
6
+ removeItem(key: string): Promise<void>;
7
+ }
8
+ export interface RNClientOptions {
9
+ /** Public key (`pk_live_...`). */
10
+ publicKey: string;
11
+ /** Persistent store (pass AsyncStorage). Omit for an in-memory, session-only id. */
12
+ storage?: RNStorage;
13
+ /** App identifier reported as the event host (for example your bundle id). */
14
+ app?: string;
15
+ /** Send the initial view on init. Default false (RN screens are tracked manually). */
16
+ autoView?: boolean;
17
+ /** Self-hosted base URL. Defaults to the hosted service. */
18
+ endpoint?: string;
19
+ timeoutMs?: number;
20
+ flushIntervalMs?: number;
21
+ maxQueueSize?: number;
22
+ }
23
+ export type RNClient = Ingest;
24
+ export declare function createClient(options: RNClientOptions): Promise<RNClient>;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * @noirtrack/sdk/react-native — analytics for React Native and Expo apps.
3
+ *
4
+ * import AsyncStorage from '@react-native-async-storage/async-storage';
5
+ * import { createClient } from '@noirtrack/sdk/react-native';
6
+ *
7
+ * const noir = await createClient({ publicKey: 'pk_live_...', storage: AsyncStorage });
8
+ * noir.view('Home'); // a screen view
9
+ * noir.event('signup', { plan: 'pro' });
10
+ *
11
+ * Same API as the web client (public key only), with two differences: storage is async so
12
+ * `createClient` returns a Promise, and screen views are manual (call `view(screenName)` from
13
+ * your navigation listener, since RN has no shared router).
14
+ */
15
+ import { resolveEndpoint } from './core/endpoint.js';
16
+ import { uuid } from './core/ids.js';
17
+ import { createIngest } from './core/ingest.js';
18
+ export async function createClient(options) {
19
+ const endpoint = resolveEndpoint(options.endpoint);
20
+ const store = options.storage;
21
+ async function loadId(key) {
22
+ if (store) {
23
+ try {
24
+ const existing = await store.getItem(key);
25
+ if (existing)
26
+ return existing;
27
+ }
28
+ catch {
29
+ /* fall through to a fresh id */
30
+ }
31
+ }
32
+ const fresh = uuid();
33
+ if (store)
34
+ await store.setItem(key, fresh).catch(() => { });
35
+ return fresh;
36
+ }
37
+ let visitorId = await loadId('noir_vid');
38
+ let sessionId = await loadId('noir_sid');
39
+ function context(pathOverride) {
40
+ return {
41
+ host: options.app ?? null,
42
+ path: pathOverride ?? '/',
43
+ referrer: null,
44
+ screen: null,
45
+ utm: { source: null, medium: null, campaign: null },
46
+ };
47
+ }
48
+ const platform = {
49
+ publicKey: options.publicKey,
50
+ endpoint,
51
+ timeoutMs: options.timeoutMs ?? 800,
52
+ batch: true,
53
+ flushIntervalMs: options.flushIntervalMs ?? 5000,
54
+ maxQueueSize: options.maxQueueSize ?? 10,
55
+ visitorId: () => visitorId,
56
+ sessionId: () => sessionId,
57
+ resetIds() {
58
+ visitorId = uuid();
59
+ sessionId = uuid();
60
+ if (store) {
61
+ void store.setItem('noir_vid', visitorId).catch(() => { });
62
+ void store.setItem('noir_sid', sessionId).catch(() => { });
63
+ }
64
+ },
65
+ context,
66
+ disabled: () => false,
67
+ deliver(url, body) {
68
+ void fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).catch(() => { });
69
+ },
70
+ scheduleFlush(flush) {
71
+ setInterval(flush, options.flushIntervalMs ?? 5000);
72
+ },
73
+ };
74
+ const ingest = createIngest(platform);
75
+ if (options.autoView)
76
+ ingest.view();
77
+ return ingest;
78
+ }
package/dist/web.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ import { type Ingest } from './core/ingest.js';
2
+ export interface WebClientOptions {
3
+ /** Public key (`pk_live_...`). */
4
+ publicKey: string;
5
+ /** Capture the initial pageview and SPA route changes. Default true. */
6
+ autoPageviews?: boolean;
7
+ /** Fire goals from `data-noir-goal` (click) and `data-noir-scroll` (view) attributes, like the
8
+ * hosted snippet. Re-scans as a framework renders new sections. Default true. */
9
+ autoGoals?: boolean;
10
+ /** Privacy mode: no cookies, ids live in sessionStorage. No consent banner needed. */
11
+ cookieless?: boolean;
12
+ /** Track on localhost too (off by default, like the hosted script). */
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`. */
17
+ blockedPage?: string;
18
+ /** Self-hosted base URL. Defaults to the hosted service. */
19
+ endpoint?: string;
20
+ timeoutMs?: number;
21
+ /** Ms between batch flushes. Default 5000. */
22
+ flushIntervalMs?: number;
23
+ /** Max queued events before an early flush. Default 10. */
24
+ maxQueueSize?: number;
25
+ }
26
+ export interface WebClient extends Ingest {
27
+ /** Whether the client was initialised in cookieless mode. */
28
+ readonly cookieless: boolean;
29
+ }
30
+ export declare function createClient(options: WebClientOptions): WebClient;