From e5238f63b4a6ea7a56cb8756fa73760d37eba4ef Mon Sep 17 00:00:00 2001 From: Yentec Date: Thu, 28 May 2026 17:29:55 +0200 Subject: [PATCH 1/5] feat(security): tighten rate limit on register and login --- package-lock.json | 4 ++-- package.json | 2 +- src/app.ts | 3 ++- src/modules/auth/auth.routes.ts | 7 +++++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index a357cd0..3326cf0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkforge", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/package.json b/package.json index 576f94c..caf513e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "1.0.0", + "version": "1.0.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 9173a1b..ffb3821 100644 --- a/src/app.ts +++ b/src/app.ts @@ -38,8 +38,9 @@ export async function buildApp(): Promise { // 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. + // Don't rate-limit health probes, docs UI, or the test suite. allowList: (request) => + process.env['NODE_ENV'] === 'test' || request.url.startsWith('/health') || request.url.startsWith('/ready') || request.url.startsWith('/docs') || diff --git a/src/modules/auth/auth.routes.ts b/src/modules/auth/auth.routes.ts index a91f547..7be29cb 100644 --- a/src/modules/auth/auth.routes.ts +++ b/src/modules/auth/auth.routes.ts @@ -8,8 +8,11 @@ import { createAuthController } from './auth.controller'; export function authRoutes(app: FastifyInstance): void { const controller = createAuthController(createAuthService(createAuthRepository(prisma))); - app.post('/auth/register', controller.register); - app.post('/auth/login', controller.login); + // Stricter than the global limit: account creation and login are abuse-prone. + const strict = { rateLimit: { max: 10, timeWindow: '1 minute' } }; + + app.post('/auth/register', { config: strict }, controller.register); + app.post('/auth/login', { config: strict }, controller.login); app.post('/auth/refresh', controller.refresh); app.post('/auth/logout', controller.logout); app.get('/auth/me', { preHandler: authenticate }, controller.me); From 7919ba35150c332e43018fba7591f95802cdba76 Mon Sep 17 00:00:00 2001 From: Yentec Date: Thu, 28 May 2026 17:36:18 +0200 Subject: [PATCH 2/5] feat(links): add optional env-gated default link ttl --- .env.example | 5 ++++- package-lock.json | 4 ++-- package.json | 2 +- render.yaml | 2 ++ src/config/env.ts | 4 ++++ src/modules/links/links.service.ts | 7 ++++++- 6 files changed, 19 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 3f822d7..b1d5faf 100644 --- a/.env.example +++ b/.env.example @@ -13,4 +13,7 @@ REDIS_URL="redis://localhost:6379" # Secrets — replace before running. JWT secrets >= 32 chars, IP salt >= 16 chars. JWT_SECRET="dev-only-change-me-to-a-32-char-minimum-secret" -IP_HASH_SALT="dev-only-16-chars-min" \ No newline at end of file +IP_HASH_SALT="dev-only-16-chars-min" + +# Default link lifetime in days (0 = never expires). Demo uses 7. +LINK_DEFAULT_TTL_DAYS=0 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3326cf0..89a07ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkforge", - "version": "1.0.1", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/package.json b/package.json index caf513e..14f7b9a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "1.0.1", + "version": "1.0.2", "description": "URL shortener API with authentication, API keys, async click tracking and analytics.", "type": "module", "license": "MIT", diff --git a/render.yaml b/render.yaml index 9071ae7..cd3e9d2 100644 --- a/render.yaml +++ b/render.yaml @@ -28,3 +28,5 @@ services: generateValue: true - key: IP_HASH_SALT generateValue: true + - key: LINK_DEFAULT_TTL_DAYS + value: 7 diff --git a/src/config/env.ts b/src/config/env.ts index 8e0c222..ee8f3cf 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -16,6 +16,10 @@ const envSchema = z.object({ JWT_SECRET: z.string().min(32), IP_HASH_SALT: z.string().min(16), + + // Default link lifetime in days when the client provides no expiresAt. + // 0 disables the default (links never expire). Set to e.g. 7 in production. + LINK_DEFAULT_TTL_DAYS: z.coerce.number().int().min(0).default(0), }); const parsed = envSchema.safeParse(process.env); diff --git a/src/modules/links/links.service.ts b/src/modules/links/links.service.ts index 1831fb4..c57eef2 100644 --- a/src/modules/links/links.service.ts +++ b/src/modules/links/links.service.ts @@ -74,11 +74,16 @@ export function createLinksService(repo: LinksRepository, cache: CacheService) { const code = input.customSlug ?? (await generateUniqueCode()); + const defaultExpiry = + env.LINK_DEFAULT_TTL_DAYS > 0 + ? new Date(Date.now() + env.LINK_DEFAULT_TTL_DAYS * 86_400_000) + : null; + const link = await repo.create({ code, target: input.target, userId, - expiresAt: input.expiresAt ? new Date(input.expiresAt) : null, + expiresAt: input.expiresAt ? new Date(input.expiresAt) : defaultExpiry, }); if (idempotencyKey) { From 0f378fcc7d5c11d90c048d5a35c836ffd248ab76 Mon Sep 17 00:00:00 2001 From: Yentec Date: Thu, 28 May 2026 17:46:48 +0200 Subject: [PATCH 3/5] feat(maintenance): add scheduled cleanup of expired links and stale users --- package-lock.json | 4 +- package.json | 2 +- .../maintenance/maintenance.processor.ts | 18 +++++ src/modules/maintenance/maintenance.queue.ts | 39 +++++++++++ .../maintenance/maintenance.repository.ts | 19 +++++ src/modules/maintenance/maintenance.types.ts | 6 ++ src/modules/maintenance/maintenance.worker.ts | 32 +++++++++ src/server.ts | 9 +++ tests/integration/cleanup.test.ts | 70 +++++++++++++++++++ 9 files changed, 196 insertions(+), 3 deletions(-) create mode 100644 src/modules/maintenance/maintenance.processor.ts create mode 100644 src/modules/maintenance/maintenance.queue.ts create mode 100644 src/modules/maintenance/maintenance.repository.ts create mode 100644 src/modules/maintenance/maintenance.types.ts create mode 100644 src/modules/maintenance/maintenance.worker.ts create mode 100644 tests/integration/cleanup.test.ts diff --git a/package-lock.json b/package-lock.json index 89a07ff..a50a1bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkforge", - "version": "1.0.2", + "version": "1.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "1.0.2", + "version": "1.0.3", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/package.json b/package.json index 14f7b9a..1710a86 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "1.0.2", + "version": "1.0.3", "description": "URL shortener API with authentication, API keys, async click tracking and analytics.", "type": "module", "license": "MIT", diff --git a/src/modules/maintenance/maintenance.processor.ts b/src/modules/maintenance/maintenance.processor.ts new file mode 100644 index 0000000..cdd50a2 --- /dev/null +++ b/src/modules/maintenance/maintenance.processor.ts @@ -0,0 +1,18 @@ +import type { Job } from 'bullmq'; +import { logger } from '@/config/logger'; +import type { MaintenanceRepository } from './maintenance.repository'; +import type { CleanupJobData } from './maintenance.types'; + +const STALE_USER_DAYS = 7; + +export function createCleanupProcessor(repo: MaintenanceRepository) { + return async (_job: Job): Promise => { + const now = new Date(); + const cutoff = new Date(now.getTime() - STALE_USER_DAYS * 86_400_000); + + const expired = await repo.deleteExpiredLinks(now); + const stale = await repo.deleteStaleUsers(cutoff); + + logger.info({ expiredLinks: expired.count, staleUsers: stale.count }, 'Cleanup run complete'); + }; +} diff --git a/src/modules/maintenance/maintenance.queue.ts b/src/modules/maintenance/maintenance.queue.ts new file mode 100644 index 0000000..92093c0 --- /dev/null +++ b/src/modules/maintenance/maintenance.queue.ts @@ -0,0 +1,39 @@ +import { Queue } from 'bullmq'; +import { logger } from '@/config/logger'; +import { createQueueConnection } from '@/shared/queue/connection'; +import { + CLEANUP_SCHEDULER_ID, + MAINTENANCE_QUEUE_NAME, + type CleanupJobData, +} from './maintenance.types'; + +let queue: Queue | null = null; + +function getQueue(): Queue { + queue ??= new Queue(MAINTENANCE_QUEUE_NAME, { + connection: createQueueConnection(), + defaultJobOptions: { removeOnComplete: 50, removeOnFail: 50 }, + }); + return queue; +} + +/** Idempotent: upserting the same scheduler id just updates the schedule. */ +export async function scheduleCleanup(everyMs: number): Promise { + try { + await getQueue().upsertJobScheduler( + CLEANUP_SCHEDULER_ID, + { every: everyMs }, + { name: 'cleanup', data: { reason: 'scheduled' } }, + ); + logger.info({ everyMs }, 'Cleanup scheduler registered'); + } catch (err) { + logger.error({ err }, 'Failed to register cleanup scheduler'); + } +} + +export async function closeMaintenanceQueue(): Promise { + if (queue) { + await queue.close(); + queue = null; + } +} diff --git a/src/modules/maintenance/maintenance.repository.ts b/src/modules/maintenance/maintenance.repository.ts new file mode 100644 index 0000000..1316720 --- /dev/null +++ b/src/modules/maintenance/maintenance.repository.ts @@ -0,0 +1,19 @@ +import type { PrismaClient } from '@prisma/client'; + +const DEMO_EMAIL = 'demo@linkforge.dev'; + +export type MaintenanceRepository = ReturnType; + +export function createMaintenanceRepository(db: PrismaClient) { + return { + deleteExpiredLinks: (now: Date) => + db.link.deleteMany({ + where: { expiresAt: { not: null, lt: now } }, + }), + + deleteStaleUsers: (cutoff: Date) => + db.user.deleteMany({ + where: { email: { not: DEMO_EMAIL }, createdAt: { lt: cutoff } }, + }), + }; +} diff --git a/src/modules/maintenance/maintenance.types.ts b/src/modules/maintenance/maintenance.types.ts new file mode 100644 index 0000000..c9f1468 --- /dev/null +++ b/src/modules/maintenance/maintenance.types.ts @@ -0,0 +1,6 @@ +export const MAINTENANCE_QUEUE_NAME = 'maintenance'; +export const CLEANUP_SCHEDULER_ID = 'cleanup-scheduler'; + +export interface CleanupJobData { + reason: 'scheduled'; +} diff --git a/src/modules/maintenance/maintenance.worker.ts b/src/modules/maintenance/maintenance.worker.ts new file mode 100644 index 0000000..df65eb5 --- /dev/null +++ b/src/modules/maintenance/maintenance.worker.ts @@ -0,0 +1,32 @@ +import { Worker } from 'bullmq'; +import { logger } from '@/config/logger'; +import { prisma } from '@/shared/db'; +import { createQueueConnection } from '@/shared/queue/connection'; +import { MAINTENANCE_QUEUE_NAME, type CleanupJobData } from './maintenance.types'; +import { createMaintenanceRepository } from './maintenance.repository'; +import { createCleanupProcessor } from './maintenance.processor'; + +let worker: Worker | null = null; + +export function startMaintenanceWorker(): Worker { + if (worker) return worker; + + const processor = createCleanupProcessor(createMaintenanceRepository(prisma)); + worker = new Worker(MAINTENANCE_QUEUE_NAME, processor, { + connection: createQueueConnection(), + concurrency: 1, + }); + + worker.on('failed', (job, err) => { + logger.error({ jobId: job?.id, err }, 'Cleanup job failed'); + }); + + return worker; +} + +export async function stopMaintenanceWorker(): Promise { + if (worker) { + await worker.close(); + worker = null; + } +} diff --git a/src/server.ts b/src/server.ts index d962c84..0a8d8aa 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,17 +6,26 @@ 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'; +import { + startMaintenanceWorker, + stopMaintenanceWorker, +} from '@/modules/maintenance/maintenance.worker'; +import { scheduleCleanup, closeMaintenanceQueue } from '@/modules/maintenance/maintenance.queue'; async function start(): Promise { const app = await buildApp(); startClickWorker(); + startMaintenanceWorker(); + await scheduleCleanup(60 * 60 * 1000); // hourly const shutdown = async (signal: string): Promise => { logger.info({ signal }, 'Shutting down'); await app.close(); await stopClickWorker(); await closeClickQueue(); + await stopMaintenanceWorker(); + await closeMaintenanceQueue(); await disconnectDb(); redis.disconnect(); process.exit(0); diff --git a/tests/integration/cleanup.test.ts b/tests/integration/cleanup.test.ts new file mode 100644 index 0000000..3b544e6 --- /dev/null +++ b/tests/integration/cleanup.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 { createMaintenanceRepository } from '@/modules/maintenance/maintenance.repository'; +import { createCleanupProcessor } from '@/modules/maintenance/maintenance.processor'; +import type { CleanupJobData } from '@/modules/maintenance/maintenance.types'; + +describe('Cleanup processor', () => { + const processor = createCleanupProcessor(createMaintenanceRepository(prisma)); + const job = { data: { reason: 'scheduled' } } as Job; + + beforeAll(async () => { + await buildTestApp(); + }); + + afterAll(async () => { + await prisma.$disconnect(); + redis.disconnect(); + }); + + beforeEach(resetDb); + + it('deletes expired links but keeps valid ones', async () => { + const user = await prisma.user.create({ + data: { email: 'u@example.com', password: 'x' }, + }); + await prisma.link.create({ + data: { + code: 'expired', + target: 'https://e.com', + userId: user.id, + expiresAt: new Date(Date.now() - 1000), + }, + }); + await prisma.link.create({ + data: { + code: 'valid01', + target: 'https://e.com', + userId: user.id, + expiresAt: new Date(Date.now() + 86_400_000), + }, + }); + await prisma.link.create({ + data: { code: 'forever', target: 'https://e.com', userId: user.id }, + }); + + await processor(job); + + const remaining = await prisma.link.findMany(); + expect(remaining.map((l) => l.code).sort()).toEqual(['forever', 'valid01']); + }); + + it('prunes stale non-demo users but keeps the demo account', async () => { + const old = new Date(Date.now() - 8 * 86_400_000); + await prisma.user.create({ + data: { email: 'demo@linkforge.dev', password: 'x', createdAt: old }, + }); + await prisma.user.create({ + data: { email: 'stale@example.com', password: 'x', createdAt: old }, + }); + await prisma.user.create({ data: { email: 'recent@example.com', password: 'x' } }); + + await processor(job); + + const emails = (await prisma.user.findMany()).map((u) => u.email).sort(); + expect(emails).toEqual(['demo@linkforge.dev', 'recent@example.com']); + }); +}); From 1d9f1265e5e2d3847f8810c783528798e8ada737 Mon Sep 17 00:00:00 2001 From: Yentec Date: Thu, 28 May 2026 17:50:19 +0200 Subject: [PATCH 4/5] chore(release): 1.1.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 a50a1bb..d9c91ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkforge", - "version": "1.0.3", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "1.0.3", + "version": "1.1.0", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/package.json b/package.json index 1710a86..4897151 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "1.0.3", + "version": "1.1.0", "description": "URL shortener API with authentication, API keys, async click tracking and analytics.", "type": "module", "license": "MIT", From 715186ea29895b038302c9fd12b4a839c2dad2a8 Mon Sep 17 00:00:00 2001 From: Yentec Date: Thu, 28 May 2026 17:54:44 +0200 Subject: [PATCH 5/5] fix(docs): update readme and claude --- CLAUDE.md | 3 ++- README.md | 1 - package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a736d00..8f5ee74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,12 +73,13 @@ src/ │ ├── analytics/ # agrégations SQL, stats cachées │ ├── docs/ # montage de la route /openapi.json + Scalar │ ├── health/ # /health (liveness), /ready (readiness) +│ ├── maintenance/ # cron de nettoyage (liens expirés, comptes anciens) │ └── / # .routes .controller .service .repository .schemas .types ├── shared/ │ ├── auth/ # password (Argon2id), jwt (jose), tokens (opaques + sha256) │ ├── cache/ # connexion Redis (ioredis) + CacheService JSON │ ├── errors/ # AppError + error-handler global -│ ├── middleware/ # authenticate, idempotency (rate-limit optionnel) +│ ├── middleware/ # authenticate, idempotency (rate-limit) │ ├── queue/ # factory de connexion BullMQ │ ├── geo/ # résolveur de pays best-effort (null par défaut, pluggable) │ └── utils/ # short-code (nanoid), classifieur UA maison, validateur URL anti-SSRF diff --git a/README.md b/README.md index d8f5842..1996567 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,6 @@ A few choices that aren't obvious from the code: Not implemented, deliberately: -- Rate limiting per API key (Redis sliding window) — easy add via `@fastify/rate-limit`. - Webhook on click events, signed with HMAC-SHA256. - Real GeoIP via MaxMind GeoLite2, behind an env flag. - Refresh-token-reuse detection: revoke the entire token chain on replay. diff --git a/package-lock.json b/package-lock.json index d9c91ed..4d01948 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkforge", - "version": "1.1.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "1.1.0", + "version": "1.1.1", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/package.json b/package.json index 4897151..58cc055 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "1.1.0", + "version": "1.1.1", "description": "URL shortener API with authentication, API keys, async click tracking and analytics.", "type": "module", "license": "MIT",