From cf2f039c0f30f6d8a54343b109b47dee568ffead Mon Sep 17 00:00:00 2001 From: Yentec Date: Wed, 27 May 2026 16:54:49 +0200 Subject: [PATCH 1/5] feat(analytics): add click stats service with sql aggregations --- package-lock.json | 4 +- package.json | 2 +- src/app.ts | 2 + src/modules/analytics/analytics.controller.ts | 15 ++++ src/modules/analytics/analytics.repository.ts | 76 ++++++++++++++++ src/modules/analytics/analytics.routes.ts | 21 +++++ src/modules/analytics/analytics.schemas.ts | 13 +++ src/modules/analytics/analytics.service.ts | 87 +++++++++++++++++++ 8 files changed, 217 insertions(+), 3 deletions(-) create mode 100644 src/modules/analytics/analytics.controller.ts create mode 100644 src/modules/analytics/analytics.repository.ts create mode 100644 src/modules/analytics/analytics.routes.ts create mode 100644 src/modules/analytics/analytics.schemas.ts create mode 100644 src/modules/analytics/analytics.service.ts diff --git a/package-lock.json b/package-lock.json index 55ca9b0..00edfa7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkforge", - "version": "0.5.0", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "0.5.0", + "version": "0.5.1", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/package.json b/package.json index 0ccf6b7..381af0a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "0.5.0", + "version": "0.5.1", "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 3378718..b6521b8 100644 --- a/src/app.ts +++ b/src/app.ts @@ -9,6 +9,7 @@ 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'; +import { analyticsRoutes } from './modules/analytics/analytics.routes'; /** * Builds a fully configured Fastify instance without starting the server. @@ -33,6 +34,7 @@ export async function buildApp(): Promise { v1.register(authRoutes); v1.register(apiKeyRoutes); v1.register(linkRoutes); + v1.register(analyticsRoutes); }, { prefix: '/v1' }, ); diff --git a/src/modules/analytics/analytics.controller.ts b/src/modules/analytics/analytics.controller.ts new file mode 100644 index 0000000..d71c0f6 --- /dev/null +++ b/src/modules/analytics/analytics.controller.ts @@ -0,0 +1,15 @@ +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { getAuth } from '@/shared/middleware/authenticate'; +import type { AnalyticsService } from './analytics.service'; +import { statsParamsSchema, statsQuerySchema } from './analytics.schemas'; + +export function createAnalyticsController(service: AnalyticsService) { + return { + stats: async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = getAuth(request); + const { id } = statsParamsSchema.parse(request.params); + const query = statsQuerySchema.parse(request.query); + return reply.send(await service.getStats(userId, id, query)); + }, + }; +} diff --git a/src/modules/analytics/analytics.repository.ts b/src/modules/analytics/analytics.repository.ts new file mode 100644 index 0000000..b587b7c --- /dev/null +++ b/src/modules/analytics/analytics.repository.ts @@ -0,0 +1,76 @@ +import { Prisma, type PrismaClient } from '@prisma/client'; + +type Interval = 'day' | 'week'; + +// Whitelisted physical columns for top-N grouping. Never user-provided. +const TOP_COLUMNS = { + country: 'country', + referrer: 'referrerHost', + device: 'deviceType', + browser: 'browser', +} as const; + +export type TopDimension = keyof typeof TOP_COLUMNS; + +interface Bucket { + bucket: Date; + count: number; +} + +interface TopRow { + key: string; + count: number; +} + +export type AnalyticsRepository = ReturnType; + +export const createAnalyticsRepository = (db: PrismaClient) => { + return { + countClicks(linkId: string, from: Date, to: Date): Promise { + return db.click.count({ + where: { + linkId, + createdAt: { + gte: from, + lt: to, + }, + }, + }); + }, + + // count(*)::int returns int4 -> mapped to number, avoiding bigint serialization. + timeSeries(linkId: string, from: Date, to: Date, interval: Interval): Promise { + return db.$queryRaw(Prisma.sql` + SELECT + date_trunc(${interval}, "createdAt") AS bucket, + count(*)::int AS count + FROM "clicks" + WHERE + "linkId" = ${linkId}::uuid + AND "createdAt" >= ${from} + AND "createdAt" < ${to} + GROUP BY bucket + ORDER BY bucket ASC + `); + }, + + topBy(linkId: string, from: Date, to: Date, dimension: TopDimension): Promise { + // Prisma.raw is safe here: the column comes from a fixed internal map. + const column = Prisma.raw(`coalesce("${TOP_COLUMNS[dimension]}"::text, 'unknown')`); + + return db.$queryRaw(Prisma.sql` + SELECT + ${column} AS key, + count(*)::int AS count + FROM "clicks" + WHERE + "linkId" = ${linkId}::uuid + AND "createdAt" >= ${from} + AND "createdAt" < ${to} + GROUP BY 1 + ORDER BY count DESC + LIMIT 5 + `); + }, + }; +}; diff --git a/src/modules/analytics/analytics.routes.ts b/src/modules/analytics/analytics.routes.ts new file mode 100644 index 0000000..8013870 --- /dev/null +++ b/src/modules/analytics/analytics.routes.ts @@ -0,0 +1,21 @@ +import type { FastifyInstance } from 'fastify'; +import { prisma } from '@/shared/db'; +import { redis } from '@/shared/cache/redis'; +import { createCacheService } from '@/shared/cache/cache.service'; +import { authenticate, requireScope } from '@/shared/middleware/authenticate'; +import { createLinksRepository } from '@/modules/links/links.repository'; +import { createAnalyticsRepository } from './analytics.repository'; +import { createAnalyticsService } from './analytics.service'; +import { createAnalyticsController } from './analytics.controller'; + +export function analyticsRoutes(app: FastifyInstance): void { + const service = createAnalyticsService( + createAnalyticsRepository(prisma), + createLinksRepository(prisma), + createCacheService(redis), + ); + const controller = createAnalyticsController(service); + + app.addHook('preHandler', authenticate); + app.get('/links/:id/stats', { preHandler: requireScope('read') }, controller.stats); +} diff --git a/src/modules/analytics/analytics.schemas.ts b/src/modules/analytics/analytics.schemas.ts new file mode 100644 index 0000000..22fcf43 --- /dev/null +++ b/src/modules/analytics/analytics.schemas.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +export const statsParamsSchema = z.object({ + id: z.uuid(), +}); + +export const statsQuerySchema = z.object({ + from: z.iso.datetime().optional(), + to: z.iso.datetime().optional(), + interval: z.enum(['day', 'week']).default('day'), +}); + +export type StatsQuery = z.infer; diff --git a/src/modules/analytics/analytics.service.ts b/src/modules/analytics/analytics.service.ts new file mode 100644 index 0000000..2f5ffeb --- /dev/null +++ b/src/modules/analytics/analytics.service.ts @@ -0,0 +1,87 @@ +import { Errors } from '@/shared/errors/app-error'; +import type { CacheService } from '@/shared/cache/cache.service'; +import type { LinksRepository } from '@/modules/links/links.repository'; +import type { AnalyticsRepository } from './analytics.repository'; +import type { StatsQuery } from './analytics.schemas'; + +const STATS_TTL_SECONDS = 60; +const DEFAULT_WINDOW_DAYS = 30; + +export type AnalyticsService = ReturnType; + +export const createAnalyticsService = ( + analytics: AnalyticsRepository, + links: LinksRepository, + cache: CacheService, +) => { + return { + async getStats(userId: string, linkId: string, query: StatsQuery) { + const link = await links.findById(linkId); + + if (!link || link.userId !== userId) { + throw Errors.notFound('Link'); + } + + const to = query.to ? new Date(query.to) : new Date(); + + const from = query.from + ? new Date(query.from) + : new Date(to.getTime() - DEFAULT_WINDOW_DAYS * 86_400_000); + + if (from >= to) { + throw Errors.badRequest('"from" must be earlier than "to"'); + } + + const cacheKey = ['stats', linkId, from.toISOString(), to.toISOString(), query.interval].join( + ':', + ); + + const cached = await cache.getJson(cacheKey); + + if (cached) { + return cached; + } + + const [total, series, countries, referrers, devices, browsers] = await Promise.all([ + analytics.countClicks(linkId, from, to), + + analytics.timeSeries(linkId, from, to, query.interval), + + analytics.topBy(linkId, from, to, 'country'), + + analytics.topBy(linkId, from, to, 'referrer'), + + analytics.topBy(linkId, from, to, 'device'), + + analytics.topBy(linkId, from, to, 'browser'), + ]); + + const result = { + linkId, + code: link.code, + + range: { + from: from.toISOString(), + to: to.toISOString(), + interval: query.interval, + }, + + totalClicks: total, + + timeseries: series.map((bucket) => ({ + date: new Date(bucket.bucket).toISOString(), + count: bucket.count, + })), + + topCountries: countries, + topReferrers: referrers, + topDevices: devices, + topBrowsers: browsers, + }; + + await cache.setJson(cacheKey, result, STATS_TTL_SECONDS); + + return result; + }, + }; +}; From c22e9c3b5d11d45afbce1bac1a11ccc9c89296de Mon Sep 17 00:00:00 2001 From: Yentec Date: Wed, 27 May 2026 16:59:23 +0200 Subject: [PATCH 2/5] test(analytics): add stats aggregation and ownership tests --- package-lock.json | 4 +- package.json | 2 +- tests/integration/analytics.test.ts | 128 ++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 tests/integration/analytics.test.ts diff --git a/package-lock.json b/package-lock.json index 00edfa7..3532fb4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkforge", - "version": "0.5.1", + "version": "0.5.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "0.5.1", + "version": "0.5.2", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/package.json b/package.json index 381af0a..0e80c65 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "0.5.1", + "version": "0.5.2", "description": "URL shortener API with authentication, API keys, async click tracking and analytics.", "type": "module", "license": "MIT", diff --git a/tests/integration/analytics.test.ts b/tests/integration/analytics.test.ts new file mode 100644 index 0000000..0947f30 --- /dev/null +++ b/tests/integration/analytics.test.ts @@ -0,0 +1,128 @@ +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('Analytics', () => { + 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 setup(): Promise<{ headers: { authorization: string }; linkId: string }> { + 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 created = await app.inject({ + method: 'POST', + url: '/v1/links', + headers, + payload: { target: 'https://example.com' }, + }); + return { headers, linkId: created.json<{ id: string }>().id }; + } + + async function seedClicks(linkId: string): Promise { + const base = (overrides: Record) => ({ + linkId, + deviceType: 'desktop', + ipHash: 'abc0123456789def', + country: null, + browser: null, + referrerHost: null, + ...overrides, + }); + await prisma.click.createMany({ + data: [ + base({ country: 'FR', browser: 'Chrome', createdAt: new Date('2026-01-01T10:00:00Z') }), + base({ country: 'FR', browser: 'Firefox', createdAt: new Date('2026-01-01T12:00:00Z') }), + base({ country: 'US', browser: 'Chrome', createdAt: new Date('2026-01-02T09:00:00Z') }), + base({ country: 'FR', deviceType: 'mobile', createdAt: new Date('2026-01-02T11:00:00Z') }), + ], + }); + } + + it('returns aggregated stats with totals, series and top dimensions', async () => { + const { headers, linkId } = await setup(); + await seedClicks(linkId); + + const res = await app.inject({ + method: 'GET', + url: `/v1/links/${linkId}/stats?from=2025-12-01T00:00:00Z&to=2026-02-01T00:00:00Z&interval=day`, + headers, + }); + expect(res.statusCode).toBe(200); + type StatsBody = { + totalClicks: number; + timeseries: Array<{ count: number }>; + topCountries: Array<{ key: string; count: number }>; + topBrowsers: Array<{ key: string; count: number }>; + topDevices: Array<{ key: string; count: number }>; + }; + const body = res.json(); + + expect(body.totalClicks).toBe(4); + expect(body.timeseries).toHaveLength(2); // Jan 1 and Jan 2 + expect(body.timeseries[0]).toMatchObject({ count: 2 }); + + // FR appears 3 times, US once -> FR first. + expect(body.topCountries[0]).toEqual({ key: 'FR', count: 3 }); + expect(body.topBrowsers.find((r: { key: string }) => r.key === 'unknown')?.count).toBe(1); + expect(body.topDevices).toEqual( + expect.arrayContaining([ + { key: 'desktop', count: 3 }, + { key: 'mobile', count: 1 }, + ]), + ); + }); + + it('returns zeros for a link with no clicks', async () => { + const { headers, linkId } = await setup(); + const res = await app.inject({ method: 'GET', url: `/v1/links/${linkId}/stats`, headers }); + expect(res.statusCode).toBe(200); + const body = res.json<{ totalClicks: number; timeseries: unknown[] }>(); + expect(body.totalClicks).toBe(0); + expect(body.timeseries).toEqual([]); + }); + + it("returns 404 for another user's link", async () => { + const { linkId } = await setup(); + const intruder = await app.inject({ + method: 'POST', + url: '/v1/auth/register', + payload: { email: 'intruder@example.com', password: 'SuperSecret123' }, + }); + const res = await app.inject({ + method: 'GET', + url: `/v1/links/${linkId}/stats`, + headers: { authorization: `Bearer ${intruder.json<{ accessToken: string }>().accessToken}` }, + }); + expect(res.statusCode).toBe(404); + }); + + it('rejects an inverted date range with 400', async () => { + const { headers, linkId } = await setup(); + const res = await app.inject({ + method: 'GET', + url: `/v1/links/${linkId}/stats?from=2026-02-01T00:00:00Z&to=2026-01-01T00:00:00Z`, + headers, + }); + expect(res.statusCode).toBe(400); + }); +}); From 0c74dac9846e3625e9b27676a0ff9694fbc10dbc Mon Sep 17 00:00:00 2001 From: Yentec Date: Wed, 27 May 2026 17:25:30 +0200 Subject: [PATCH 3/5] feat(docs): derive openapi from zod and serve scalar reference --- package-lock.json | 235 +++++++++++++++++++++++++++++++- package.json | 3 +- src/app.ts | 12 +- src/docs/openapi.ts | 193 ++++++++++++++++++++++++++ src/modules/docs/docs.routes.ts | 7 + vitest.config.ts | 2 +- 6 files changed, 445 insertions(+), 7 deletions(-) create mode 100644 src/docs/openapi.ts create mode 100644 src/modules/docs/docs.routes.ts diff --git a/package-lock.json b/package-lock.json index 3532fb4..6b3aae6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { "name": "linkforge", - "version": "0.5.2", + "version": "0.5.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "0.5.2", + "version": "0.5.3", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", "@fastify/helmet": "^13.0.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", + "@scalar/fastify-api-reference": "^1.57.5", "argon2": "^0.44.0", "bullmq": "^5.77.6", "dotenv": "^17.4.2", @@ -2355,6 +2356,180 @@ "win32" ] }, + "node_modules/@scalar/client-side-rendering": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/@scalar/client-side-rendering/-/client-side-rendering-0.1.12.tgz", + "integrity": "sha512-prwHK4ozTU268BHZ/5OstoKB23JSidDuvddAOp0bVz9c29ZxsyzzxPtPcVgF7X16LiZnS1OzY030FoDCM+iC9Q==", + "license": "MIT", + "dependencies": { + "@scalar/schemas": "0.3.2", + "@scalar/types": "0.12.2", + "@scalar/validation": "0.6.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/fastify-api-reference": { + "version": "1.57.5", + "resolved": "https://registry.npmjs.org/@scalar/fastify-api-reference/-/fastify-api-reference-1.57.5.tgz", + "integrity": "sha512-GtkU5lT3hX5ID+IQa1DXfMk2pdhNVdEBpjNrkJ4PHtaTqRTMMTjvv86jWoAc0IZ3q/9Gnbbe852j8Lrnxa2HwQ==", + "license": "MIT", + "dependencies": { + "@scalar/client-side-rendering": "0.1.12", + "@scalar/openapi-parser": "0.28.5", + "@scalar/openapi-types": "0.9.0", + "fastify-plugin": "^4.5.1", + "github-slugger": "2.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/fastify-api-reference/node_modules/fastify-plugin": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz", + "integrity": "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==", + "license": "MIT" + }, + "node_modules/@scalar/helpers": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@scalar/helpers/-/helpers-0.8.0.tgz", + "integrity": "sha512-gmOC6VravNB9VDl6wnt/GOj4K/hn48tj5bpW4AM4MhH8Ubil6uu7g1DSoKHwltu8Ks79KEtR6JmOrROi9R7jaQ==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/json-magic": { + "version": "0.12.14", + "resolved": "https://registry.npmjs.org/@scalar/json-magic/-/json-magic-0.12.14.tgz", + "integrity": "sha512-dWrCy3ew1r7OQ1pu2r4ZjiKEVy0yVd66kXdmsl41bteOG2F2I2IBlPjmPV6p8ckjImQHxtNBIntFaQfNrdBhJg==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.8.0", + "pathe": "^2.0.3", + "yaml": "^2.8.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/openapi-parser": { + "version": "0.28.5", + "resolved": "https://registry.npmjs.org/@scalar/openapi-parser/-/openapi-parser-0.28.5.tgz", + "integrity": "sha512-W8uS5p4dR5Ri4Zqra6c/KaL54R5qkLJAwXCm/QTYvzsLw/NT4N6NdbqHQLHpJeLMG9c0dkA70S/KZKkFZgDRTg==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.8.0", + "@scalar/json-magic": "0.12.14", + "@scalar/openapi-types": "0.9.0", + "@scalar/openapi-upgrader": "0.2.8", + "ajv": "^8.17.1", + "ajv-draft-04": "^1.0.0", + "ajv-formats": "^3.0.1", + "jsonpointer": "^5.0.1", + "leven": "^4.0.0", + "yaml": "^2.8.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/openapi-parser/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@scalar/openapi-parser/node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@scalar/openapi-parser/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/@scalar/openapi-types": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@scalar/openapi-types/-/openapi-types-0.9.0.tgz", + "integrity": "sha512-XGuYegItO8iJbEy5hHxS04vyxoFIuERy3D/twt1hGPya6KJljg9iO0geZ/X5vjDKmSKYAuMNkGqYO5Jjp4NLUA==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/openapi-upgrader": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@scalar/openapi-upgrader/-/openapi-upgrader-0.2.8.tgz", + "integrity": "sha512-/SDb3+SIFuScKwrNRTFd7bklGDkayo81cHZlajXfUaT22Oc4VUlOUvyMXBLPa0miOfZTExYTM1I0BWW7Nujugw==", + "license": "MIT", + "dependencies": { + "@scalar/openapi-types": "0.9.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/schemas": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@scalar/schemas/-/schemas-0.3.2.tgz", + "integrity": "sha512-iadXBgJ02XUU5C5s6/xh/PmGLzUPd7X8upXIvPWBXDcQ4FHACNgkG8PPZ/beYM8UPDDkTUPM3ygEs0G6jKwGjQ==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.8.0", + "@scalar/validation": "0.6.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/types": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@scalar/types/-/types-0.12.2.tgz", + "integrity": "sha512-EzLkubCb7xioiTm9eYnmn/032akaq4kkrrdclgV2uezwtniR8ErQICjhMl2AjBWL6nstHiFZ9RnPZm2Z2/KM0Q==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.8.0", + "nanoid": "^5.1.6", + "type-fest": "^5.3.1", + "zod": "^4.3.5" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/validation": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@scalar/validation/-/validation-0.6.0.tgz", + "integrity": "sha512-tpmmG+/xRE2Kn9RpflU3AIyZv08v10+E1ZrJCx7z6+/91zHVxy0M73kC1LT4/8PbYNt85ywyC8+n+D99JdMcGA==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -4796,6 +4971,12 @@ "giget": "dist/cli.mjs" } }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -5240,6 +5421,15 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5296,6 +5486,18 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/leven": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-4.1.0.tgz", + "integrity": "sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -6159,7 +6361,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "devOptional": true, "license": "MIT" }, "node_modules/perfect-debounce": { @@ -7449,6 +7650,18 @@ "node": ">=8" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tar-fs": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", @@ -8264,6 +8477,21 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", + "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -8741,7 +8969,6 @@ "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/package.json b/package.json index 0e80c65..d24359e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "0.5.2", + "version": "0.5.3", "description": "URL shortener API with authentication, API keys, async click tracking and analytics.", "type": "module", "license": "MIT", @@ -33,6 +33,7 @@ "@fastify/helmet": "^13.0.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", + "@scalar/fastify-api-reference": "^1.57.5", "argon2": "^0.44.0", "bullmq": "^5.77.6", "dotenv": "^17.4.2", diff --git a/src/app.ts b/src/app.ts index b6521b8..078fb54 100644 --- a/src/app.ts +++ b/src/app.ts @@ -10,6 +10,7 @@ import { apiKeyRoutes } from './modules/api-keys/api-keys.routes'; import { linkRoutes } from './modules/links/links.routes'; import { redirectRoutes } from './modules/redirect/redirect.routes'; import { analyticsRoutes } from './modules/analytics/analytics.routes'; +import { docsRoutes } from './modules/docs/docs.routes'; /** * Builds a fully configured Fastify instance without starting the server. @@ -22,12 +23,21 @@ export async function buildApp(): Promise { trustProxy: true, }); - await app.register(helmet); + // CSP disabled: the only HTML we serve is the Scalar docs UI, which uses inline + // scripts. A pure JSON API gains little from CSP; all other helmet headers stay on. + await app.register(helmet, { contentSecurityPolicy: false }); await app.register(cors, { origin: true }); registerErrorHandler(app); await app.register(healthRoutes); + await app.register(docsRoutes); + + // Interactive API reference at /docs, reading the derived OpenAPI document. + await app.register(import('@scalar/fastify-api-reference'), { + routePrefix: '/docs', + configuration: { url: '/openapi.json', title: 'LinkForge API' }, + }); await app.register( (v1) => { diff --git a/src/docs/openapi.ts b/src/docs/openapi.ts new file mode 100644 index 0000000..4e7aff0 --- /dev/null +++ b/src/docs/openapi.ts @@ -0,0 +1,193 @@ +import { z } from 'zod'; +import { registerSchema, refreshSchema } from '@/modules/auth/auth.schemas'; +import { createApiKeySchema } from '@/modules/api-keys/api-keys.schemas'; +import { createLinkSchema, updateLinkSchema } from '@/modules/links/links.schemas'; +import { statsQuerySchema } from '@/modules/analytics/analytics.schemas'; + +// Single source of truth: request shapes come straight from the Zod schemas. +const toJson = (schema: z.ZodType): Record => + z.toJSONSchema(schema, { target: 'openapi-3.0', unrepresentable: 'any' }); + +const json = { 'application/json': { schema: { type: 'object' } } } as const; +const bodyOf = (schema: z.ZodType) => ({ + required: true, + content: { 'application/json': { schema: toJson(schema) } }, +}); + +const errorResponse = { + description: 'Error', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + error: { + type: 'object', + properties: { code: { type: 'string' }, message: { type: 'string' } }, + }, + }, + }, + }, + }, +} as const; + +export function buildOpenApiDocument(): Record { + return { + openapi: '3.0.3', + info: { + title: 'LinkForge API', + version: '1.0.0', + description: + 'URL shortener API with authentication, API keys, anonymized async click tracking and analytics.', + }, + servers: [{ url: '/' }], + components: { + securitySchemes: { + bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, + apiKeyAuth: { type: 'apiKey', in: 'header', name: 'X-API-Key' }, + }, + }, + security: [{ bearerAuth: [] }, { apiKeyAuth: [] }], + paths: { + '/v1/auth/register': { + post: { + tags: ['Auth'], + summary: 'Register a new user', + security: [], + requestBody: bodyOf(registerSchema), + responses: { 201: { description: 'Token pair', content: json }, 409: errorResponse }, + }, + }, + '/v1/auth/login': { + post: { + tags: ['Auth'], + summary: 'Log in', + security: [], + requestBody: bodyOf(registerSchema), + responses: { 200: { description: 'Token pair', content: json }, 401: errorResponse }, + }, + }, + '/v1/auth/refresh': { + post: { + tags: ['Auth'], + summary: 'Rotate the refresh token', + security: [], + requestBody: bodyOf(refreshSchema), + responses: { 200: { description: 'New token pair', content: json }, 401: errorResponse }, + }, + }, + '/v1/auth/logout': { + post: { + tags: ['Auth'], + summary: 'Revoke a refresh token', + security: [], + requestBody: bodyOf(refreshSchema), + responses: { 204: { description: 'Logged out' } }, + }, + }, + '/v1/auth/me': { + get: { + tags: ['Auth'], + summary: 'Current identity', + responses: { 200: { description: 'Identity', content: json }, 401: errorResponse }, + }, + }, + '/v1/api-keys': { + post: { + tags: ['API Keys'], + summary: 'Create an API key (raw value returned once)', + requestBody: bodyOf(createApiKeySchema), + responses: { 201: { description: 'Created key', content: json } }, + }, + get: { + tags: ['API Keys'], + summary: 'List API keys', + responses: { 200: { description: 'Keys', content: json } }, + }, + }, + '/v1/api-keys/{id}': { + delete: { + tags: ['API Keys'], + summary: 'Revoke an API key', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { 204: { description: 'Revoked' }, 404: errorResponse }, + }, + }, + '/v1/links': { + post: { + tags: ['Links'], + summary: 'Create a short link', + parameters: [ + { + name: 'Idempotency-Key', + in: 'header', + required: false, + schema: { type: 'string' }, + }, + ], + requestBody: bodyOf(createLinkSchema), + responses: { + 201: { description: 'Created link', content: json }, + 400: errorResponse, + 409: errorResponse, + }, + }, + get: { + tags: ['Links'], + summary: 'List links (cursor paginated)', + parameters: [ + { name: 'cursor', in: 'query', required: false, schema: { type: 'string' } }, + { + name: 'limit', + in: 'query', + required: false, + schema: { type: 'integer', minimum: 1, maximum: 100, default: 20 }, + }, + ], + responses: { 200: { description: 'Page of links', content: json } }, + }, + }, + '/v1/links/{id}': { + patch: { + tags: ['Links'], + summary: 'Update a link', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: bodyOf(updateLinkSchema), + responses: { 200: { description: 'Updated link', content: json }, 404: errorResponse }, + }, + delete: { + tags: ['Links'], + summary: 'Soft-delete a link', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { 204: { description: 'Deleted' }, 404: errorResponse }, + }, + }, + '/v1/links/{id}/stats': { + get: { + tags: ['Analytics'], + summary: 'Click analytics for a link', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + ...Object.entries( + (toJson(statsQuerySchema)['properties'] as Record) ?? {}, + ).map(([name, schema]) => ({ name, in: 'query', required: false, schema })), + ], + responses: { 200: { description: 'Stats', content: json }, 404: errorResponse }, + }, + }, + '/{code}': { + get: { + tags: ['Redirect'], + summary: 'Public redirect to the target URL', + security: [], + parameters: [{ name: 'code', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + 302: { description: 'Redirect' }, + 404: errorResponse, + 410: errorResponse, + }, + }, + }, + }, + }; +} diff --git a/src/modules/docs/docs.routes.ts b/src/modules/docs/docs.routes.ts new file mode 100644 index 0000000..516d886 --- /dev/null +++ b/src/modules/docs/docs.routes.ts @@ -0,0 +1,7 @@ +import type { FastifyInstance } from 'fastify'; +import { buildOpenApiDocument } from '@/docs/openapi'; + +export function docsRoutes(app: FastifyInstance): void { + const document = buildOpenApiDocument(); + app.get('/openapi.json', () => document); +} diff --git a/vitest.config.ts b/vitest.config.ts index f0a5a57..71fd0e1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,7 +14,7 @@ export default defineConfig({ coverage: { provider: 'v8', include: ['src/**'], - exclude: ['src/server.ts', 'src/config/**', 'src/**/*.routes.ts'], + exclude: ['src/server.ts', 'src/config/**', 'src/docs/**', 'src/**/*.routes.ts'], }, }, }); From 0f07483074aa5f866f52db5c7322f6290c2d55bc Mon Sep 17 00:00:00 2001 From: Yentec Date: Wed, 27 May 2026 17:32:37 +0200 Subject: [PATCH 4/5] feat(security): add redis-backed rate limiting --- package-lock.json | 35 +++++++++++++++++++++++++++++++++-- package.json | 3 ++- src/app.ts | 18 ++++++++++++++++++ 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6b3aae6..84ef7fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "linkforge", - "version": "0.5.3", + "version": "0.5.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "0.5.3", + "version": "0.5.4", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", "@fastify/helmet": "^13.0.2", + "@fastify/rate-limit": "^10.3.0", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@scalar/fastify-api-reference": "^1.57.5", @@ -926,6 +927,27 @@ "ipaddr.js": "^2.1.0" } }, + "node_modules/@fastify/rate-limit": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz", + "integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, "node_modules/@grpc/grpc-js": { "version": "1.14.4", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", @@ -1148,6 +1170,15 @@ "debug": "^4.1.1" } }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "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", diff --git a/package.json b/package.json index d24359e..57adc53 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "0.5.3", + "version": "0.5.4", "description": "URL shortener API with authentication, API keys, async click tracking and analytics.", "type": "module", "license": "MIT", @@ -31,6 +31,7 @@ "dependencies": { "@fastify/cors": "^11.2.0", "@fastify/helmet": "^13.0.2", + "@fastify/rate-limit": "^10.3.0", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@scalar/fastify-api-reference": "^1.57.5", diff --git a/src/app.ts b/src/app.ts index 078fb54..9173a1b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -11,6 +11,8 @@ import { linkRoutes } from './modules/links/links.routes'; import { redirectRoutes } from './modules/redirect/redirect.routes'; import { analyticsRoutes } from './modules/analytics/analytics.routes'; import { docsRoutes } from './modules/docs/docs.routes'; +import { redis } from '@/shared/cache/redis'; +import rateLimit from '@fastify/rate-limit'; /** * Builds a fully configured Fastify instance without starting the server. @@ -28,6 +30,22 @@ export async function buildApp(): Promise { await app.register(helmet, { contentSecurityPolicy: false }); await app.register(cors, { origin: true }); + // ... après await app.register(cors, ...) : + await app.register(rateLimit, { + max: 100, + timeWindow: '1 minute', + redis, + // Key by API key when present, else by IP. + keyGenerator: (request) => + typeof request.headers['x-api-key'] === 'string' ? request.headers['x-api-key'] : request.ip, + // Don't rate-limit health probes or the docs UI. + allowList: (request) => + request.url.startsWith('/health') || + request.url.startsWith('/ready') || + request.url.startsWith('/docs') || + request.url === '/openapi.json', + }); + registerErrorHandler(app); await app.register(healthRoutes); From cd000cb6add096dfe742151700299e08a89280eb Mon Sep 17 00:00:00 2001 From: Yentec Date: Wed, 27 May 2026 17:39:31 +0200 Subject: [PATCH 5/5] chore(release): 0.6.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 84ef7fc..fe920f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkforge", - "version": "0.5.4", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "0.5.4", + "version": "0.6.0", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/package.json b/package.json index 57adc53..172eb75 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "0.5.4", + "version": "0.6.0", "description": "URL shortener API with authentication, API keys, async click tracking and analytics.", "type": "module", "license": "MIT",