From a0f38222f266571e4a4f4109f331269ea52a0a0a Mon Sep 17 00:00:00 2001 From: Yentec Date: Wed, 27 May 2026 10:08:50 +0200 Subject: [PATCH 1/9] feat(errors): add 410 gone error for expired links --- package-lock.json | 4 ++-- package.json | 2 +- src/shared/errors/app-error.ts | 7 +++++++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index a569c9d..c592501 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkforge", - "version": "0.4.0", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "0.4.0", + "version": "0.4.1", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/package.json b/package.json index c28fa31..1c1cfb6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "0.4.0", + "version": "0.4.1", "description": "URL shortener API with authentication, API keys, async click tracking and analytics.", "type": "module", "license": "MIT", diff --git a/src/shared/errors/app-error.ts b/src/shared/errors/app-error.ts index ad3a2ec..4969602 100644 --- a/src/shared/errors/app-error.ts +++ b/src/shared/errors/app-error.ts @@ -62,6 +62,13 @@ export const Errors = { message, }), + gone: (message: string): AppError => + createAppError({ + code: 'GONE', + statusCode: 410, + message, + }), + rateLimited: (retryAfter: number): AppError => createAppError({ code: 'RATE_LIMITED', From 431fffa428c3f80ff057e0d7dd3345b403e55638 Mon Sep 17 00:00:00 2001 From: Yentec Date: Wed, 27 May 2026 10:11:03 +0200 Subject: [PATCH 2/9] feat(tracking): add click prisma model with analytics index --- package-lock.json | 4 ++-- package.json | 2 +- .../20260527080957_clicks_model/migration.sql | 19 +++++++++++++++++++ prisma/schema.prisma | 18 ++++++++++++++++++ 4 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20260527080957_clicks_model/migration.sql diff --git a/package-lock.json b/package-lock.json index c592501..95a1aaa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkforge", - "version": "0.4.1", + "version": "0.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "0.4.1", + "version": "0.4.2", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/package.json b/package.json index 1c1cfb6..ae6388b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "0.4.1", + "version": "0.4.2", "description": "URL shortener API with authentication, API keys, async click tracking and analytics.", "type": "module", "license": "MIT", diff --git a/prisma/migrations/20260527080957_clicks_model/migration.sql b/prisma/migrations/20260527080957_clicks_model/migration.sql new file mode 100644 index 0000000..43afe71 --- /dev/null +++ b/prisma/migrations/20260527080957_clicks_model/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "clicks" ( + "id" UUID NOT NULL, + "linkId" UUID NOT NULL, + "country" TEXT, + "deviceType" TEXT NOT NULL, + "browser" TEXT, + "referrerHost" TEXT, + "ipHash" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "clicks_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "clicks_linkId_createdAt_idx" ON "clicks"("linkId", "createdAt"); + +-- AddForeignKey +ALTER TABLE "clicks" ADD CONSTRAINT "clicks_linkId_fkey" FOREIGN KEY ("linkId") REFERENCES "links"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 35f89da..01cb5f6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -60,7 +60,25 @@ model Link { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + clicks Click[] + // Cursor pagination scans by (userId, createdAt, id). @@index([userId, createdAt, id]) @@map("links") +} + +model Click { + id String @id @default(uuid()) @db.Uuid + linkId String @db.Uuid + link Link @relation(fields: [linkId], references: [id], onDelete: Cascade) + country String? + deviceType String + browser String? + referrerHost String? + ipHash String + createdAt DateTime @default(now()) + + // Analytics queries scan by link over a time window. + @@index([linkId, createdAt]) + @@map("clicks") } \ No newline at end of file From 72f70c51ff08df24473af086e29e52d86442041a Mon Sep 17 00:00:00 2001 From: Yentec Date: Wed, 27 May 2026 10:17:48 +0200 Subject: [PATCH 3/9] feat(tracking): add user-agent classifier and geo resolver --- package-lock.json | 4 ++-- package.json | 2 +- src/shared/geo/geo.ts | 12 ++++++++++ src/shared/utils/user-agent.ts | 42 ++++++++++++++++++++++++++++++++++ tests/unit/user-agent.test.ts | 38 ++++++++++++++++++++++++++++++ 5 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 src/shared/geo/geo.ts create mode 100644 src/shared/utils/user-agent.ts create mode 100644 tests/unit/user-agent.test.ts diff --git a/package-lock.json b/package-lock.json index 95a1aaa..2a6d0b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkforge", - "version": "0.4.2", + "version": "0.4.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "0.4.2", + "version": "0.4.3", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/package.json b/package.json index ae6388b..e2488bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "0.4.2", + "version": "0.4.3", "description": "URL shortener API with authentication, API keys, async click tracking and analytics.", "type": "module", "license": "MIT", diff --git a/src/shared/geo/geo.ts b/src/shared/geo/geo.ts new file mode 100644 index 0000000..2bbde00 --- /dev/null +++ b/src/shared/geo/geo.ts @@ -0,0 +1,12 @@ +/** + * Best-effort country resolution from an IP address. + * + * The default implementation returns null: shipping a MaxMind GeoLite2 database + * requires a license key and a large binary file, which would make the repo + * harder to clone and run. To enable real geolocation, plug a MaxMind reader + * here (e.g. the `maxmind` package against a GeoLite2-Country.mmdb) behind an + * env flag. Demo analytics data is provided by the seed script. + */ +export function resolveCountry(_ip: string): Promise { + return Promise.resolve(null); +} diff --git a/src/shared/utils/user-agent.ts b/src/shared/utils/user-agent.ts new file mode 100644 index 0000000..1a14d2c --- /dev/null +++ b/src/shared/utils/user-agent.ts @@ -0,0 +1,42 @@ +export type DeviceType = 'mobile' | 'tablet' | 'desktop' | 'bot'; + +export interface ParsedUserAgent { + deviceType: DeviceType; + browser: string | null; +} + +/** + * Minimal, dependency-free user-agent classifier. + * Deliberately coarse: analytics only needs device buckets and browser families, + * not exhaustive parsing. Avoids the AGPL/commercial licensing of ua-parser-js v2+. + */ +export function parseUserAgent(ua: string): ParsedUserAgent { + const s = ua.toLowerCase(); + + if (!s) return { deviceType: 'desktop', browser: null }; + + if (/bot|crawler|spider|crawling|slurp|curl|wget|headless/.test(s)) { + return { deviceType: 'bot', browser: null }; + } + + const deviceType: DeviceType = /ipad|tablet|playbook|silk/.test(s) + ? 'tablet' + : /mobi|iphone|android.*mobile|phone|ipod/.test(s) + ? 'mobile' + : 'desktop'; + + // Order matters: Edge/Opera/Chrome share tokens, so check the most specific first. + const browser = /edg\//.test(s) + ? 'Edge' + : /opr\/|opera/.test(s) + ? 'Opera' + : /firefox|fxios/.test(s) + ? 'Firefox' + : /chrome|crios/.test(s) + ? 'Chrome' + : /safari/.test(s) + ? 'Safari' + : null; + + return { deviceType, browser }; +} diff --git a/tests/unit/user-agent.test.ts b/tests/unit/user-agent.test.ts new file mode 100644 index 0000000..28a790d --- /dev/null +++ b/tests/unit/user-agent.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { parseUserAgent } from '@/shared/utils/user-agent'; + +describe('parseUserAgent', () => { + it('detects desktop Chrome', () => { + const ua = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36'; + expect(parseUserAgent(ua)).toEqual({ deviceType: 'desktop', browser: 'Chrome' }); + }); + + it('detects mobile Safari on iPhone', () => { + const ua = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'; + expect(parseUserAgent(ua)).toEqual({ deviceType: 'mobile', browser: 'Safari' }); + }); + + it('detects tablet (iPad)', () => { + const ua = 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Safari/604.1'; + expect(parseUserAgent(ua).deviceType).toBe('tablet'); + }); + + it('detects Edge over Chrome', () => { + const ua = + 'Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 Chrome/120.0 Safari/537.36 Edg/120.0'; + expect(parseUserAgent(ua).browser).toBe('Edge'); + }); + + it('detects bots', () => { + expect(parseUserAgent('Googlebot/2.1 (+http://www.google.com/bot.html)').deviceType).toBe( + 'bot', + ); + expect(parseUserAgent('curl/8.4.0').deviceType).toBe('bot'); + }); + + it('handles empty user agent', () => { + expect(parseUserAgent('')).toEqual({ deviceType: 'desktop', browser: null }); + }); +}); From 47bd638e6bb8586cc734941aa248a5eb5be83d60 Mon Sep 17 00:00:00 2001 From: Yentec Date: Wed, 27 May 2026 10:49:09 +0200 Subject: [PATCH 4/9] feat(tracking): add bullmq queue, connection and click repository --- package-lock.json | 199 +++++++++++++++++++- package.json | 3 +- src/modules/tracking/tracking.queue.ts | 38 ++++ src/modules/tracking/tracking.repository.ts | 18 ++ src/modules/tracking/tracking.types.ts | 8 + src/shared/queue/connection.ts | 11 ++ 6 files changed, 270 insertions(+), 7 deletions(-) create mode 100644 src/modules/tracking/tracking.queue.ts create mode 100644 src/modules/tracking/tracking.repository.ts create mode 100644 src/modules/tracking/tracking.types.ts create mode 100644 src/shared/queue/connection.ts diff --git a/package-lock.json b/package-lock.json index 2a6d0b5..6d375f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkforge", - "version": "0.4.3", + "version": "0.4.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "0.4.3", + "version": "0.4.4", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", @@ -14,6 +14,7 @@ "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "argon2": "^0.44.0", + "bullmq": "^5.77.6", "dotenv": "^17.4.2", "fastify": "^5.8.5", "ioredis": "^5.10.1", @@ -1146,6 +1147,84 @@ "debug": "^4.1.1" } }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -3348,6 +3427,43 @@ "node": ">=10.0.0" } }, + "node_modules/bullmq": { + "version": "5.77.6", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.77.6.tgz", + "integrity": "sha512-WCpSoCD4vWyRD+btOsFrO7iBGInrTgG155gTZCV8qY0Yex2KtsbVtFERx6V1WZ2xWl/5ZxnLar8Z8ufnS4f5jg==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.10.1", + "msgpackr": "2.0.1", + "node-abort-controller": "3.1.1", + "semver": "7.8.0", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=12.22.0" + }, + "peerDependencies": { + "redis": ">=5.0.0" + }, + "peerDependenciesMeta": { + "redis": { + "optional": true + } + } + }, + "node_modules/bullmq/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -3702,6 +3818,18 @@ "node": ">= 14" } }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cross-env": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", @@ -3821,7 +3949,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -5582,6 +5710,15 @@ "url": "https://github.com/sponsors/wellwelwel" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -5717,6 +5854,37 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msgpackr": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.1.tgz", + "integrity": "sha512-9J+tqTEsbHqY8YohazYgty7LgerFIWxvMLpUjqETSmjHojtJm2WnX2kK/2a1fLI7CO7ERP1YSEUXMucz4j+yBA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, "node_modules/mysql2": { "version": "3.15.3", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", @@ -5796,6 +5964,12 @@ "dev": true, "license": "MIT" }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, "node_modules/node-addon-api": { "version": "8.8.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.8.0.tgz", @@ -5816,6 +5990,21 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -7467,9 +7656,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/tsup": { "version": "8.5.1", diff --git a/package.json b/package.json index e2488bc..ae62713 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "0.4.3", + "version": "0.4.4", "description": "URL shortener API with authentication, API keys, async click tracking and analytics.", "type": "module", "license": "MIT", @@ -34,6 +34,7 @@ "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "argon2": "^0.44.0", + "bullmq": "^5.77.6", "dotenv": "^17.4.2", "fastify": "^5.8.5", "ioredis": "^5.10.1", diff --git a/src/modules/tracking/tracking.queue.ts b/src/modules/tracking/tracking.queue.ts new file mode 100644 index 0000000..95a5387 --- /dev/null +++ b/src/modules/tracking/tracking.queue.ts @@ -0,0 +1,38 @@ +import { Queue } from 'bullmq'; +import { logger } from '@/config/logger'; +import { createQueueConnection } from '@/shared/queue/connection'; +import { CLICK_QUEUE_NAME, type ClickJobData } from './tracking.types'; + +let queue: Queue | null = null; + +function getQueue(): Queue { + queue ??= new Queue(CLICK_QUEUE_NAME, { + connection: createQueueConnection(), + defaultJobOptions: { + attempts: 3, + backoff: { type: 'exponential', delay: 1000 }, + removeOnComplete: 1000, + removeOnFail: 5000, + }, + }); + return queue; +} + +/** + * Enqueues a click for async processing. Tracking must never break a redirect, + * so failures are swallowed and logged. + */ +export async function enqueueClick(data: ClickJobData): Promise { + try { + await getQueue().add('track', data); + } catch (err) { + logger.error({ err }, 'Failed to enqueue click'); + } +} + +export async function closeClickQueue(): Promise { + if (queue) { + await queue.close(); + queue = null; + } +} diff --git a/src/modules/tracking/tracking.repository.ts b/src/modules/tracking/tracking.repository.ts new file mode 100644 index 0000000..e7bf9fe --- /dev/null +++ b/src/modules/tracking/tracking.repository.ts @@ -0,0 +1,18 @@ +import type { PrismaClient } from '@prisma/client'; + +type CreateClickData = { + linkId: string; + country: string | null; + deviceType: string; + browser: string | null; + referrerHost: string | null; + ipHash: string; +}; + +export const createClickRepository = (db: PrismaClient) => { + return { + create(data: CreateClickData) { + return db.click.create({ data }); + }, + }; +}; diff --git a/src/modules/tracking/tracking.types.ts b/src/modules/tracking/tracking.types.ts new file mode 100644 index 0000000..9dd74d3 --- /dev/null +++ b/src/modules/tracking/tracking.types.ts @@ -0,0 +1,8 @@ +export interface ClickJobData { + linkId: string; + ip: string; + userAgent: string; + referrer: string | null; +} + +export const CLICK_QUEUE_NAME = 'clicks'; diff --git a/src/shared/queue/connection.ts b/src/shared/queue/connection.ts new file mode 100644 index 0000000..aad2817 --- /dev/null +++ b/src/shared/queue/connection.ts @@ -0,0 +1,11 @@ +import { Redis } from 'ioredis'; +import { env } from '@/config/env'; + +/** + * Dedicated Redis connection for BullMQ. Workers issue blocking commands, so they + * must not share the app's command connection. maxRetriesPerRequest: null is + * required by BullMQ. + */ +export function createQueueConnection(): Redis { + return new Redis(env.REDIS_URL, { maxRetriesPerRequest: null }); +} From 28dc3b44d9b32d3aeb529bee6d888a157e0bd0ce Mon Sep 17 00:00:00 2001 From: Yentec Date: Wed, 27 May 2026 11:00:00 +0200 Subject: [PATCH 5/9] feat(tracking): add click processor and worker --- package-lock.json | 4 +-- package.json | 2 +- src/modules/tracking/tracking.processor.ts | 40 +++++++++++++++++++++ src/modules/tracking/tracking.repository.ts | 2 ++ src/modules/tracking/tracking.worker.ts | 34 ++++++++++++++++++ 5 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 src/modules/tracking/tracking.processor.ts create mode 100644 src/modules/tracking/tracking.worker.ts diff --git a/package-lock.json b/package-lock.json index 6d375f8..e49e2ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkforge", - "version": "0.4.4", + "version": "0.4.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "0.4.4", + "version": "0.4.5", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/package.json b/package.json index ae62713..7c293ec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "0.4.4", + "version": "0.4.5", "description": "URL shortener API with authentication, API keys, async click tracking and analytics.", "type": "module", "license": "MIT", diff --git a/src/modules/tracking/tracking.processor.ts b/src/modules/tracking/tracking.processor.ts new file mode 100644 index 0000000..bba6af5 --- /dev/null +++ b/src/modules/tracking/tracking.processor.ts @@ -0,0 +1,40 @@ +import type { Job } from 'bullmq'; +import { env } from '@/config/env'; +import { sha256 } from '@/shared/auth/tokens'; +import { parseUserAgent } from '@/shared/utils/user-agent'; +import { resolveCountry } from '@/shared/geo/geo'; +import type { ClickRepository } from './tracking.repository'; +import type { ClickJobData } from './tracking.types'; + +function safeHost(referrer: string | null): string | null { + if (!referrer) return null; + try { + return new URL(referrer).hostname; + } catch { + return null; + } +} + +/** + * Pure processor factory. Kept independent of BullMQ wiring so it can be + * unit-tested by calling it with a minimal job object. + */ +export function createClickProcessor(repo: ClickRepository) { + return async (job: Job): Promise => { + const { linkId, ip, userAgent, referrer } = job.data; + const ua = parseUserAgent(userAgent); + const country = await resolveCountry(ip); + + // Anonymize the IP: store only a salted, truncated one-way hash (GDPR). + const ipHash = sha256(`${ip}${env.IP_HASH_SALT}`).slice(0, 16); + + await repo.create({ + linkId, + country, + deviceType: ua.deviceType, + browser: ua.browser, + referrerHost: safeHost(referrer), + ipHash, + }); + }; +} diff --git a/src/modules/tracking/tracking.repository.ts b/src/modules/tracking/tracking.repository.ts index e7bf9fe..81cbf5a 100644 --- a/src/modules/tracking/tracking.repository.ts +++ b/src/modules/tracking/tracking.repository.ts @@ -9,6 +9,8 @@ type CreateClickData = { ipHash: string; }; +export type ClickRepository = ReturnType; + export const createClickRepository = (db: PrismaClient) => { return { create(data: CreateClickData) { diff --git a/src/modules/tracking/tracking.worker.ts b/src/modules/tracking/tracking.worker.ts new file mode 100644 index 0000000..175af24 --- /dev/null +++ b/src/modules/tracking/tracking.worker.ts @@ -0,0 +1,34 @@ +import { Worker } from 'bullmq'; +import { logger } from '@/config/logger'; +import { prisma } from '@/shared/db'; +import { createQueueConnection } from '@/shared/queue/connection'; +import { CLICK_QUEUE_NAME, type ClickJobData } from './tracking.types'; +import { createClickRepository } from './tracking.repository'; +import { createClickProcessor } from './tracking.processor'; + +let worker: Worker | null = null; + +/** Starts the click worker. Called from server.ts only (never in tests). */ +export function startClickWorker(): Worker { + if (worker) return worker; + + const processor = createClickProcessor(createClickRepository(prisma)); + worker = new Worker(CLICK_QUEUE_NAME, processor, { + connection: createQueueConnection(), + concurrency: 10, + }); + + worker.on('failed', (job, err) => { + logger.error({ jobId: job?.id, err }, 'Click job failed'); + }); + + logger.info('Click worker started'); + return worker; +} + +export async function stopClickWorker(): Promise { + if (worker) { + await worker.close(); + worker = null; + } +} From 2561e9ee10173ccb5007e290ff4173d4a19d1251 Mon Sep 17 00:00:00 2001 From: Yentec Date: Wed, 27 May 2026 11:05:23 +0200 Subject: [PATCH 6/9] feat(redirect): add public redirect with cache and async tracking --- package-lock.json | 4 +- package.json | 2 +- src/app.ts | 4 ++ src/modules/redirect/redirect.controller.ts | 24 ++++++++ src/modules/redirect/redirect.routes.ts | 15 +++++ src/modules/redirect/redirect.schemas.ts | 6 ++ src/modules/redirect/redirect.service.ts | 61 +++++++++++++++++++++ 7 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 src/modules/redirect/redirect.controller.ts create mode 100644 src/modules/redirect/redirect.routes.ts create mode 100644 src/modules/redirect/redirect.schemas.ts create mode 100644 src/modules/redirect/redirect.service.ts diff --git a/package-lock.json b/package-lock.json index e49e2ed..2dbeffb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkforge", - "version": "0.4.5", + "version": "0.4.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "0.4.5", + "version": "0.4.6", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/package.json b/package.json index 7c293ec..b8307b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "0.4.5", + "version": "0.4.6", "description": "URL shortener API with authentication, API keys, async click tracking and analytics.", "type": "module", "license": "MIT", diff --git a/src/app.ts b/src/app.ts index 3e23202..3378718 100644 --- a/src/app.ts +++ b/src/app.ts @@ -8,6 +8,7 @@ import { healthRoutes } from '@/modules/health/health.routes'; import { authRoutes } from './modules/auth/auth.routes'; import { apiKeyRoutes } from './modules/api-keys/api-keys.routes'; import { linkRoutes } from './modules/links/links.routes'; +import { redirectRoutes } from './modules/redirect/redirect.routes'; /** * Builds a fully configured Fastify instance without starting the server. @@ -36,5 +37,8 @@ export async function buildApp(): Promise { { prefix: '/v1' }, ); + // Public redirect catch-all. Must be registered last. + await app.register(redirectRoutes); + return app; } diff --git a/src/modules/redirect/redirect.controller.ts b/src/modules/redirect/redirect.controller.ts new file mode 100644 index 0000000..f35f982 --- /dev/null +++ b/src/modules/redirect/redirect.controller.ts @@ -0,0 +1,24 @@ +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { enqueueClick } from '@/modules/tracking/tracking.queue'; +import type { RedirectService } from './redirect.service'; +import { redirectParamsSchema } from './redirect.schemas'; + +export function createRedirectController(service: RedirectService) { + return { + redirect: async (request: FastifyRequest, reply: FastifyReply) => { + const { code } = redirectParamsSchema.parse(request.params); + const link = await service.resolve(code); + + // Fire-and-forget: tracking must never block or fail the redirect. + void enqueueClick({ + linkId: link.id, + ip: request.ip, + userAgent: request.headers['user-agent'] ?? '', + referrer: request.headers.referer ?? null, + }); + + // Fastify 5 signature: redirect(url, code?). + return reply.redirect(link.target, 302); + }, + }; +} diff --git a/src/modules/redirect/redirect.routes.ts b/src/modules/redirect/redirect.routes.ts new file mode 100644 index 0000000..bd059c7 --- /dev/null +++ b/src/modules/redirect/redirect.routes.ts @@ -0,0 +1,15 @@ +import type { FastifyInstance } from 'fastify'; +import { prisma } from '@/shared/db'; +import { redis } from '@/shared/cache/redis'; +import { createCacheService } from '@/shared/cache/cache.service'; +import { createLinksRepository } from '@/modules/links/links.repository'; +import { createRedirectService } from './redirect.service'; +import { createRedirectController } from './redirect.controller'; + +export function redirectRoutes(app: FastifyInstance): void { + const service = createRedirectService(createLinksRepository(prisma), createCacheService(redis)); + const controller = createRedirectController(service); + + // Public, unauthenticated, root-level. Registered last so it can't shadow /v1 or /health. + app.get('/:code', controller.redirect); +} diff --git a/src/modules/redirect/redirect.schemas.ts b/src/modules/redirect/redirect.schemas.ts new file mode 100644 index 0000000..8f19c74 --- /dev/null +++ b/src/modules/redirect/redirect.schemas.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +// Matches both generated codes and custom slugs (3-30 chars). +export const redirectParamsSchema = z.object({ + code: z.string().min(1).max(30), +}); diff --git a/src/modules/redirect/redirect.service.ts b/src/modules/redirect/redirect.service.ts new file mode 100644 index 0000000..0936b67 --- /dev/null +++ b/src/modules/redirect/redirect.service.ts @@ -0,0 +1,61 @@ +import { Errors } from '@/shared/errors/app-error'; +import type { CacheService } from '@/shared/cache/cache.service'; +import type { LinksRepository } from '@/modules/links/links.repository'; + +interface CachedLink { + id: string; + target: string; + expiresAt: string | null; +} + +const CACHE_TTL_SECONDS = 300; + +const assertNotExpired = (expiresAt: Date | null): void => { + if (expiresAt && expiresAt < new Date()) { + throw Errors.gone('This link has expired'); + } +}; + +export type RedirectService = ReturnType; + +export const createRedirectService = (repo: LinksRepository, cache: CacheService) => { + return { + async resolve(code: string): Promise<{ id: string; target: string }> { + const cacheKey = `link:${code}`; + + const cached = await cache.getJson(cacheKey); + + if (cached) { + assertNotExpired(cached.expiresAt ? new Date(cached.expiresAt) : null); + + return { + id: cached.id, + target: cached.target, + }; + } + + const link = await repo.findByCode(code); + + if (!link) { + throw Errors.notFound('Link'); + } + + assertNotExpired(link.expiresAt); + + await cache.setJson( + cacheKey, + { + id: link.id, + target: link.target, + expiresAt: link.expiresAt, + }, + CACHE_TTL_SECONDS, + ); + + return { + id: link.id, + target: link.target, + }; + }, + }; +}; From 0b26877fef9451e6d5d88da2515ec99496cc798c Mon Sep 17 00:00:00 2001 From: Yentec Date: Wed, 27 May 2026 11:20:01 +0200 Subject: [PATCH 7/9] feat(tracking): start click worker in server lifecycle --- package-lock.json | 4 ++-- package.json | 2 +- src/server.ts | 6 ++++++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2dbeffb..7efe2b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkforge", - "version": "0.4.6", + "version": "0.4.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "0.4.6", + "version": "0.4.7", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/package.json b/package.json index b8307b1..ace0801 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "0.4.6", + "version": "0.4.7", "description": "URL shortener API with authentication, API keys, async click tracking and analytics.", "type": "module", "license": "MIT", diff --git a/src/server.ts b/src/server.ts index cd7d469..d962c84 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,13 +4,19 @@ import { env } from '@/config/env'; import { logger } from '@/config/logger'; import { disconnectDb } from '@/shared/db'; import { redis } from '@/shared/cache/redis'; +import { startClickWorker, stopClickWorker } from '@/modules/tracking/tracking.worker'; +import { closeClickQueue } from '@/modules/tracking/tracking.queue'; async function start(): Promise { const app = await buildApp(); + startClickWorker(); + const shutdown = async (signal: string): Promise => { logger.info({ signal }, 'Shutting down'); await app.close(); + await stopClickWorker(); + await closeClickQueue(); await disconnectDb(); redis.disconnect(); process.exit(0); From 50f31bcc8f4adf2b2d35c4007c66917cbcab24a3 Mon Sep 17 00:00:00 2001 From: Yentec Date: Wed, 27 May 2026 11:28:14 +0200 Subject: [PATCH 8/9] test(redirect): add redirect http and click processor tests --- package-lock.json | 4 +- package.json | 2 +- src/modules/tracking/tracking.types.ts | 2 +- tests/integration/click-processor.test.ts | 70 +++++++++++++++++++++++ tests/integration/redirect.test.ts | 63 ++++++++++++++++++++ 5 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 tests/integration/click-processor.test.ts create mode 100644 tests/integration/redirect.test.ts diff --git a/package-lock.json b/package-lock.json index 7efe2b4..4b04092 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkforge", - "version": "0.4.7", + "version": "0.4.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "0.4.7", + "version": "0.4.8", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/package.json b/package.json index ace0801..0ed2e3b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "0.4.7", + "version": "0.4.8", "description": "URL shortener API with authentication, API keys, async click tracking and analytics.", "type": "module", "license": "MIT", diff --git a/src/modules/tracking/tracking.types.ts b/src/modules/tracking/tracking.types.ts index 9dd74d3..6d2a747 100644 --- a/src/modules/tracking/tracking.types.ts +++ b/src/modules/tracking/tracking.types.ts @@ -5,4 +5,4 @@ export interface ClickJobData { referrer: string | null; } -export const CLICK_QUEUE_NAME = 'clicks'; +export const CLICK_QUEUE_NAME = process.env['NODE_ENV'] === 'test' ? 'clicks-test' : 'clicks'; diff --git a/tests/integration/click-processor.test.ts b/tests/integration/click-processor.test.ts new file mode 100644 index 0000000..23557ce --- /dev/null +++ b/tests/integration/click-processor.test.ts @@ -0,0 +1,70 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import type { Job } from 'bullmq'; +import { prisma } from '@/shared/db'; +import { redis } from '@/shared/cache/redis'; +import { buildTestApp, resetDb } from '../helpers/test-app'; +import { createClickRepository } from '@/modules/tracking/tracking.repository'; +import { createClickProcessor } from '@/modules/tracking/tracking.processor'; +import type { ClickJobData } from '@/modules/tracking/tracking.types'; + +describe('Click processor', () => { + const processor = createClickProcessor(createClickRepository(prisma)); + + // Build the app once to register a user + link to satisfy the FK. + let linkId: string; + + beforeAll(async () => { + await buildTestApp(); + }); + + afterAll(async () => { + await prisma.$disconnect(); + redis.disconnect(); + }); + + beforeEach(async () => { + await resetDb(); + const user = await prisma.user.create({ + data: { email: 'p@example.com', password: 'x' }, + }); + const link = await prisma.link.create({ + data: { code: 'abc1234', target: 'https://example.com', userId: user.id }, + }); + linkId = link.id; + }); + + function job(data: Partial): Job { + return { + data: { + linkId, + ip: '8.8.8.8', + userAgent: 'Mozilla/5.0 (iPhone) Safari', + referrer: 'https://news.ycombinator.com/item?id=1', + ...data, + }, + } as Job; + } + + it('persists an enriched click and anonymizes the IP', async () => { + await processor(job({})); + + const clicks = await prisma.click.findMany({ where: { linkId } }); + expect(clicks).toHaveLength(1); + const click = clicks[0]!; + expect(click.deviceType).toBe('mobile'); + expect(click.browser).toBe('Safari'); + expect(click.referrerHost).toBe('news.ycombinator.com'); + expect(click.country).toBeNull(); // best-effort resolver + // IP is never stored in clear: 16-char hex hash. + expect(click.ipHash).toMatch(/^[a-f0-9]{16}$/); + expect(click.ipHash).not.toContain('8.8.8.8'); + }); + + it('tolerates a missing referrer and empty UA', async () => { + await processor(job({ referrer: null, userAgent: '' })); + const click = (await prisma.click.findMany({ where: { linkId } }))[0]!; + expect(click.referrerHost).toBeNull(); + expect(click.deviceType).toBe('desktop'); + expect(click.browser).toBeNull(); + }); +}); diff --git a/tests/integration/redirect.test.ts b/tests/integration/redirect.test.ts new file mode 100644 index 0000000..f8343f0 --- /dev/null +++ b/tests/integration/redirect.test.ts @@ -0,0 +1,63 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { buildTestApp, resetDb } from '../helpers/test-app'; +import { prisma } from '@/shared/db'; +import { redis } from '@/shared/cache/redis'; + +describe('Public redirect', () => { + let app: FastifyInstance; + + beforeAll(async () => { + app = await buildTestApp(); + }); + + afterAll(async () => { + await app.close(); + await prisma.$disconnect(); + redis.disconnect(); + }); + + beforeEach(async () => { + await resetDb(); + await redis.flushdb(); + }); + + async function createLink(payload: Record): Promise { + const reg = await app.inject({ + method: 'POST', + url: '/v1/auth/register', + payload: { email: 'owner@example.com', password: 'SuperSecret123' }, + }); + const headers = { authorization: `Bearer ${reg.json<{ accessToken: string }>().accessToken}` }; + const res = await app.inject({ method: 'POST', url: '/v1/links', headers, payload }); + return res.json<{ code: string }>().code; + } + + it('redirects a known code with 302 and a location header', async () => { + const code = await createLink({ target: 'https://example.com/landing' }); + const res = await app.inject({ method: 'GET', url: `/${code}` }); + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe('https://example.com/landing'); + }); + + it('returns 404 for an unknown code', async () => { + const res = await app.inject({ method: 'GET', url: '/unknown' }); + expect(res.statusCode).toBe(404); + }); + + it('returns 410 for an expired link', async () => { + const code = await createLink({ + target: 'https://example.com', + expiresAt: new Date(Date.now() - 1000).toISOString(), + }); + const res = await app.inject({ method: 'GET', url: `/${code}` }); + expect(res.statusCode).toBe(410); + expect(res.json<{ error: { code: string } }>().error.code).toBe('GONE'); + }); + + it('does not let the catch-all shadow health or v1 routes', async () => { + expect((await app.inject({ method: 'GET', url: '/health' })).statusCode).toBe(200); + const me = await app.inject({ method: 'GET', url: '/v1/auth/me' }); + expect(me.statusCode).toBe(401); // route exists, just unauthenticated + }); +}); From 8a8f2d80a8a4c64255b3561fb38c79e4f4571c95 Mon Sep 17 00:00:00 2001 From: Yentec Date: Wed, 27 May 2026 11:31:37 +0200 Subject: [PATCH 9/9] chore(release): 0.5.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4b04092..55ca9b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkforge", - "version": "0.4.8", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "0.4.8", + "version": "0.5.0", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/package.json b/package.json index 0ed2e3b..0ccf6b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "0.4.8", + "version": "0.5.0", "description": "URL shortener API with authentication, API keys, async click tracking and analytics.", "type": "module", "license": "MIT",