diff --git a/CLAUDE.md b/CLAUDE.md index 7081da5..a736d00 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,43 +4,43 @@ Contexte projet pour Claude Code. À lire avant toute modification. ## Projet -**LinkForge** — API de raccourcissement d'URL avec authentification, clés API, tracking de clics anonymisé (RGPD) et analytics agrégées. Backend pur. Pas de frontend (hors mini-dashboard de démo optionnel). +**LinkForge** — API de raccourcissement d'URL avec authentification, clés API, tracking de clics anonymisé (RGPD) et analytics agrégées. Backend pur. Pas de frontend. Projet portfolio à finalité de recrutement. Scope volontairement borné (~5–7 jours). Privilégier la qualité d'exécution à l'étendue fonctionnelle. ## Stack - **Runtime** : Node.js 22 LTS, TypeScript `strict` -- **Framework** : Fastify -- **ORM** : Prisma + PostgreSQL 16 +- **Framework** : Fastify 5 +- **ORM** : Prisma 7 + PostgreSQL 16 - **Cache / Queue** : Redis 7 + BullMQ -- **Validation** : Zod (entrée + sortie + génération OpenAPI) -- **Auth** : JWT (access 15 min) + refresh token rotatif en DB, Argon2id pour les mots de passe +- **Validation** : Zod 4 — validation de l'entrée uniquement (`.parse()` dans les controllers). Le document OpenAPI est _dérivé_ de ces mêmes schémas via `z.toJSONSchema()` natif. +- **Auth** : access token JWT (HS256, 15 min) + refresh token **opaque** (32 octets aléatoires, stocké hashé en SHA-256, rotatif et révocable en DB). Argon2id pour les mots de passe. Pas de `JWT_REFRESH_SECRET`. - **Logging** : Pino (JSON structuré) -- **Tests** : Vitest + Supertest/`app.inject` + Testcontainers -- **Doc API** : `@fastify/swagger` + Scalar sur `/docs` +- **Tests** : Vitest + `app.inject` + Testcontainers (Postgres jetable) +- **Doc API** : document OpenAPI construit dans `src/docs/openapi.ts`, servi en `/openapi.json` et via `@scalar/fastify-api-reference` sur `/docs`. (Pas de `@fastify/swagger`.) - **Package manager** : npm ## Commandes ```bash -npm run dev # serveur en watch (tsx) -npm run build # compilation TS -> dist/ -npm start # exécution prod (dist/server.js) -npm run lint # ESLint, doit passer sans warning -npm run typecheck # tsc --noEmit -npm test # tests Vitest -npm run test:cov # tests + coverage - -npm run db:migrate # prisma migrate dev -npm run db:deploy # prisma migrate deploy (prod/CI) -npm run db:seed # prisma db seed -> tsx prisma/seed.ts -npm run db:studio # Prisma Studio - -docker-compose up -d # Postgres + Redis en local +npm run dev # serveur en watch (tsx) +npm run build # compilation -> dist/ (tsup) +npm start # exécution prod (dist/server.js) +npm run lint # ESLint, doit passer sans warning +npm run typecheck # tsc --noEmit +npm test # tests Vitest (nécessite Docker pour Testcontainers + Redis local) +npm run test:cov # tests + coverage + +npm run db:migrate # prisma migrate dev +npm run db:deploy # prisma migrate deploy (prod/CI) +npm run db:seed # prisma db seed -> tsx prisma/seed.ts +npm run db:studio # Prisma Studio + +docker compose up -d # Postgres + Redis en local ``` -Avant tout commit : `npm lint && npm typecheck && npm test` doivent passer. +Avant tout commit : `npm run lint && npm run typecheck && npm test` doivent passer. ## Architecture @@ -50,39 +50,53 @@ Layered architecture (monolithe modulaire). Flux unidirectionnel : Routes (Fastify) -> Controllers -> Services -> Repositories -> Prisma/Redis ``` -- **Routes** : déclaration des endpoints + schémas Zod. Aucune logique. -- **Controllers** : parsing/validation entrée, mapping HTTP, appel service. Pas de logique métier. +- **Routes** : déclaration des endpoints. Aucune logique. +- **Controllers** : parsing/validation entrée (Zod `.parse()`), mapping HTTP, appel service. Pas de logique métier. - **Services** : logique métier pure. Ne connaissent NI Fastify NI HTTP NI `req`/`res`. Testables en isolation. - **Repositories** : seule couche qui importe Prisma. Masque l'accès DB. -Injection manuelle (factories), pas de DI container — choix assumé vu la taille. +Injection manuelle (factories dans les `*.routes.ts`), pas de DI container — choix assumé vu la taille. + +Le **worker BullMQ tourne dans le même process** que l'API (démarré dans `server.ts`, jamais dans les tests). Il consomme les jobs de clics depuis Redis et persiste via son propre `ClickRepository` (module `tracking`) — il n'appelle pas les services métier. ## Structure ``` src/ -├── config/ # env (validé Zod), logger -├── modules/ # auth, api-keys, links, analytics, redirect -│ └── / # .routes .controller .service .repository .schemas .types +├── config/ # env (validé Zod, fail fast), logger Pino +├── modules/ +│ ├── auth/ # register, login, refresh, logout, me +│ ├── api-keys/ # gestion des clés, scopes +│ ├── links/ # CRUD, idempotence, pagination cursor, anti-SSRF +│ ├── redirect/ # GET /:code public, cache + enqueue async +│ ├── tracking/ # queue BullMQ, worker, processor, click repository +│ ├── analytics/ # agrégations SQL, stats cachées +│ ├── docs/ # montage de la route /openapi.json + Scalar +│ ├── health/ # /health (liveness), /ready (readiness) +│ └── / # .routes .controller .service .repository .schemas .types ├── shared/ -│ ├── errors/ # AppError + error-handler global -│ ├── middleware/ # authenticate, rate-limit, idempotency -│ ├── queue/ # BullMQ connection + workers -│ ├── cache/ # Redis -│ └── utils/ -├── app.ts # build de l'instance Fastify (testable, sans listen) -└── server.ts # entry point (listen uniquement) -prisma/ # schema, migrations, seed -tests/ # integration, unit, helpers +│ ├── 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) +│ ├── 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 +├── docs/openapi.ts # document OpenAPI construit depuis les schémas Zod +├── app.ts # build de l'instance Fastify (testable, sans listen) +└── server.ts # entry point (listen) + arrêt gracieux + cycle de vie du worker +prisma/ # schema, migrations, seed (compte démo + ~600 clics) +tests/ # integration, unit, helpers +scripts/start.sh # applique les migrations puis démarre le serveur (conteneur) ``` -`app.ts` et `server.ts` sont séparés : `app.ts` retourne une instance configurée pour que les tests l'instancient sans ouvrir de port. +`app.ts` et `server.ts` sont séparés : `app.ts` retourne une instance configurée pour que les tests l'instancient sans ouvrir de port ni démarrer le worker. ## Conventions de code - TypeScript `strict: true` + `noUncheckedIndexedAccess: true`. - **Zéro `any`** non justifié, **zéro `@ts-ignore`** non commenté. -- Imports absolus via alias `@/` (configuré dans `tsconfig` + résolveur Vitest). +- Imports absolus via alias `@/` (configuré dans `tsconfig` + résolveur Vitest via `vite-tsconfig-paths`). - Nommage : fichiers en `kebab-case`, classes en `PascalCase`, fonctions/variables en `camelCase`. - Un module = un dossier cohérent. Pas de logique métier hors des services. - Toute entrée externe (body, query, params, headers) est validée par Zod avant usage. @@ -90,49 +104,53 @@ tests/ # integration, unit, helpers ## Gestion des erreurs - Lancer uniquement des `AppError` typées (`code`, `statusCode`, `message`) via le helper `Errors`. -- Ne jamais renvoyer `res.send` manuellement une erreur dans un service. +- Ne jamais renvoyer manuellement une erreur via `reply.send` dans un service. - L'error handler global Fastify mappe `AppError -> réponse JSON` `{ error: { code, message, details? } }`. - Les erreurs inattendues -> 500 générique + log Pino avec stack et request ID. Ne jamais leak de stack en réponse. ## Sécurité (non négociable) -- Mots de passe : Argon2id uniquement. -- JWT access court + refresh rotatif (un seul refresh actif par session, stocké hashé, révocable). -- Clés API : format `lf_live_`, seul le hash est stocké, jamais la valeur en clair après création. -- Rate limiting : par IP (routes anonymes) et par clé API (routes authentifiées), sliding window Redis. -- `@fastify/helmet` activé. CORS configurable via env. -- **Anti-SSRF** : valider les URL `target` à la création, refuser `localhost`, `127.0.0.0/8`, `169.254.0.0/16`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`. -- **RGPD** : les IP des clics sont hashées (SHA-256 + `IP_HASH_SALT`), jamais stockées en clair. +- Mots de passe : Argon2id uniquement (params OWASP : 19 MiB / t=2 / p=1). +- Refresh tokens : opaques, stockés hashés (SHA-256), un seul actif par session, révoqués + rotés à chaque usage. +- Clés API : format `lf_live_`, seul le hash SHA-256 est stocké, jamais la valeur en clair après création. +- `@fastify/helmet` activé (CSP désactivée pour servir l'UI Scalar, autres en-têtes conservés). CORS configurable. +- **Anti-SSRF** : valider les URL `target` à la création, refuser `localhost`, `127.0.0.0/8`, `169.254.0.0/16`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, et tout protocole hors http(s). +- **RGPD** : les IP des clics sont hashées (SHA-256 + `IP_HASH_SALT`, tronqué), jamais stockées en clair. - Aucun secret en dur. Tout passe par `env` validé au démarrage (fail fast). +- Rate limiting (`@fastify/rate-limit` + store Redis) : par clé API si présente, sinon par IP. ## Patterns clés - **Idempotence** : `POST /links` accepte un header `Idempotency-Key`, mémorisé 24h en Redis (`idem::`). - **Pagination** : cursor-based sur `(createdAt, id)`. Pas d'offset. -- **Redirection** : `GET /:code` lit le lien depuis le cache Redis (TTL 5 min, invalidé à l'update), répond en 301/302 immédiatement, puis enqueue un job `track-click`. Le tracking est 100 % asynchrone, jamais bloquant. +- **Redirection** : `GET /:code` résout le lien depuis le cache Redis (`link:`, TTL 5 min, invalidé à l'update/delete), répond en **302** immédiatement, puis `enqueueClick` (fire-and-forget) vers la queue. Le tracking est 100 % asynchrone, jamais bloquant. Lien expiré -> 410. - **Génération de code** : nanoid base62, 7 caractères, retry sur collision (max 5). +- **Tracking** : le worker enrichit le job (classifieur UA maison, résolveur géo best-effort, hash IP) et insère en DB. Retries avec backoff exponentiel. - **Cache stats** : TTL 60 s. ## Tests -- Intégration : flux critiques (auth complet, CRUD links, redirect + tracking). Lancer un vrai Postgres via Testcontainers. -- Unitaire : logique pure (génération de code, hash, validation URL anti-SSRF). -- Helper `tests/helpers/test-app.ts` construit l'app + DB de test. +- Intégration : flux critiques (auth complet, CRUD links, redirect, analytics). Vrai Postgres via Testcontainers + Redis local/CI. +- Unitaire : logique pure (génération de code, classifieur UA, validation URL anti-SSRF). +- Le processor de clics est testé en isolation (sans lancer le worker BullMQ, pour éviter la dépendance au timing). +- Helper `tests/helpers/test-app.ts` construit l'app + reset DB. - Cible coverage : services ≥ 80 %, flux critiques couverts en intégration. - Ne pas mocker Prisma dans les tests d'intégration — utiliser une vraie DB jetable. ## Git - Conventional Commits (`feat`, `fix`, `chore`, `test`, `docs`, `refactor`) sous la forme `(): `. +- Une branche par feature/jour (GitHub Flow), PR vers `main`, branche supprimée après merge. - Commits atomiques et lisibles (l'historique est relu par des recruteurs). - `main` protégée : CI (lint + typecheck + test + build) doit passer. ## Déploiement - Docker multi-stage, image finale `node:22-alpine`. -- Cible : Fly.io (Postgres + Redis managés). -- Migrations appliquées via `npm db:deploy` au déploiement. +- Cible : **Render** (web service, Docker) + **Neon** (Postgres managé) + **Upstash** (Redis managé). Blueprint dans `render.yaml`. +- Migrations appliquées au démarrage du conteneur par `scripts/start.sh` (`prisma migrate deploy`, idempotent). - CI/CD : GitHub Actions (`.github/workflows/`). +- Le free tier Render dort après 15 min d'inactivité (cold start 30–60 s) — assumé et documenté dans le README. ## À ne PAS faire @@ -141,4 +159,5 @@ tests/ # integration, unit, helpers - Pas de logique métier dans les routes ou controllers. - Pas d'import de Prisma hors des repositories. - Pas de tracking synchrone dans le hot path de redirection. +- Pas d'appel aux services métier depuis le worker (il a son propre repository). - Pas de sur-engineering : préférer la solution simple et lisible. diff --git a/README.md b/README.md index 5393a13..d8f5842 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,155 @@ +
+ # LinkForge URL shortener API with authentication, API keys, anonymized async click tracking and analytics. -## Stack +[![CI](https://github.com/Yentec/LinkForge/actions/workflows/ci.yml/badge.svg)](https://github.com/Yentec/LinkForge/actions) +![Node](https://img.shields.io/badge/node-22-339933?logo=node.js&logoColor=white) +![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript&logoColor=white) +![License](https://img.shields.io/badge/license-MIT-blue) + +**[Live demo](https://linkforge-538y.onrender.com/health)** · **[API reference](https://linkforge-538y.onrender.com/docs/)** · **[Architecture](docs/architecture.md)** + +
+ +> ⚠️ The demo runs on a free Render instance and sleeps after 15 min of inactivity. +> The first request after sleep may take 30-60 s. Subsequent requests are fast. + +Demo account: `demo@linkforge.dev` / `DemoUser2026!` + +--- + +## Features + +- **Authentication.** Email + password with Argon2id, JWT access tokens (15 min) and rotating opaque refresh tokens. +- **API keys** with `read` / `write` scopes, returned in clear once, stored hashed. +- **Short links** with auto-generated codes or custom slugs, soft delete, expiry, idempotent creation (`Idempotency-Key`), cursor pagination. +- **Public redirect** with Redis-cached lookup (TTL 5 min) and async click tracking off the hot path. +- **Click analytics** — totals, time series (day/week), top countries / referrers / devices / browsers — aggregated in SQL. +- **OpenAPI** derived from Zod schemas, served as a JSON document and an interactive [Scalar](https://scalar.com) reference. +- **GDPR-friendly tracking.** IPs are never stored: only a salted, truncated SHA-256 hash. +- **Anti-SSRF.** Targets pointing to localhost or RFC1918 ranges are rejected at creation. + +## Tech stack + +Node.js 22 · TypeScript (strict) · Fastify 5 · Prisma 7 · PostgreSQL 16 · Redis 7 · BullMQ · Zod 4 · Pino · Vitest · Testcontainers + +## Architecture + +![Architecture diagram](docs/images/architecture.png) -Node.js 22 · TypeScript (strict) · Fastify 5 · Prisma 6 · PostgreSQL 16 · Redis 7 · Zod 4 · Pino · Vitest +Single Node process, layered modules, in-process BullMQ worker. State on Neon (Postgres) and Upstash (Redis). Full details in [`docs/architecture.md`](docs/architecture.md). ## Quickstart ```bash +git clone https://github.com/Yentec/LinkForge.git +cd linkforge npm install -cp .env.example .env # then configure environment variables -docker compose up -d +cp .env.example .env # then edit secrets +docker compose up -d # postgres + redis npm run db:migrate -npm run dev +npm run dev # http://localhost:3000 ``` -Server runs on `http://localhost:3000`. +Open `http://localhost:3000/docs` for the interactive reference. -## Health checks +## Try the API ```bash -curl http://localhost:3000/health # liveness probe -curl http://localhost:3000/ready # readiness probe (DB + Redis) +BASE=http://localhost:3000 + +# 1. Register and capture the access token +TOKEN=$(curl -s -X POST $BASE/v1/auth/register \ + -H 'content-type: application/json' \ + -d '{"email":"you@example.com","password":"SuperSecret123"}' | jq -r .accessToken) + +# 2. Create a short link (with idempotency) +curl -s -X POST $BASE/v1/links \ + -H "authorization: Bearer $TOKEN" \ + -H 'content-type: application/json' \ + -H 'idempotency-key: my-first-link' \ + -d '{"target":"https://example.com/article"}' | jq + +# 3. Follow the redirect (and trigger an async click) +curl -sI $BASE/ + +# 4. Read the stats +curl -s -H "authorization: Bearer $TOKEN" \ + "$BASE/v1/links//stats?interval=day" | jq ``` -## Available scripts +## Project structure + +``` +src/ +├── config/ env validation (Zod), Pino logger +├── modules/ +│ ├── auth/ register, login, refresh, logout, me +│ ├── api-keys/ key management, scopes +│ ├── links/ CRUD, idempotency, cursor pagination, anti-SSRF +│ ├── redirect/ public GET /:code, cache + async enqueue +│ ├── tracking/ BullMQ queue, worker, click processor +│ ├── analytics/ SQL aggregations, cached stats +│ ├── docs/ OpenAPI document and Scalar mount +│ └── health/ liveness, readiness +├── shared/ +│ ├── auth/ password hashing, JWT, opaque tokens +│ ├── cache/ Redis connection, JSON cache service +│ ├── errors/ typed AppError, global handler +│ ├── middleware/ authenticate, idempotency +│ ├── queue/ BullMQ connection factory +│ ├── geo/ pluggable country resolver +│ └── utils/ short-code, UA classifier, anti-SSRF url validator +├── docs/openapi.ts OpenAPI document built from Zod schemas +├── app.ts Fastify factory (testable, no listen) +└── server.ts entrypoint + graceful shutdown + worker lifecycle +prisma/ +├── schema.prisma +├── migrations/ +└── seed.ts demo account + ~600 weighted clicks +tests/ +├── integration/ end-to-end via Fastify inject + real Postgres (testcontainers) +├── unit/ URL safety, UA classifier +└── helpers/ test app builder, DB reset +docs/ +├── architecture.md + images/ +└── adr/ architecture decision records +``` + +## Testing ```bash -npm run dev # start development server -npm run build # build production bundle -npm run start # run production server -npm run lint # lint codebase -npm run typecheck # TypeScript validation -npm test # run tests +npm test # full suite (Testcontainers spins up a throwaway Postgres) +npm run test:cov # with coverage ``` -## Infrastructure +Integration tests run against a real Postgres (via Testcontainers) and a real Redis (via the Docker service in CI). Prisma migrations are applied to the test database, exercising the migration path on every CI run. -Make sure Docker services are running: +## Deployment -PostgreSQL on localhost:5432 -Redis on localhost:6379 +The live demo runs on **Render** (Docker), with **Neon** for Postgres and **Upstash** for Redis. Infrastructure is declared in [`render.yaml`](render.yaml). Migrations are applied on every container start by [`scripts/start.sh`](scripts/start.sh). -```bash -docker compose ps -``` +## Decisions and trade-offs + +A few choices that aren't obvious from the code: + +- **Modular monolith over microservices.** One process, one deploy, one log stream. The worker can be extracted later without rewriting the modules. ([ADR 0001](docs/adr/0001-modular-monolith.md)) +- **Opaque refresh tokens, not JWT.** Revocation requires a DB lookup anyway, so a self-contained JWT adds no value. ([ADR 0002](docs/adr/0002-opaque-refresh-tokens.md)) +- **Zod 4 as single source of truth.** Validation, static types and the OpenAPI document all come from the same schemas via `z.toJSONSchema()`. ([ADR 0003](docs/adr/0003-zod-as-source-of-truth.md)) +- **In-house UA classifier** to avoid `ua-parser-js` v2+ AGPL licensing. ([ADR 0004](docs/adr/0004-no-ua-parser-js.md)) +- **Asynchronous click tracking** keeps redirect latency bounded by the cache, regardless of DB or geo-resolver issues. ([ADR 0005](docs/adr/0005-async-click-tracking.md)) + +## Roadmap + +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. +- Workspaces / team accounts. ## License diff --git a/docs/adr/0001-modular-monolith.md b/docs/adr/0001-modular-monolith.md new file mode 100644 index 0000000..90fb9b0 --- /dev/null +++ b/docs/adr/0001-modular-monolith.md @@ -0,0 +1,19 @@ +# ADR 0001 — Modular monolith over microservices + +**Status:** accepted • **Date:** 2026-05 + +## Context + +A 5-day backend portfolio needs to demonstrate ownership of a non-trivial system. + +## Decision + +Single Node process, layered architecture, modules separated by folder +(`modules/auth`, `modules/links`, …). The BullMQ worker runs in the same process +as the HTTP server. + +## Consequences + +- One deploy, one log stream, one set of secrets. Tractable for one developer. +- The worker can be extracted later by importing the same modules and starting only `startClickWorker()` without `app.listen()`. +- Tradeoff: a CPU-heavy job could starve the HTTP loop. Acceptable for the expected traffic of a portfolio demo. diff --git a/docs/adr/0002-opaque-refresh-tokens.md b/docs/adr/0002-opaque-refresh-tokens.md new file mode 100644 index 0000000..b1f9d73 --- /dev/null +++ b/docs/adr/0002-opaque-refresh-tokens.md @@ -0,0 +1,22 @@ +# ADR 0002 — Opaque refresh tokens, not JWT + +**Status:** accepted • **Date:** 2026-05 + +## Context + +Refresh tokens must be revocable (logout, rotation, theft response). A signed +JWT cannot be revoked without a server-side lookup, defeating the point of being +self-contained. + +## Decision + +Refresh tokens are 32 random bytes (base64url). Only their SHA-256 hash is +persisted, with `expiresAt` and `revokedAt`. Each `/auth/refresh` revokes the +presented token and issues a new pair (single-use rotation). Detection of reuse +would be an obvious next step (revoke the whole chain). + +## Consequences + +- One DB lookup per refresh. Acceptable; refreshes are infrequent. +- Stored hashes are fast to verify (SHA-256 is appropriate for high-entropy inputs; Argon2 would be overkill). +- No `JWT_REFRESH_SECRET` needed — removed from configuration. diff --git a/docs/adr/0003-zod-as-source-of-truth.md b/docs/adr/0003-zod-as-source-of-truth.md new file mode 100644 index 0000000..373b396 --- /dev/null +++ b/docs/adr/0003-zod-as-source-of-truth.md @@ -0,0 +1,26 @@ +# ADR 0003 — Zod as the single source of truth, OpenAPI derived + +**Status:** accepted • **Date:** 2026-05 + +## Context + +The team needs request validation, static types, and OpenAPI documentation +without keeping three definitions in sync. + +## Decision + +Zod 4 schemas are the only source. Controllers call `schema.parse()` directly. +The OpenAPI document is generated from the same schemas via the native +`z.toJSONSchema()` (Zod 4). Services and repositories stay framework-agnostic. + +## Alternatives considered + +`fastify-type-provider-zod` provides tighter integration (auto-validation, +auto-serialization, auto-OpenAPI) but would require attaching schemas to every +route and adopting Fastify's `withTypeProvider` pattern across the codebase. +Deferred: the current approach yields the same outputs with less coupling. + +## Consequences + +- Zero duplication between validation and documentation. +- Response shapes are not validated against a schema. The tradeoff: simpler code, faster iteration, at the cost of catching response drift only via tests. diff --git a/docs/adr/0004-no-ua-parser-js.md b/docs/adr/0004-no-ua-parser-js.md new file mode 100644 index 0000000..c6fdb65 --- /dev/null +++ b/docs/adr/0004-no-ua-parser-js.md @@ -0,0 +1,20 @@ +# ADR 0004 — In-house user-agent classifier (no ua-parser-js) + +**Status:** accepted • **Date:** 2026-05 + +## Context + +`ua-parser-js` v2+ moved to a dual AGPLv3 / commercial license. Using it in an +MIT-licensed project would either contaminate the license or require paid +commercial terms. + +## Decision + +A ~40-line classifier in `src/shared/utils/user-agent.ts` returns the device +bucket (`mobile|tablet|desktop|bot`) and browser family (`Chrome|Firefox|...`). +That is the granularity analytics needs. + +## Consequences + +- Zero runtime dependency on a contested library. +- Less accurate than full UA parsing. Acceptable; we never expose raw UA strings to end users, only aggregates. diff --git a/docs/adr/0005-async-click-tracking.md b/docs/adr/0005-async-click-tracking.md new file mode 100644 index 0000000..180cf0e --- /dev/null +++ b/docs/adr/0005-async-click-tracking.md @@ -0,0 +1,22 @@ +# ADR 0005 — Asynchronous click tracking via BullMQ + +**Status:** accepted • **Date:** 2026-05 + +## Context + +Redirection latency is user-facing. Synchronous tracking (UA parsing, geo +lookup, DB insert) would add tens of milliseconds — and any failure (DB hiccup, +geo resolver timeout) would propagate to the redirect. + +## Decision + +`GET /:code` resolves the link from cache (Redis) or DB, calls `enqueueClick` +fire-and-forget, then responds 302. A BullMQ worker in the same process pulls +the job, enriches it, and inserts the row. Failed jobs are retried with +exponential backoff (3 attempts). + +## Consequences + +- Redirect latency is bounded by cache + (rare) DB lookup. +- Clicks are eventually consistent. Analytics queries running within seconds of a burst may undercount. Acceptable for the use case. +- Requires Redis as part of the production stack. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..9d1f872 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,41 @@ +# Architecture + +LinkForge is a single Node.js process exposing a REST API and running an in-process +BullMQ worker. State lives in two managed services: Postgres (Neon) for durable +data and Redis (Upstash) for cache, idempotency keys and the click queue. + +![Architecture diagram](images/architecture.png) + +## Request paths + +**Authenticated API (`/v1/*`)** — JWT or API key → middleware → controller (Zod +parsing) → service (pure domain logic) → repository (Prisma) → Postgres. + +**Public redirect (`GET /:code`)** — cache lookup in Redis; on miss, query Postgres +and warm the cache (TTL 5 min). The redirect is served immediately (302); the +click is enqueued and processed off the hot path by the BullMQ worker. + +## Layered structure + +- **Routes** declare endpoints and Zod schemas. No logic. +- **Controllers** validate input and shape HTTP responses. No business rules. +- **Services** hold domain logic. Framework-agnostic, fully unit-testable. +- **Repositories** are the only layer that imports Prisma. + +Dependencies are wired by hand in `*.routes.ts`. No DI container — deliberate, +given the project size. + +## Async tracking + +Click tracking never blocks a redirect. `enqueueClick` is fire-and-forget: a +failure to enqueue is logged but the user still gets their 302. The worker +enriches the job (UA parsing, IP hashing, country resolution) and writes to +the `clicks` table. Failed jobs are retried with exponential backoff. + +## Caching strategy + +| Key pattern | TTL | Invalidation | +| --------------------- | ----- | --------------------- | +| `link:` | 5 min | On link update/delete | +| `idem::` | 24 h | TTL only | +| `stats::...` | 60 s | TTL only | diff --git a/docs/images/api-call.png b/docs/images/api-call.png new file mode 100644 index 0000000..a3136ea Binary files /dev/null and b/docs/images/api-call.png differ diff --git a/docs/images/architecture.png b/docs/images/architecture.png new file mode 100644 index 0000000..6133d09 Binary files /dev/null and b/docs/images/architecture.png differ diff --git a/docs/images/stats.png b/docs/images/stats.png new file mode 100644 index 0000000..d343109 Binary files /dev/null and b/docs/images/stats.png differ diff --git a/package-lock.json b/package-lock.json index 0616372..a357cd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linkforge", - "version": "0.7.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linkforge", - "version": "0.7.0", + "version": "1.0.0", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/package.json b/package.json index 891f7f1..576f94c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkforge", - "version": "0.7.0", + "version": "1.0.0", "description": "URL shortener API with authentication, API keys, async click tracking and analytics.", "type": "module", "license": "MIT", diff --git a/requests.http b/requests.http new file mode 100644 index 0000000..9e77dca --- /dev/null +++ b/requests.http @@ -0,0 +1,54 @@ +@base = http://localhost:3000 +@email = you@example.com +@password = SuperSecret123 + +### Register +# @name register +POST {{base}}/v1/auth/register +Content-Type: application/json + +{ "email": "{{email}}", "password": "{{password}}" } + +### +@token = {{register.response.body.accessToken}} +@refresh = {{register.response.body.refreshToken}} + +### Login +POST {{base}}/v1/auth/login +Content-Type: application/json + +{ "email": "{{email}}", "password": "{{password}}" } + +### Me +GET {{base}}/v1/auth/me +Authorization: Bearer {{token}} + +### Create a link +# @name createLink +POST {{base}}/v1/links +Authorization: Bearer {{token}} +Content-Type: application/json +Idempotency-Key: demo-1 + +{ "target": "https://example.com/article" } + +### +@linkId = {{createLink.response.body.id}} +@code = {{createLink.response.body.code}} + +### List links +GET {{base}}/v1/links?limit=10 +Authorization: Bearer {{token}} + +### Public redirect +GET {{base}}/{{code}} + +### Stats +GET {{base}}/v1/links/{{linkId}}/stats?interval=day +Authorization: Bearer {{token}} + +### Refresh +POST {{base}}/v1/auth/refresh +Content-Type: application/json + +{ "refreshToken": "{{refresh}}" } \ No newline at end of file