@linkforty/core 1.6.6 → 1.13.6

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/README.md CHANGED
@@ -257,7 +257,7 @@ DELETE /api/webhooks/:id?userId=user-uuid
257
257
  POST /api/webhooks/:id/test?userId=user-uuid
258
258
  ```
259
259
 
260
- Events: `click_event`, `install_event`, `conversion_event`. Payloads are HMAC SHA-256 signed.
260
+ Events: `click_event`, `install_event`, `conversion_event`, `sdk_event`. Payloads are HMAC SHA-256 signed.
261
261
 
262
262
  ### Mobile SDK Endpoints
263
263
 
@@ -312,9 +312,14 @@ interface ServerOptions {
312
312
  origin: string | string[]; // CORS allowed origins (default: '*')
313
313
  };
314
314
  logger?: boolean; // Enable Fastify logger (default: true)
315
+ trustProxy?: boolean | number; // Trust X-Forwarded-For when behind a proxy (default: false)
315
316
  }
316
317
  ```
317
318
 
319
+ ### Running behind a reverse proxy
320
+
321
+ When Core runs behind a reverse proxy, CDN, or load balancer, set `trustProxy` so the server uses the real client IP from `X-Forwarded-For` for redirect targeting, geo, attribution, and fingerprinting. Pass it when creating the server (e.g. `trustProxy: true` or a number of proxy hops) or set the `TRUST_PROXY` environment variable (e.g. `TRUST_PROXY=1`). Client-provided `ipAddress` in the SDK install request body is **not** used as the trusted IP; it is optional debug metadata only and must not be relied on for attribution.
322
+
318
323
  ### Environment Variables
319
324
 
320
325
  ```bash
@@ -323,6 +328,8 @@ REDIS_URL=redis://localhost:6379
323
328
  PORT=3000
324
329
  NODE_ENV=production
325
330
  CORS_ORIGIN=*
331
+ # When behind a reverse proxy: TRUST_PROXY=1 (or number of hops) so client IP is read from X-Forwarded-For
332
+ # TRUST_PROXY=1
326
333
 
327
334
  # Mobile SDK (optional — for iOS Universal Links and Android App Links)
328
335
  IOS_TEAM_ID=ABC123XYZ
package/dist/index.d.ts CHANGED
@@ -9,9 +9,12 @@ export interface ServerOptions {
9
9
  origin: string | string[];
10
10
  };
11
11
  logger?: boolean;
12
+ /** When true or a number (proxy hop count), Fastify trusts X-Forwarded-For so request.ip is the real client IP. Set when behind a reverse proxy. */
13
+ trustProxy?: boolean | number;
12
14
  }
13
15
  export declare function createServer(options?: ServerOptions): Promise<FastifyInstance<import("http").Server<typeof import("http").IncomingMessage, typeof import("http").ServerResponse>, import("http").IncomingMessage, import("http").ServerResponse<import("http").IncomingMessage>, import("fastify").FastifyBaseLogger, import("fastify").FastifyTypeProviderDefault>>;
14
16
  export * from './lib/utils.js';
17
+ export * from './lib/client-ip.js';
15
18
  export * from './lib/database.js';
16
19
  export * from './lib/fingerprint.js';
17
20
  export * from './lib/webhook.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAgB,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAGnD,OAAO,EAAsB,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAUxE,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,EAAE,eAAe,CAAC;IAC3B,KAAK,CAAC,EAAE;QACN,GAAG,EAAE,MAAM,CAAC;KACb,CAAC;IACF,IAAI,CAAC,EAAE;QACL,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;KAC3B,CAAC;IACF,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,wBAAsB,YAAY,CAAC,OAAO,GAAE,aAAkB,kTA+B7D;AAGD,cAAc,gBAAgB,CAAC;AAC/B,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,kBAAkB,CAAC;AACjC,cAAc,wBAAwB,CAAC;AACvC,cAAc,kBAAkB,CAAC;AACjC,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,eAAe,EAAE,SAAS,EAAE,aAAa,EAAE,cAAc,EAAE,QAAQ,EAAE,aAAa,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAgB,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAGnD,OAAO,EAAsB,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAUxE,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,EAAE,eAAe,CAAC;IAC3B,KAAK,CAAC,EAAE;QACN,GAAG,EAAE,MAAM,CAAC;KACb,CAAC;IACF,IAAI,CAAC,EAAE;QACL,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;KAC3B,CAAC;IACF,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,oJAAoJ;IACpJ,UAAU,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;CAC/B;AAED,wBAAsB,YAAY,CAAC,OAAO,GAAE,aAAkB,kTAgC7D;AAGD,cAAc,gBAAgB,CAAC;AAC/B,cAAc,oBAAoB,CAAC;AACnC,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,kBAAkB,CAAC;AACjC,cAAc,wBAAwB,CAAC;AACvC,cAAc,kBAAkB,CAAC;AACjC,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,eAAe,EAAE,SAAS,EAAE,aAAa,EAAE,cAAc,EAAE,QAAQ,EAAE,aAAa,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC"}
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ import { wellKnownRoutes } from './routes/well-known.js';
13
13
  export async function createServer(options = {}) {
14
14
  const fastify = Fastify({
15
15
  logger: options.logger !== undefined ? options.logger : true,
16
+ trustProxy: options.trustProxy,
16
17
  });
17
18
  // CORS
18
19
  await fastify.register(cors, {
@@ -39,6 +40,7 @@ export async function createServer(options = {}) {
39
40
  }
40
41
  // Re-export utilities and types
41
42
  export * from './lib/utils.js';
43
+ export * from './lib/client-ip.js';
42
44
  export * from './lib/database.js';
43
45
  export * from './lib/fingerprint.js';
44
46
  export * from './lib/webhook.js';
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,OAA4B,MAAM,SAAS,CAAC;AACnD,OAAO,IAAI,MAAM,eAAe,CAAC;AACjC,OAAO,KAAK,MAAM,gBAAgB,CAAC;AACnC,OAAO,EAAE,kBAAkB,EAAmB,MAAM,mBAAmB,CAAC;AACxE,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAC/C,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACxD,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AACrD,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACvD,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAazD,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,UAAyB,EAAE;IAC5D,MAAM,OAAO,GAAG,OAAO,CAAC;QACtB,MAAM,EAAE,OAAO,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI;KAC7D,CAAC,CAAC;IAEH,OAAO;IACP,MAAM,OAAO,CAAC,QAAQ,CAAC,IAAI,EAAE;QAC3B,MAAM,EAAE,OAAO,CAAC,IAAI,EAAE,MAAM,IAAI,GAAG;KACpC,CAAC,CAAC;IAEH,mBAAmB;IACnB,IAAI,OAAO,CAAC,KAAK,EAAE,GAAG,EAAE,CAAC;QACvB,MAAM,OAAO,CAAC,QAAQ,CAAC,KAAK,EAAE;YAC5B,GAAG,EAAE,OAAO,CAAC,KAAK,CAAC,GAAG;SACvB,CAAC,CAAC;IACL,CAAC;IAED,WAAW;IACX,MAAM,kBAAkB,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;IAE3C,SAAS;IACT,MAAM,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAC,CAAC;IACxC,MAAM,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAC;IACvC,MAAM,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC;IACnC,MAAM,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAC,CAAC;IACxC,MAAM,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC;IAClC,MAAM,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAC,CAAC;IACtC,MAAM,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAC;IACvC,MAAM,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;IAEjC,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,gCAAgC;AAChC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,kBAAkB,CAAC;AACjC,cAAc,wBAAwB,CAAC;AACvC,cAAc,kBAAkB,CAAC;AACjC,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,eAAe,EAAE,SAAS,EAAE,aAAa,EAAE,cAAc,EAAE,QAAQ,EAAE,aAAa,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,OAA4B,MAAM,SAAS,CAAC;AACnD,OAAO,IAAI,MAAM,eAAe,CAAC;AACjC,OAAO,KAAK,MAAM,gBAAgB,CAAC;AACnC,OAAO,EAAE,kBAAkB,EAAmB,MAAM,mBAAmB,CAAC;AACxE,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAC/C,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACxD,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AACrD,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACvD,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAezD,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,UAAyB,EAAE;IAC5D,MAAM,OAAO,GAAG,OAAO,CAAC;QACtB,MAAM,EAAE,OAAO,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI;QAC5D,UAAU,EAAE,OAAO,CAAC,UAAU;KAC/B,CAAC,CAAC;IAEH,OAAO;IACP,MAAM,OAAO,CAAC,QAAQ,CAAC,IAAI,EAAE;QAC3B,MAAM,EAAE,OAAO,CAAC,IAAI,EAAE,MAAM,IAAI,GAAG;KACpC,CAAC,CAAC;IAEH,mBAAmB;IACnB,IAAI,OAAO,CAAC,KAAK,EAAE,GAAG,EAAE,CAAC;QACvB,MAAM,OAAO,CAAC,QAAQ,CAAC,KAAK,EAAE;YAC5B,GAAG,EAAE,OAAO,CAAC,KAAK,CAAC,GAAG;SACvB,CAAC,CAAC;IACL,CAAC;IAED,WAAW;IACX,MAAM,kBAAkB,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;IAE3C,SAAS;IACT,MAAM,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAC,CAAC;IACxC,MAAM,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAC;IACvC,MAAM,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC;IACnC,MAAM,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAC,CAAC;IACxC,MAAM,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC;IAClC,MAAM,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAC,CAAC;IACtC,MAAM,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAC;IACvC,MAAM,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;IAEjC,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,gCAAgC;AAChC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,oBAAoB,CAAC;AACnC,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,kBAAkB,CAAC;AACjC,cAAc,wBAAwB,CAAC;AACvC,cAAc,kBAAkB,CAAC;AACjC,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,eAAe,EAAE,SAAS,EAAE,aAAa,EAAE,cAAc,EAAE,QAAQ,EAAE,aAAa,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC"}
@@ -0,0 +1,10 @@
1
+ import type { FastifyRequest } from 'fastify';
2
+ /**
3
+ * Returns the trusted client IP for the request.
4
+ * Use this everywhere client IP is needed (targeting, attribution, fingerprinting).
5
+ * When the server is behind a reverse proxy, set Fastify's trustProxy option
6
+ * so that request.ip is populated from X-Forwarded-For; this helper then
7
+ * returns that trusted value.
8
+ */
9
+ export declare function getClientIp(request: FastifyRequest): string;
10
+ //# sourceMappingURL=client-ip.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client-ip.d.ts","sourceRoot":"","sources":["../../src/lib/client-ip.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE9C;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,cAAc,GAAG,MAAM,CAQ3D"}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Returns the trusted client IP for the request.
3
+ * Use this everywhere client IP is needed (targeting, attribution, fingerprinting).
4
+ * When the server is behind a reverse proxy, set Fastify's trustProxy option
5
+ * so that request.ip is populated from X-Forwarded-For; this helper then
6
+ * returns that trusted value.
7
+ */
8
+ export function getClientIp(request) {
9
+ const ip = request.ip ?? request.socket?.remoteAddress;
10
+ if (ip && typeof ip === 'string') {
11
+ // IPv6-mapped IPv4: ::ffff:192.168.1.1 -> 192.168.1.1
12
+ if (ip.startsWith('::ffff:'))
13
+ return ip.slice(7);
14
+ return ip;
15
+ }
16
+ return 'unknown';
17
+ }
18
+ //# sourceMappingURL=client-ip.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client-ip.js","sourceRoot":"","sources":["../../src/lib/client-ip.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AACH,MAAM,UAAU,WAAW,CAAC,OAAuB;IACjD,MAAM,EAAE,GAAG,OAAO,CAAC,EAAE,IAAK,OAAe,CAAC,MAAM,EAAE,aAAa,CAAC;IAChE,IAAI,EAAE,IAAI,OAAO,EAAE,KAAK,QAAQ,EAAE,CAAC;QACjC,sDAAsD;QACtD,IAAI,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC;YAAE,OAAO,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QACjD,OAAO,EAAE,CAAC;IACZ,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC"}
@@ -0,0 +1,7 @@
1
+ declare global {
2
+ var __capturedInstallFingerprint: {
3
+ ipAddress: string;
4
+ } | null;
5
+ }
6
+ export {};
7
+ //# sourceMappingURL=client-ip.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client-ip.test.d.ts","sourceRoot":"","sources":["../../src/lib/client-ip.test.ts"],"names":[],"mappings":"AAIA,OAAO,CAAC,MAAM,CAAC;IACb,IAAI,4BAA4B,EAAE;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;CAChE"}
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { getClientIp } from './client-ip';
3
+ describe('getClientIp', () => {
4
+ it('returns request.ip when set', () => {
5
+ const request = { ip: '192.168.1.1' };
6
+ expect(getClientIp(request)).toBe('192.168.1.1');
7
+ });
8
+ it('returns socket.remoteAddress when request.ip is undefined', () => {
9
+ const request = {
10
+ ip: undefined,
11
+ socket: { remoteAddress: '10.0.0.2' },
12
+ };
13
+ expect(getClientIp(request)).toBe('10.0.0.2');
14
+ });
15
+ it('unwraps IPv6-mapped IPv4', () => {
16
+ const request = { ip: '::ffff:192.168.1.1' };
17
+ expect(getClientIp(request)).toBe('192.168.1.1');
18
+ });
19
+ it('returns "unknown" when neither ip nor socket.remoteAddress is available', () => {
20
+ const request = { ip: undefined, socket: {} };
21
+ expect(getClientIp(request)).toBe('unknown');
22
+ });
23
+ it('returns "unknown" when request has no socket', () => {
24
+ const request = { ip: undefined };
25
+ expect(getClientIp(request)).toBe('unknown');
26
+ });
27
+ });
28
+ describe('getClientIp with Fastify trustProxy (proxied request)', () => {
29
+ it('uses X-Forwarded-For when trustProxy is true', async () => {
30
+ const Fastify = (await import('fastify')).default;
31
+ const { getClientIp: getIp } = await import('./client-ip.js');
32
+ const app = Fastify({ trustProxy: true });
33
+ app.get('/ip', async (request, reply) => {
34
+ return reply.send({ ip: getIp(request) });
35
+ });
36
+ const res = await app.inject({
37
+ method: 'GET',
38
+ url: '/ip',
39
+ headers: { 'x-forwarded-for': '203.0.113.50' },
40
+ });
41
+ expect(res.statusCode).toBe(200);
42
+ const body = res.json();
43
+ expect(body.ip).toBe('203.0.113.50');
44
+ });
45
+ });
46
+ vi.mock('./fingerprint.js', async (importOriginal) => {
47
+ const mod = (await importOriginal());
48
+ return {
49
+ ...mod,
50
+ recordInstallEvent: vi.fn().mockImplementation(async (data) => {
51
+ globalThis.__capturedInstallFingerprint = data;
52
+ return { installId: 'test-id', match: null, deepLinkData: null };
53
+ }),
54
+ };
55
+ });
56
+ describe('SDK install does not trust client-provided ipAddress', () => {
57
+ it('uses connection/proxy IP for attribution, not body ipAddress', async () => {
58
+ globalThis.__capturedInstallFingerprint = null;
59
+ const Fastify = (await import('fastify')).default;
60
+ const { sdkRoutes } = await import('../routes/sdk.js');
61
+ const app = Fastify({ trustProxy: true });
62
+ await app.register(sdkRoutes);
63
+ const res = await app.inject({
64
+ method: 'POST',
65
+ url: '/api/sdk/v1/install',
66
+ payload: { ipAddress: '1.2.3.4', userAgent: 'Mozilla/5.0 Test' },
67
+ headers: { 'x-forwarded-for': '203.0.113.50', 'content-type': 'application/json' },
68
+ });
69
+ expect(res.statusCode).toBe(200);
70
+ expect(globalThis.__capturedInstallFingerprint).not.toBeNull();
71
+ expect(globalThis.__capturedInstallFingerprint.ipAddress).toBe('203.0.113.50');
72
+ const body = res.json();
73
+ expect(body.clientReportedIp).toBe('1.2.3.4');
74
+ });
75
+ });
76
+ //# sourceMappingURL=client-ip.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client-ip.test.js","sourceRoot":"","sources":["../../src/lib/client-ip.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=event-emitter.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"event-emitter.test.d.ts","sourceRoot":"","sources":["../../src/lib/event-emitter.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,162 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { clickEventEmitter, emitClickEvent, subscribeToClickEvents, } from './event-emitter';
3
+ const mockClickEvent = {
4
+ eventId: 'evt-123',
5
+ timestamp: '2026-03-10T00:00:00.000Z',
6
+ linkId: 'link-abc',
7
+ shortCode: 'abc123',
8
+ userId: 'user-1',
9
+ organizationId: 'org-1',
10
+ ipAddress: '1.2.3.4',
11
+ userAgent: 'Mozilla/5.0',
12
+ country: 'US',
13
+ city: 'New York',
14
+ deviceType: 'web',
15
+ platform: 'Windows',
16
+ browser: 'Chrome',
17
+ redirectUrl: 'https://example.com',
18
+ redirectReason: 'web_fallback',
19
+ targetingMatched: true,
20
+ utmParameters: {
21
+ source: 'newsletter',
22
+ medium: 'email',
23
+ campaign: 'spring',
24
+ },
25
+ referer: 'https://google.com',
26
+ language: 'en-US',
27
+ };
28
+ describe('clickEventEmitter', () => {
29
+ it('should be an EventEmitter instance', () => {
30
+ expect(clickEventEmitter).toBeDefined();
31
+ expect(typeof clickEventEmitter.on).toBe('function');
32
+ expect(typeof clickEventEmitter.emit).toBe('function');
33
+ expect(typeof clickEventEmitter.off).toBe('function');
34
+ });
35
+ });
36
+ describe('emitClickEvent', () => {
37
+ beforeEach(() => {
38
+ clickEventEmitter.removeAllListeners('click');
39
+ });
40
+ it('should emit a click event with the provided data', () => {
41
+ const handler = vi.fn();
42
+ clickEventEmitter.on('click', handler);
43
+ emitClickEvent(mockClickEvent);
44
+ expect(handler).toHaveBeenCalledOnce();
45
+ expect(handler).toHaveBeenCalledWith(mockClickEvent);
46
+ });
47
+ it('should emit to all registered listeners', () => {
48
+ const handler1 = vi.fn();
49
+ const handler2 = vi.fn();
50
+ clickEventEmitter.on('click', handler1);
51
+ clickEventEmitter.on('click', handler2);
52
+ emitClickEvent(mockClickEvent);
53
+ expect(handler1).toHaveBeenCalledOnce();
54
+ expect(handler2).toHaveBeenCalledOnce();
55
+ });
56
+ it('should emit multiple times when called multiple times', () => {
57
+ const handler = vi.fn();
58
+ clickEventEmitter.on('click', handler);
59
+ emitClickEvent(mockClickEvent);
60
+ emitClickEvent({ ...mockClickEvent, eventId: 'evt-456' });
61
+ expect(handler).toHaveBeenCalledTimes(2);
62
+ });
63
+ it('should pass the exact event data to the handler', () => {
64
+ const handler = vi.fn();
65
+ clickEventEmitter.on('click', handler);
66
+ const eventData = {
67
+ ...mockClickEvent,
68
+ deviceType: 'ios',
69
+ country: 'CA',
70
+ targetingMatched: false,
71
+ };
72
+ emitClickEvent(eventData);
73
+ expect(handler).toHaveBeenCalledWith(eventData);
74
+ });
75
+ it('should work with minimal required fields', () => {
76
+ const handler = vi.fn();
77
+ clickEventEmitter.on('click', handler);
78
+ const minimalEvent = {
79
+ eventId: 'evt-min',
80
+ timestamp: '2026-03-10T00:00:00.000Z',
81
+ linkId: 'link-min',
82
+ shortCode: 'min',
83
+ ipAddress: '0.0.0.0',
84
+ userAgent: '',
85
+ deviceType: 'web',
86
+ redirectUrl: 'https://example.com',
87
+ redirectReason: 'default',
88
+ targetingMatched: true,
89
+ };
90
+ emitClickEvent(minimalEvent);
91
+ expect(handler).toHaveBeenCalledWith(minimalEvent);
92
+ });
93
+ });
94
+ describe('subscribeToClickEvents', () => {
95
+ beforeEach(() => {
96
+ clickEventEmitter.removeAllListeners('click');
97
+ });
98
+ it('should call the callback when a click event is emitted', () => {
99
+ const callback = vi.fn();
100
+ subscribeToClickEvents(callback);
101
+ emitClickEvent(mockClickEvent);
102
+ expect(callback).toHaveBeenCalledOnce();
103
+ expect(callback).toHaveBeenCalledWith(mockClickEvent);
104
+ });
105
+ it('should return an unsubscribe function', () => {
106
+ const callback = vi.fn();
107
+ const unsubscribe = subscribeToClickEvents(callback);
108
+ expect(typeof unsubscribe).toBe('function');
109
+ });
110
+ it('should stop receiving events after unsubscribing', () => {
111
+ const callback = vi.fn();
112
+ const unsubscribe = subscribeToClickEvents(callback);
113
+ emitClickEvent(mockClickEvent);
114
+ expect(callback).toHaveBeenCalledOnce();
115
+ unsubscribe();
116
+ emitClickEvent(mockClickEvent);
117
+ expect(callback).toHaveBeenCalledOnce(); // still only once
118
+ });
119
+ it('should allow multiple subscribers independently', () => {
120
+ const callback1 = vi.fn();
121
+ const callback2 = vi.fn();
122
+ const unsubscribe1 = subscribeToClickEvents(callback1);
123
+ subscribeToClickEvents(callback2);
124
+ emitClickEvent(mockClickEvent);
125
+ expect(callback1).toHaveBeenCalledOnce();
126
+ expect(callback2).toHaveBeenCalledOnce();
127
+ unsubscribe1();
128
+ emitClickEvent(mockClickEvent);
129
+ expect(callback1).toHaveBeenCalledOnce(); // no new calls
130
+ expect(callback2).toHaveBeenCalledTimes(2);
131
+ });
132
+ it('should not affect other subscribers when one unsubscribes', () => {
133
+ const callback1 = vi.fn();
134
+ const callback2 = vi.fn();
135
+ const unsubscribe1 = subscribeToClickEvents(callback1);
136
+ subscribeToClickEvents(callback2);
137
+ unsubscribe1();
138
+ emitClickEvent(mockClickEvent);
139
+ expect(callback1).not.toHaveBeenCalled();
140
+ expect(callback2).toHaveBeenCalledOnce();
141
+ });
142
+ it('should handle unsubscribe called multiple times without error', () => {
143
+ const callback = vi.fn();
144
+ const unsubscribe = subscribeToClickEvents(callback);
145
+ expect(() => {
146
+ unsubscribe();
147
+ unsubscribe();
148
+ }).not.toThrow();
149
+ });
150
+ it('should pass event data correctly to callback', () => {
151
+ const received = [];
152
+ subscribeToClickEvents((data) => received.push(data));
153
+ const event1 = { ...mockClickEvent, eventId: 'e1', deviceType: 'ios' };
154
+ const event2 = { ...mockClickEvent, eventId: 'e2', deviceType: 'android' };
155
+ emitClickEvent(event1);
156
+ emitClickEvent(event2);
157
+ expect(received).toHaveLength(2);
158
+ expect(received[0]).toEqual(event1);
159
+ expect(received[1]).toEqual(event2);
160
+ });
161
+ });
162
+ //# sourceMappingURL=event-emitter.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"event-emitter.test.js","sourceRoot":"","sources":["../../src/lib/event-emitter.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=fingerprint.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fingerprint.test.d.ts","sourceRoot":"","sources":["../../src/lib/fingerprint.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,227 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ // Mock the database module so tests don't require a real Postgres connection.
3
+ vi.mock('./database', () => ({
4
+ db: {
5
+ query: vi.fn(),
6
+ },
7
+ }));
8
+ import * as fingerprint from './fingerprint';
9
+ import { db } from './database';
10
+ const mockDbQuery = db.query;
11
+ const baseFingerprint = {
12
+ ipAddress: '192.168.1.100',
13
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36',
14
+ timezone: 'America/Los_Angeles',
15
+ language: 'en-US',
16
+ screenWidth: 1080,
17
+ screenHeight: 1920,
18
+ platform: 'Windows',
19
+ platformVersion: '10',
20
+ };
21
+ describe('generateFingerprintHash', () => {
22
+ it('produces a deterministic 64-character SHA-256 hash', () => {
23
+ const hash1 = fingerprint.generateFingerprintHash(baseFingerprint);
24
+ const hash2 = fingerprint.generateFingerprintHash(baseFingerprint);
25
+ expect(hash1).toHaveLength(64);
26
+ expect(hash1).toBe(hash2);
27
+ });
28
+ it('produces different hashes for different data', () => {
29
+ const other = { ...baseFingerprint, ipAddress: '10.0.0.1' };
30
+ const hash1 = fingerprint.generateFingerprintHash(baseFingerprint);
31
+ const hash2 = fingerprint.generateFingerprintHash(other);
32
+ expect(hash1).not.toBe(hash2);
33
+ });
34
+ });
35
+ describe('calculateConfidenceScore', () => {
36
+ it('returns 0 score when nothing matches', () => {
37
+ const a = { ...baseFingerprint };
38
+ const b = {
39
+ ...baseFingerprint,
40
+ ipAddress: '10.0.0.1',
41
+ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/537.36',
42
+ timezone: 'Asia/Tokyo',
43
+ language: 'ja-JP',
44
+ screenWidth: 800,
45
+ screenHeight: 600,
46
+ platform: 'Macintosh',
47
+ platformVersion: '11.0',
48
+ };
49
+ const { score, matchedFactors } = fingerprint.calculateConfidenceScore(a, b);
50
+ expect(score).toBe(0);
51
+ expect(matchedFactors).toEqual([]);
52
+ });
53
+ it('matches IP within the same /24 subnet and normalizes user agent', () => {
54
+ const click = {
55
+ ...baseFingerprint,
56
+ ipAddress: '192.168.1.250',
57
+ timezone: 'Africa/Cairo',
58
+ language: 'fr-FR',
59
+ screenWidth: 800,
60
+ screenHeight: 600,
61
+ platform: 'Linux',
62
+ };
63
+ const install = {
64
+ ...baseFingerprint,
65
+ ipAddress: '192.168.1.123',
66
+ userAgent: baseFingerprint.userAgent.replace('Chrome/95.0.4638.69', 'Chrome/116.0.0.0'),
67
+ timezone: 'Europe/London',
68
+ language: 'de-DE',
69
+ screenWidth: 1200,
70
+ screenHeight: 900,
71
+ platform: 'Windows',
72
+ };
73
+ const { score, matchedFactors } = fingerprint.calculateConfidenceScore(click, install);
74
+ expect(score).toBe(70);
75
+ expect(matchedFactors).toContain('ip');
76
+ expect(matchedFactors).toContain('user_agent');
77
+ });
78
+ it('matches language by first two characters', () => {
79
+ const a = {
80
+ ...baseFingerprint,
81
+ ipAddress: '10.0.0.1',
82
+ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/537.36',
83
+ timezone: 'Asia/Tokyo',
84
+ screenWidth: 800,
85
+ screenHeight: 600,
86
+ platform: 'Macintosh',
87
+ platformVersion: '11.0',
88
+ language: 'en-US',
89
+ };
90
+ const b = {
91
+ ...a,
92
+ ipAddress: '172.16.0.1',
93
+ userAgent: 'Mozilla/5.0 (Linux; Android 10; SM-G973F) Chrome/91.0.4472.120 Mobile Safari/537.36',
94
+ timezone: 'UTC',
95
+ screenWidth: 1024,
96
+ screenHeight: 768,
97
+ platform: 'Linux',
98
+ platformVersion: '10',
99
+ language: 'en-GB',
100
+ };
101
+ const { score, matchedFactors } = fingerprint.calculateConfidenceScore(a, b);
102
+ expect(score).toBe(10);
103
+ expect(matchedFactors).toEqual(['language']);
104
+ });
105
+ it('matches timezone and resolution', () => {
106
+ const a = {
107
+ ...baseFingerprint,
108
+ ipAddress: '10.0.0.1',
109
+ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/537.36',
110
+ language: 'ja-JP',
111
+ platform: 'Macintosh',
112
+ platformVersion: '11.0',
113
+ timezone: 'UTC',
114
+ screenWidth: 100,
115
+ screenHeight: 200,
116
+ };
117
+ const b = {
118
+ ...a,
119
+ ipAddress: '172.16.0.1',
120
+ userAgent: 'Mozilla/5.0 (Linux; Android 10; SM-G973F) Chrome/91.0.4472.120 Mobile Safari/537.36',
121
+ language: 'fr-FR',
122
+ };
123
+ const { score, matchedFactors } = fingerprint.calculateConfidenceScore(a, b);
124
+ expect(score).toBe(20);
125
+ expect(matchedFactors.sort()).toEqual(['screen', 'timezone'].sort());
126
+ });
127
+ });
128
+ describe('matchInstallToClick', () => {
129
+ beforeEach(() => {
130
+ vi.useFakeTimers();
131
+ vi.setSystemTime(new Date('2025-01-01T00:00:00Z'));
132
+ mockDbQuery.mockReset();
133
+ });
134
+ afterEach(() => {
135
+ vi.useRealTimers();
136
+ });
137
+ it('returns null when there are no click rows', async () => {
138
+ mockDbQuery.mockResolvedValueOnce({ rows: [] });
139
+ const result = await fingerprint.matchInstallToClick(baseFingerprint);
140
+ expect(result).toBeNull();
141
+ });
142
+ it('returns the best match above the confidence threshold', async () => {
143
+ const clickTime = new Date('2024-12-31T23:00:00Z');
144
+ // First row: only IP match (score 40)
145
+ const rowA = {
146
+ click_id: 'click-a',
147
+ link_id: 'link-a',
148
+ clicked_at: clickTime.toISOString(),
149
+ attribution_window_hours: 24,
150
+ ip_address: '192.168.1.200',
151
+ user_agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/537.36',
152
+ timezone: 'Asia/Tokyo',
153
+ language: 'ja-JP',
154
+ screen_width: 720,
155
+ screen_height: 1280,
156
+ platform: 'Macintosh',
157
+ platform_version: '11.0',
158
+ };
159
+ // Second row: IP + user agent + timezone + language + screen (score 100)
160
+ const rowB = {
161
+ click_id: 'click-b',
162
+ link_id: 'link-b',
163
+ clicked_at: clickTime.toISOString(),
164
+ attribution_window_hours: 24,
165
+ ip_address: '192.168.1.250',
166
+ user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36',
167
+ timezone: 'America/Los_Angeles',
168
+ language: 'en-US',
169
+ screen_width: 1080,
170
+ screen_height: 1920,
171
+ platform: 'Windows',
172
+ platform_version: '10',
173
+ };
174
+ mockDbQuery.mockResolvedValueOnce({ rows: [rowA, rowB] });
175
+ const installFingerprint = {
176
+ ...baseFingerprint,
177
+ ipAddress: '192.168.1.123',
178
+ userAgent: baseFingerprint.userAgent.replace('Chrome/95.0.4638.69', 'Chrome/116.0.0.0'),
179
+ };
180
+ const result = await fingerprint.matchInstallToClick(installFingerprint);
181
+ expect(result).not.toBeNull();
182
+ expect(result?.clickId).toBe('click-b');
183
+ expect(result?.confidenceScore).toBe(100);
184
+ expect(result?.matchedFactors).toEqual(expect.arrayContaining(['ip', 'user_agent', 'timezone', 'language', 'screen']));
185
+ });
186
+ it('skips clicks that are outside the attribution window', async () => {
187
+ const oldClickTime = new Date('2024-01-01T00:00:00Z');
188
+ mockDbQuery.mockResolvedValueOnce({
189
+ rows: [
190
+ {
191
+ click_id: 'click-old',
192
+ link_id: 'link-old',
193
+ clicked_at: oldClickTime.toISOString(),
194
+ attribution_window_hours: 1,
195
+ ip_address: baseFingerprint.ipAddress,
196
+ user_agent: baseFingerprint.userAgent,
197
+ timezone: baseFingerprint.timezone,
198
+ language: baseFingerprint.language,
199
+ screen_width: baseFingerprint.screenWidth,
200
+ screen_height: baseFingerprint.screenHeight,
201
+ platform: baseFingerprint.platform,
202
+ platform_version: baseFingerprint.platformVersion,
203
+ },
204
+ ],
205
+ });
206
+ const result = await fingerprint.matchInstallToClick(baseFingerprint);
207
+ expect(result).toBeNull();
208
+ });
209
+ });
210
+ describe('recordInstallEvent', () => {
211
+ beforeEach(() => {
212
+ mockDbQuery.mockReset();
213
+ });
214
+ it('inserts an install event and returns the install id when no match is found', async () => {
215
+ // matchInstallToClick is invoked internally by recordInstallEvent.
216
+ // The first db query is used to find click events. Return an empty list to force a null match.
217
+ mockDbQuery.mockResolvedValueOnce({ rows: [] });
218
+ mockDbQuery.mockResolvedValueOnce({ rows: [{ id: 'install-123', deep_link_data: {} }] });
219
+ const result = await fingerprint.recordInstallEvent(baseFingerprint, 'device-1');
220
+ expect(result.installId).toBe('install-123');
221
+ expect(result.match).toBeNull();
222
+ expect(result.deepLinkData).toEqual({});
223
+ expect(mockDbQuery).toHaveBeenCalledTimes(2);
224
+ expect(mockDbQuery).toHaveBeenLastCalledWith(expect.any(String), expect.arrayContaining([null, null, expect.any(String), null, expect.any(String), expect.any(String), null, null, null, null, null, null, null, 'device-1', null]));
225
+ });
226
+ });
227
+ //# sourceMappingURL=fingerprint.test.js.map