{"id":"d551b9a6-ca79-48b0-be68-5fad5138c991","slug":"url-shortneer","kind":"mcp","name":"URL-Shortneer","summary":"A URL shortener with click analytics and an MCP server — built on Postgres with 12 design patterns, each one load-bearing. The landing page animates the real request path as it runs.","owner":"subhm2004","repoUrl":"https://github.com/subhm2004/URL_Shortneer","license":"MIT","licenseTier":"permissive","trust":"community","stars":0,"match":null,"fewStars":true,"format":"mcp","createdAt":"2026-07-14T13:30:26.000Z","addedAt":"2026-10-21T06:25:53.685Z","logoUrl":"https://avatars.githubusercontent.com/u/146654596?u=55b57911b252e62708644af35f01ebea76056ae8&v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["databases","design-media","cloud-infra"],"tags":["design-patterns","express","nodejs","postgresql","react","rest-api","url-shortener","nextjs"],"runtime":"binary","local":true,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-23T17:48:03.963Z","sample":false,"url":"https://conduits.dev/tools/url-shortneer","language":"TypeScript","transports":["stdio"],"install":{},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"<div align=\"center\">\n\n<img src=\".github/assets/header.svg\" width=\"100%\" alt=\"Trunc — a POST to /api/shorten passing through the validation chain, the short-code strategy, the repository, and the event bus\" />\n\n<br />\n\n**A URL shortener that shows you its own machinery.**\n\nPaste a link and watch every layer of the request execute, in order, as it happens.\nThen ask an AI assistant about your links — over MCP, the same protocol Claude Desktop speaks.\n\n`PostgreSQL` · `Express` · `Next.js 15` · `TypeScript` · `MCP` · `Groq`\n\n[![CI](https://github.com/subhm2004/URL_Shortneer/actions/workflows/ci.yml/badge.svg)](https://github.com/subhm2004/URL_Shortneer/actions/workflows/ci.yml)\n[![License: MIT](https://img.shields.io/badge/License-MIT-111111.svg?style=flat-square)](LICENSE)\n[![Node](https://img.shields.io/badge/node-%3E%3D20-111111.svg?style=flat-square)](https://nodejs.org)\n[![Postgres](https://img.shields.io/badge/postgres-14%2B-111111.svg?style=flat-square)](https://postgresql.org)\n[![Design Patterns](https://img.shields.io/badge/design%20patterns-12-111111.svg?style=flat-square)](#design-patterns)\n\n</div>\n\n---\n\n## Contents\n\n- [What this is](#what-this-is)\n- [Architecture](#architecture)\n- [Quick start](#quick-start)\n- [**Design patterns**](#design-patterns) — twelve, with the code and the reason\n- [**MCP**](#mcp-model-context-protocol) — two clients, one protocol\n- [The AI assistant](#the-ai-assistant)\n- [Authentication](#authentication)\n- [What the rewrite fixed](#what-the-rewrite-fixed)\n- [API reference](#api-reference)\n- [Testing and CI](#testing-and-ci)\n- [**Deployment**](#deployment) — Render + Vercel + Neon\n- [Configuration](#configuration)\n- [Project layout](#project-layout)\n\n---\n\n## What this is\n\nMost URL shorteners hide the machinery. Trunc shows it to you.\n\nPaste a link on the landing page and a terminal prints the work as it happens —\nthe validation chain, the code generation, the database write, the event\ndispatch. Not a mock: those are the layers the request actually passes through,\nin the order it passes them.\n\nIt is also a **complete MCP integration**. The same four tools are reachable two\nways — from Claude Desktop over stdio or HTTP, and from a chat built into the app\nitself, which is a genuine MCP client rather than a chat bolted on beside one.\n\nEverything below is production-shaped: a layered backend, a typed frontend, real\nmigrations, real CI that spins up a Postgres and drives the API over HTTP, and\ntwelve design patterns that each exist because something was unsafe, slow, or\nimpossible to change without them.\n\n---\n\n## Architecture\n\n### The system\n\n```mermaid\ngraph TB\n    subgraph Client\n        Browser[\"🌐 Browser\"]\n        Claude[\"🤖 Claude Desktop\"]\n    end\n\n    subgraph Vercel[\"▲ Vercel\"]\n        Next[\"Next.js 15<br/>App Router · TypeScript\"]\n        ChatAPI[\"/api/chat<br/>route handler — server only\"]\n    end\n\n    subgraph Render[\"☁️ Render\"]\n        API[\"Express API<br/>redirects · auth · links\"]\n        MCP[\"MCP Server<br/>stdio + HTTP transports\"]\n    end\n\n    subgraph External\n        Neon[(\"🐘 Neon<br/>PostgreSQL\")]\n        Groq[\"⚡ Groq<br/>llama-3.3-70b\"]\n        Google[\"🔑 Google<br/>OAuth 2.0\"]\n    end\n\n    Browser --> Next\n    Browser -.->|\"NEXT_PUBLIC_API_URL\"| API\n    Next --> ChatAPI\n    ChatAPI -->|\"tool calls · JSON-RPC\"| MCP\n    ChatAPI -->|\"tool definitions\"| Groq\n    Claude -->|\"MCP protocol\"| MCP\n    MCP -->|\"SHORTENER_API_BASE\"| API\n    API --> Neon\n    API <-->|\"authorization code\"| Google\n\n    classDef vercel fill:#111,stroke:#666,color:#fff\n    classDef render fill:#111,stroke:#666,color:#fff\n    classDef ext fill:#0d0d0d,stroke:#37f712,color:#fff\n    class Next,ChatAPI vercel\n    class API,MCP render\n    class Neon,Groq,Google ext\n```\n\nNote the two paths into the MCP server. **Claude Desktop and the in-app chat are\nboth MCP clients**, speaking the same protocol to the same endpoint, calling the\nsame tools. If one works and the other doesn't, that's a bug in the server — and\nhaving both is how you find out.\n\n### The backend, in layers\n\nRequests only ever call *downward*. Every class receives its collaborators through\nits constructor and imports none of them — which is what makes them testable\nwithout a database, and what lets the cache be swapped for Redis by editing one\nline.\n\n```mermaid\ngraph TD\n    R[\"routes/<br/>HTTP surface · auth middleware\"]\n    C[\"controllers/<br/>transport only — read, call, shape\"]\n    S[\"services/<br/>business logic · knows nothing of HTTP or SQL\"]\n    RE[\"repositories/<br/>all the SQL lives here\"]\n    DB[\"db/<br/>pool + migrations\"]\n\n    R --> C --> S --> RE --> DB\n\n    CORE[\"core/<br/>errors · logger · ApiResponse · EventBus\"]\n    ST[\"strategies/<br/>short-code generation\"]\n    V[\"validation/<br/>the URL rule chain\"]\n    O[\"observers/<br/>post-response side-effects\"]\n    CT[\"container.js<br/><b>composition root</b>\"]\n\n    S -.-> CORE\n    S -.-> ST\n    S -.-> V\n    S -.-> O\n    CT -.->|\"wires everything\"| S\n\n    classDef layer fill:#111,stroke:#37f712,color:#fff\n    classDef support fill:#0d0d0d,stroke:#444,color:#aaa\n    class R,C,S,RE,DB layer\n    class CORE,ST,V,O,CT support\n```\n\n**Nothing above `repositories/` imports a database driver.** That boundary is why\nmigrating this app from MongoDB to PostgreSQL rewrote one directory rather than\nthe whole codebase.\n\n### What happens when you shorten a link\n\nThis is the sequence the landing page animates — and it is the real one.\n\n```mermaid\nsequenceDiagram\n    autonumber\n    participant B as Browser\n    participant C as UrlController\n    participant S as UrlService\n    participant V as UrlValidator<br/>chain of responsibility\n    participant G as ShortCodeStrategy<br/>strategy + factory\n    participant R as UrlRepository<br/>repository + decorator\n    participant E as EventBus<br/>observer\n    participant P as PostgreSQL\n\n    B->>C: POST /api/shorten\n    C->>S: shorten({ longUrl, userId })\n\n    S->>V: validate(longUrl)\n    Note over V: Required → MaxLength → Parsable<br/>→ Protocol → PublicHost → NoSelfRef\n    V-->>S: normalised url\n\n    S->>G: generate()\n    G-->>S: \"Yw3dcSQK\"\n\n    S->>R: create({ urlCode, longUrl })\n    R->>P: INSERT INTO urls\n    Note over P: unique(url_code) arbitrates<br/>collisions — service retries\n    P-->>R: row\n    R-->>S: url\n\n    S->>E: publish(link.created)\n    S-->>C: { url, created: true }\n    C-->>B: 201 { url }\n\n    Note over E,P: observers run AFTER the response.<br/>The caller never waits on them.\n```\n\nThat last note is the point of the Observer seam. The old code awaited two\ndatabase writes before sending the redirect — every visitor to every link paid for\nour analytics, on the one code path where latency *is* the product.\n\n### The data model\n\n```mermaid\nerDiagram\n    USERS ||--o{ URLS : owns\n    URLS ||--o{ CLICKS : receives\n\n    USERS {\n        uuid id PK\n        text name\n        text email UK \"unique on lower(email)\"\n        text password_hash \"NULL for Google accounts\"\n        text google_id UK \"Google's sub — never the email\"\n        text avatar_url\n        timestamptz created_at\n    }\n\n    URLS {\n        uuid id PK\n        text url_code UK \"the redirect key — UNIQUE\"\n        text long_url\n        uuid user_id FK \"NULL for anonymous\"\n        bigint click_count \"atomic increment\"\n        timestamptz created_at\n    }\n\n    CLICKS {\n        bigserial id PK\n        uuid url_id FK\n        timestamptz clicked_at\n        text referer\n        text user_agent\n    }\n```\n\nThree decisions worth defending:\n\n- **`url_code` is `UNIQUE`.** It is the redirect key. Without the constraint, two\n  links could be handed the same generated code and one of them would send\n  visitors to the wrong site.\n- **Clicks are their own table**, not an array on the URL row. An array grows the\n  row on every single click, eventually hits Postgres' tuple limits, and cannot be\n  aggregated in SQL without unnesting it first.\n- **`google_id` keys on Google's `sub`, never the email.** A Google user can change\n  their email address; keying on it would either lose them their account or hand it\n  to whoever later picks up their old address.\n\n---\n\n## Quick start\n\n**You need:** Node 20+, and a PostgreSQL connection string — [Neon](https://neon.tech),\n[Supabase](https://supabase.com), Railway, or a local Postgres. Any of them.\n\n### 1. Backend\n\n```bash\ncd app\nnpm install\ncp .env.example .env\n```\n\nFill in two values in `app/.env`:\n\n```bash\nDATABASE_URL=postgresql://user:pass@host/db?sslmode=require\nJWT_SECRET=            # generate one:\n```\n\n```bash\nnode -e \"console.log(require('crypto').randomBytes(48).toString('hex'))\"\n```\n\n```bash\nnpm run dev            # → http://localhost:5050\n```\n\nTables are created on first boot — migrations run automatically. (By hand:\n`npm run migrate`.)\n\n> [!NOTE]\n> **The backend runs on 5050, not 5000.** On macOS, port 5000 is held by the AirPlay\n> Receiver, which accepts connections and silently swallows them — you get empty\n> responses with no error anywhere. Leave the port alone, or turn AirPlay Receiver\n> off in *System Settings → General → AirDrop & Handoff*.\n\n### 2. Frontend\n\n```bash\ncd frontend\nnpm install\ncp .env.example .env.local\nnpm run dev            # → http://localhost:3000\n```\n\nOpen **http://localhost:3000** and shorten something.\n\n> `NEXT_PUBLIC_API_URL` is deliberately **unset** in development. The browser then\n> fetches the relative path `/api/…` — same origin — and `next.config.ts` forwards\n> it to the backend. Nothing crosses an origin, so CORS never enters the picture\n> locally. See [Configuration](#configuration) for why production is different.\n\n### 3. MCP server (optional)\n\n```bash\ncd mcp-server\nnpm install\ncp .env.example .env\nnpm run dev:http       # → http://localhost:3001/mcp\n```\n\nNeeded for the in-app assistant and for connecting Claude Desktop.\n\n### With Docker\n\n```bash\nmake up          # uses DATABASE_URL from the root .env (e.g. Neon)\nmake up-local    # runs a Postgres container too — no external DB needed\n```\n\n| | |\n|---|---|\n| `make logs` | Follow logs |\n| `make down` | Stop everything |\n| `make psql` | psql shell (local-db only) |\n| `make reset` | Stop **and delete** the local DB volume |\n\n---\n\n## Design patterns\n\n**Twelve patterns. Every one of them load-bearing.**\n\nMost projects list design patterns like trophies. These are here because specific\nthings were unsafe, slow, or impossible to change without them — and the code says\nwhich. Read the *\"without it\"* column first, because that's the whole argument.\n\n| # | Pattern | Where | Without it |\n|:--|:--|:--|:--|\n| 01 | [**Singleton**](#01--singleton) | `config/` · `db/pool.js` · `core/logger.js` | A connection pool per request, exhausting Postgres' limit |\n| 02 | [**Repository**](#02--repository) | `repositories/` | SQL smeared through the business logic |\n| 03 | [**Template Method**](#03--template-method) | `BaseRepository` | Every repository re-implementing connection handling |\n| 04 | [**Decorator**](#04--decorator) | `CachedUrlRepository` | Cache checks tangled into the service that uses it |\n| 05 | [**Null Object**](#05--null-object) | `NullCache` | `if (cache)` at every call site — where cache bugs come from |\n| 06 | [**Strategy**](#06--strategy) | `strategies/shortcode/` | `nanoid(8)` hardcoded in the middle of a controller |\n| 07 | [**Factory**](#07--factory) | `ShortCodeStrategyFactory` | Every caller knowing the concrete class names |\n| 08 | [**Chain of Responsibility**](#08--chain-of-responsibility) | `validation/` | One long `if/else` that nobody dares reorder |\n| 09 | [**Observer**](#09--observer) | `core/EventBus` | Every visitor waiting on a DB write before being redirected |\n| 10 | [**Builder**](#10--builder) | `core/ApiResponse` | Five hand-rolled response shapes that drifted apart |\n| 11 | [**Dependency Injection**](#11--dependency-injection) | `container.js` | Unit tests that need a live database |\n| 12 | [**Facade**](#12--facade) | `frontend/src/lib/api.ts` | The same twenty lines of `fetch` in three files |\n\n---\n\n### 01 · Singleton\n\n```js\n// app/db/pool.js\nlet pool = null;\n\nexport function getPool() {\n  if (pool) return pool;\n\n  pool = new Pool({\n    connectionString: config.db.connectionString,\n    ssl: config.db.ssl,\n    max: config.db.max,\n  });\n\n  return pool;\n}\n```\n\n**Why.** A `Pool` opens and reuses TCP connections. One per request would open a\nfresh connection every time and blow through Postgres' connection limit under any\nreal traffic — on Neon's free tier, within seconds.\n\nThe config Singleton does something subtler: it validates the **whole environment\nat boot**, so a missing `DATABASE_URL` fails on startup rather than on the first\nrequest unlucky enough to need it.\n\n```js\n// app/config/index.js\nexport function assertConfigValid() {\n  const missing = REQUIRED.filter((name) => !process.env[name]);\n  if (missing.length) {\n    throw new Error(`Missing required environment variable(s): ${missing.join(\", \")}`);\n  }\n}\n```\n\n---\n\n### 02 · Repository\n\n```js\n// app/repositories/UrlRepository.js\nexport default class UrlRepository extends BaseRepository {\n  findByCode(urlCode) {\n    return this.one(`SELECT * FROM urls WHERE url_code = $1`, [urlCode]);\n  }\n\n  toDomain(row) {\n    return {\n      id: row.id,\n      urlCode: row.url_code,\n      longUrl: row.long_url,\n      shortUrl: `${config.baseUrl}/${row.url_code}`,   // derived, never stored\n      clickCount: row.click_count,\n      createdAt: row.created_at,\n    };\n  }\n}\n```\n\n**Why.** `UrlService` asks for *a URL by its code*. It doesn't know there's a\ntable, or a `SELECT`, or Postgres at all. That boundary is what makes the service\ntestable with a fake repository — and it's why moving this app off MongoDB rewrote\none directory instead of the whole codebase.\n\n> `shortUrl` is **derived from config, not stored**. The old schema persisted the\n> full short URL on every row — so moving the app to a new domain silently broke\n> every link ever created.\n\n---\n\n### 03 · Template Method\n\n```js\n// app/repositories/BaseRepository.js\nexport default class BaseRepository {\n  toDomain(row) { return row; }          // ← subclasses override this\n\n  async one(text, params = []) {\n    const { rows } = await this.query(text, params);\n    return rows.length ? this.toDomain(rows[0]) : null;\n  }\n\n  async withTransaction(fn) {\n    const client = await getPool().connect();\n    try {\n      await client.query(\"BEGIN\");\n      const scoped = new this.constructor(client);   // same repo, bound to this client\n      const result = await fn(scoped, client);\n      await client.query(\"COMMIT\");\n      return result;\n    } catch (err) {\n      await client.query(\"ROLLBACK\").catch(() => {});\n      throw err;\n    } finally {\n      client.release();\n    }\n  }\n}\n```\n\n**Why.** Connection checkout, `BEGIN`/`COMMIT`/`ROLLBACK`, and releasing the client\nare easy to get subtly wrong — a missed `client.release()` leaks a connection and\nyou find out days later when the pool runs dry. Writing it once means four\nrepositories can't each get it wrong in their own way.\n\n---\n\n### 04 · Decorator\n\n```js\n// app/repositories/CachedUrlRepository.js\nexport default class CachedUrlRepository {\n  async findByCode(urlCode) {\n    const key = `code:${urlCode}`;\n\n    const hit = this.#cache.get(key);\n    if (hit !== undefined) return hit;\n\n    const url = await this.#inner.findByCode(urlCode);\n\n    // Misses are cached too (as null) — otherwise a bot hammering nonexistent\n    // codes would hit Postgres on every request.\n    this.#cache.set(key, url);\n    return url;\n  }\n\n  // everything else forwards straight through\n  findByUser(userId) { return this.#inner.findByUser(userId); }\n}\n```\n\nIt implements the same interface as the thing it wraps, so this is the **only**\nline that changes to turn caching on:\n\n```js\n// app/container.js\nconst urlRepository = new CachedUrlRepository(new UrlRepository(), cache);\n```\n\n**Why.** Every redirect performs `findByCode` — that's the hot path. The\nalternative is cache checks smeared through `UrlService`, which is exactly how you\nend up serving stale data and not knowing why.\n\nNote what it deliberately does **not** cache: `findByLongUrlAndUser`, which runs on\nthe *write* path, where a stale answer would mean minting a duplicate row.\n\n---\n\n### 05 · Null Object\n\n```js\n// app/cache/NullCache.js\nexport default class NullCache {\n  get()    { return undefined; }   // always a miss\n  set()    {}                      // forget immediately\n  delete() {}\n}\n```\n\n```js\nconst cache = config.cache.enabled\n  ? new InMemoryCache({ maxEntries: 1000, ttlMs: 60_000 })\n  : new NullCache();\n```\n\n**Why.** The alternative is `if (this.cache)` guarding every read and every write\ninside the decorator — four branches that all have to be right, and cache bugs live\nin exactly those branches. With a Null Object, `CACHE_ENABLED=false` is a *different\nobject*, not a different code path.\n\n---\n\n### 06 · Strategy\n\nBefore, this sat in the middle of a controller:\n\n```js\nconst urlCode = nanoid(8);                    // ← the old code\n```\n\nNow it's an interface with three implementations:\n\n```js\n// app/strategies/shortcode/CustomAliasStrategy.js\nexport default class CustomAliasStrategy extends ShortCodeStrategy {\n  generate(context = {}) {\n    const alias = context.customAlias?.trim();\n    if (!alias) return this.#fallback.generate(context);   // no alias → delegate\n\n    if (!ALIAS_PATTERN.test(alias)) {\n      throw new ValidationError(\"A custom alias must be 3–32 characters…\");\n    }\n    if (RESERVED.has(alias.toLowerCase())) {\n      throw new ValidationError(`\"${alias}\" is a reserved word — pick another alias.`);\n    }\n    return alias;\n  }\n}\n```\n\nSwap the algorithm for the entire app with one environment variable:\n\n```bash\nSHORT_CODE_STRATEGY=base62     # nanoid | base62\n```\n\n**Why.** Not one line of `UrlService` changes. That's the test of whether a seam is\nreal, and this one passes it.\n\nThere's a design detail worth noticing: `CustomAliasStrategy` can **fail**, because\nan alias is user input — it is *validated*, not generated. The random strategies\nnever fail. That asymmetry is precisely why generation had to become an interface:\nthe caller just asks for a code and handles the error, without knowing which\nstrategy produced it.\n\n> The reserved-word list is not decoration. A link at `/api` or `/login` would be\n> shadowed by the router and **unreachable** — the alias would exist and never work.\n\n---\n\n### 07 · Factory\n\n```js\n// app/strategies/shortcode/ShortCodeStrategyFactory.js\nexport default class ShortCodeStrategyFactory {\n  static #registry = {\n    nanoid: (opts) => new NanoIdStrategy(opts),\n    base62: (opts) => new Base62Strategy(opts),\n  };\n\n  static create(name, options = {}) {\n    const build = this.#registry[name];\n    if (!build) {\n      throw new Error(`Unknown SHORT_CODE_STRATEGY \"${name}\". Known: ${Object.keys(this.#registry)}`);\n    }\n    // Every strategy is wrapped, so any of them can accept a custom alias.\n    return new CustomAliasStrategy(build(options));\n  }\n}\n```\n\n**Why.** `SHORT_CODE_STRATEGY` is a *string* from the environment. Something has to\nturn it into an object, and if that logic lives at the call site, every call site\nhas to import every strategy. Adding a fourth algorithm is one line here.\n\nIt also fails **loudly and early**: a typo in the env var throws at boot with a list\nof valid names, instead of silently minting codes with the wrong generator.\n\n---\n\n### 08 · Chain of Responsibility\n\n```js\n// app/validation/UrlValidator.js\nconst required = new RequiredRule();\n\nrequired\n  .setNext(new MaxLengthRule(maxLength))\n  .setNext(new ParsableRule())          // ← must run before the two below\n  .setNext(new ProtocolRule())\n  .setNext(new PublicHostRule())\n  .setNext(new NoSelfReferenceRule(baseUrl));\n```\n\nEach rule is small enough to be obviously correct:\n\n```js\nexport class ProtocolRule extends ValidationRule {\n  static ALLOWED = new Set([\"http:\", \"https:\"]);\n\n  check(value, context) {\n    if (!ProtocolRule.ALLOWED.has(context.parsed.protocol)) {\n      throw new ValidationError(\"Only http:// and https:// URLs can be shortened.\");\n    }\n    return value;\n  }\n}\n```\n\n**Why — and this one is a security story.**\n\nThe old validator was a single `validUrl.isUri()` call. That function happily\naccepts:\n\n| Input | What it actually is |\n|:--|:--|\n| `javascript:alert(1)` | **Stored XSS** in any client that renders the link |\n| `http://169.254.169.254/` | The **cloud metadata endpoint** — an SSRF gadget wearing your domain |\n| `http://localhost:5050/admin` | Anything on your own private network |\n\nThe chain enforces a protocol **allowlist** (a blocklist is the wrong shape — you\nwill always miss one) and refuses private, loopback and link-local hosts. It also\nrefuses self-referential short links, because two chained together is a redirect\nloop.\n\nSplitting the checks into named rules is what made those omissions *visible*. A\nforty-line `if/else` hides what it forgot; a list of six rule names does not.\n\n---\n\n### 09 · Observer\n\n```js\n// app/services/UrlService.js\nasync resolve({ urlCode, referer, userAgent }) {\n  const url = await this.#urls.findByCode(urlCode);\n  if (!url) throw new NotFoundError(\"That short link doesn't exist.\");\n\n  this.#events.publish(EVENTS.LINK_CLICKED, { urlId: url.id, referer, userAgent });\n\n  return url;      // ← does NOT wait for the click to be written\n}\n```\n\n```js\n// app/observers/clickObservers.js\neventBus.subscribe(EVENTS.LINK_CLICKED, async ({ urlId }) => {\n  await urlRepository.incrementClickCount(urlId);\n});\n\neventBus.subscribe(EVENTS.LINK_CLICKED, async ({ urlId, referer, userAgent }) => {\n  await clickRepository.record({ urlId, referer, userAgent });\n});\n```\n\nThe bus defers subscribers to the next tick, so the response is already on the wire\nbefore any of them run:\n\n```js\npublish(event, payload) {\n  setImmediate(() => {\n    try { super.emit(event, payload); }\n    catch (err) { logger.error(\"Observer threw\", { event, error: err.message }); }\n  });\n}\n```\n\n**Why.** The old redirect handler `await`ed two database writes before sending the\n301. Every visitor to every link paid for our analytics, in latency, on the one code\npath where latency is the entire product.\n\n**The trade-off is real and deliberate:** a click can be lost if the process dies in\nthe ~1ms between `publish` and the write. For click *counts*, that is a fair price\nfor a redirect that doesn't wait. If these were payment events the answer would be\ndifferent — and the pattern would still be right, just with a durable queue behind\nit instead of `setImmediate`.\n\nAdding a new side-effect of a click — a webhook, a geo-IP lookup, a rate-limit\ncounter — is now one `subscribe()` call, and the redirect stays exactly as fast.\n\n---\n\n### 10 · Builder\n\n```js\nreturn ApiResponse.created()\n  .message(\"Short URL created.\")\n  .data({ url })\n  .send(res);\n```\n\n```js\ntoJSON() {\n  const body = { success: this.#status < 400 };   // derived, not asserted\n  if (this.#message !== null) body.message = this.#message;\n  Object.assign(body, this.#meta);\n  if (this.#data !== undefined) body.data = this.#data;\n  return body;\n}\n```\n\n**Why.** Every endpoint used to hand-assemble its own object, and they had **already\ndrifted**: some had a `count`, some nested `data.url`, some returned the array bare.\nThe frontend had a different unwrapping ritual for each one.\n\n`success` is now *derived* from the status code rather than typed by hand — so it\ncannot disagree with the HTTP response.\n\n---\n\n### 11 · Dependency Injection\n\n```js\n// app/services/UrlService.js\nconstructor({ urlRepository, urlValidator, shortCodeStrategy, eventBus, config }) {\n  this.#urls = urlRepository;\n  this.#validator = urlValidator;\n  this.#codeStrategy = shortCodeStrategy;\n  this.#events = eventBus;\n  this.#config = config;\n}\n```\n\nNotice what `UrlService` **imports**: nothing. Not the repository, not the\nvalidator, not the database. It cannot reach for a global even if it wanted to.\n\nThe wiring lives in exactly one place:\n\n```js\n// app/container.js — the composition root\nexport function buildContainer() {\n  const cache = config.cache.enabled ? new InMemoryCache({...}) : new NullCache();\n\n  const urlRepository = new CachedUrlRepository(new UrlRepository(), cache);\n  const shortCodeStrategy = ShortCodeStrategyFactory.create(config.shortCode.strategy);\n  const urlValidator = new UrlValidator({ baseUrl: config.baseUrl });\n\n  const urlService = new UrlService({\n    urlRepository, urlValidator, shortCodeStrategy, eventBus, config,\n  });\n\n  return { services: { urlService, ... }, controllers: { ... } };\n}\n```\n\n**Why.** This is the pattern that makes the other eleven *usable* — and it isn't a\nclaim, it's [the test suite](#testing-and-ci). A unit test for `UrlService` hands it\na fake repository and a fake clock and drives it with no Postgres, no Express, no\nnetwork; forty-two of them run in about 200ms. Swapping `InMemoryCache` for Redis is\none line here, and nothing downstream notices.\n\nThe honest cost: *something* has to do the wiring, and it's this file. That's the\ntrade — one explicit, readable graph instead of `new` calls and module-level\nsingletons scattered across thirty files.\n\n---\n\n### 12 · Facade\n\n```ts\n// frontend/src/lib/api.ts\n\n/** \"required\" fails fast without a token; \"optional\" sends one if we have it. */\ntype Auth = \"required\" | \"optional\" | \"none\";\n\nasync function request<T>(\n  path: string,\n  { method = \"GET\", body, auth = \"optional\" }: RequestOptions = {},\n): Promise<ApiEnvelope<T>> {\n  const headers: Record<string, string> = {};\n  if (body !== undefined) headers[\"Content-Type\"] = \"application/json\";\n\n  if (auth !== \"none\") {\n    const token = tokenStore.get();\n    if (token) headers.Authorization = `Bearer ${token}`;\n    else if (auth === \"required\") throw new ApiError(\"You need to sign in first.\", 401);\n  }\n\n  // …one place that fetches, parses JSON, checks res.ok, and throws a typed ApiError\n}\n```\n\nEvery call site collapses to one line, and the auth policy is part of the call:\n\n```ts\n// auth: \"none\" — a stale token must not ride along with a login attempt\nexport async function login(input: { email: string; password: string }) { … }\n\n// anonymous shortening is a feature, so the token is optional here\nexport async function shorten(longUrl: string, customAlias?: string) { … }\n\nexport async function myLinks(): Promise<ShortUrl[]> {\n  const res = await request<ShortUrl[]>(\"/api/links/my-links\", { auth: \"required\" });\n  return res.data ?? [];\n}\n```\n\n**Why.** The three old service files each repeated the same twenty lines — build the\nURL, set `Content-Type`, attach the bearer token, parse JSON, check `res.ok`, throw,\nlog, rethrow.\n\nAnd they had **already drifted**: only two of the three sent the token at all, and\neach invented its own fallback error message. That's the failure mode this pattern\nexists to prevent — not verbosity, but *divergence*.\n\n---\n\n## MCP (Model Context Protocol)\n\nMCP is how an AI assistant calls your code. The server exposes **tools**; the client\n(Claude Desktop, or our own chat) decides which to call and when.\n\nTrunc ships a full MCP server with **two transports** and **two clients**.\n\n```mermaid\ngraph LR\n    subgraph Clients[\"MCP Clients\"]\n        CD[\"Claude Desktop\"]\n        Chat[\"Trunc's own chat<br/>/api/chat\"]\n    end\n\n    subgraph Server[\"MCP Server\"]\n        Stdio[\"stdio transport<br/>local subprocess\"]\n        Http[\"HTTP transport<br/>streamable, stateless\"]\n        Tools[\"tools.ts<br/>whoami · shorten_url<br/>get_my_links · get_clicks_by_day\"]\n    end\n\n    API[\"Express API\"]\n\n    CD -->|\"MIKKU_MCP_TOKEN\"| Stdio\n    CD -->|\"Authorization: Bearer\"| Http\n    Chat -->|\"Authorization: Bearer\"| Http\n    Stdio --> Tools\n    Http --> Tools\n    Tools -->|\"SHORTENER_API_BASE\"| API\n\n    classDef c fill:#0d0d0d,stroke:#37f712,color:#fff\n    classDef s fill:#111,stroke:#666,color:#fff\n    class CD,Chat c\n    class Stdio,Http,Tools,API s\n```\n\n### The tools\n\n| Tool | Auth | What it does |\n|:--|:--|:--|\n| `whoami` | — | Whether a JWT is visible to the server. |\n| `shorten_url` | **required** | Shorten a URL. Takes an optional `customAlias`. |\n| `get_my_links` | **required** | List the signed-in user's links. |\n| `get_clicks_by_day` | **required** | Clicks per day (1–90), zero-filled. |\n\n### The auth model\n\n**The MCP server has no login of its own.** It identifies the caller by the JWT the\nweb app issues, and it never mints one. That is deliberate:\n\n- **HTTP transport** — the token arrives per-request in the `Authorization` header.\n  The server is **stateless**: no sessions, no stored credentials. Two users hitting\n  the same deployed server see only their own links.\n- **stdio transport** — Claude Desktop runs the server as a subprocess, so there is\n  no request to carry a header. The token comes from an env var in the client config.\n\nGet your token from the in-app guide at **`/mcp`**, which shows it and generates a\nready-to-paste config.\n\n### Connecting Claude Desktop\n\n**HTTP** (nothing to install):\n\n```jsonc\n// claude_desktop_config.json\n{\n  \"mcpServers\": {\n    \"trunc\": {\n      \"url\": \"https://trunc-mcp-server.onrender.com/mcp\",\n      \"headers\": { \"Authorization\": \"Bearer <your-jwt>\" }\n    }\n  }\n}\n```\n\n**stdio** (runs locally):\n\n```jsonc\n{\n  \"mcpServers\": {\n    \"trunc\": {\n      \"command\": \"npx\",\n      \"args\": [\"tsx\", \"/path/to/mcp-server/src/index.ts\"],\n      \"env\": {\n        \"SHORTENER_API_BASE\": \"http://localhost:5050\",\n        \"TRUNC_MCP_TOKEN\": \"<your-jwt>\"\n      }\n    }\n  }\n}\n```\n\nQuit Claude Desktop **completely** (⌘Q) and reopen it. Then just ask:\n\n> *\"shorten github.com/subhm2004 and tell me which of my links is doing best\"*\n\nFull docs: **[mcp-server/README.md](mcp-server/README.md)**.\n\n---\n\n## The AI assistant\n\nThe app ships its own chat at **`/chat`** — and it is a **genuine MCP client**, not a\nchatbot bolted on beside one. It speaks the same protocol, to the same endpoint,\ncalling the same tools Claude Desktop would.\n\n```mermaid\nsequenceDiagram\n    autonumber\n    participant U as You\n    participant R as /api/chat<br/>server-side only\n    participant M as MCP Server\n    participant G as Groq<br/>llama-3.3-70b\n    participant A as Express API\n\n    U->>R: \"shorten github.com and show my top links\"\n    R->>M: tools/list\n    M-->>R: 4 tool definitions\n    R->>G: messages + tools\n    G-->>R: tool_calls: [shorten_url, get_my_links]\n\n    loop for each tool call (max 4 rounds)\n        R->>M: tools/call\n        M->>A: POST /api/shorten\n        A-->>M: { url }\n        M-->>R: result\n    end\n\n    R->>G: tool results\n    G-->>R: final answer\n    R-->>U: answer + the full tool trace\n```\n\nTwo things make this more than a demo.\n\n**The tool list is not hardcoded.** `/api/chat` asks the MCP server what tools exist\nand hands that list to the model. Add a tool to `mcp-server/src/tools.ts` and it\nappears in the chat with **no change to the frontend**.\n\n**It shows its work.** The arguments, the raw result, and the latency of every tool\ncall are on screen, expandable. Most chat UIs bury this: you get an answer and no way\nto tell whether the model looked anything up or simply made it up.\n\n> An assistant you cannot check is an assistant you cannot trust.\n\n```\nyou    shorten github.com/subhm2004 and show my top links\n\n       ✓ shorten_url                                    142ms   [+]\n       ✓ get_my_links                                    89ms   [+]\n\ntrunc  Done — it's at trunc.sh/x7Kp2mQ1. Your best performer is\n       /neon-db with 51 clicks, about half of everything this month.\n```\n\n### Security\n\n`GROQ_API_KEY` has **no `NEXT_PUBLIC_` prefix**, so Next refuses to inline it into\nthe client bundle. The chat runs entirely inside a route handler on the server — the\nbrowser never sees the key, and never sees the MCP server's address either.\n`lib/mcpClient.ts` imports `server-only`, so importing it into a client component is\na **build error** rather than a leak.\n\nThe loop is capped at four tool rounds. A model that keeps reaching for tools would\nkeep spending tokens forever; four is plenty for *\"shorten this and show my stats\"*\nand it bounds the cost of a bad prompt.\n\n---\n\n## Authentication\n\nTwo ways in, one token out.\n\n```mermaid\nsequenceDiagram\n    autonumber\n    participant B as Browser\n    participant A as Express API\n    participant G as Google\n    participant D as PostgreSQL\n\n    rect rgb(20,20,20)\n    Note over B,D: Password\n    B->>A: POST /api/auth/login\n    A->>D: SELECT … WHERE lower(email) = lower($1)\n    D-->>A: user + password_hash\n    A->>A: bcrypt.compare\n    A-->>B: JWT\n    end\n\n    rect rgb(20,20,20)\n    Note over B,D: Google OAuth 2.0 — authorization code\n    B->>A: GET /api/auth/google\n    A->>A: sign state nonce → httpOnly cookie\n    A-->>B: 302 → Google consent\n    B->>G: consent\n    G-->>B: 302 → /api/auth/google/callback?code&state\n    B->>A: callback\n    A->>A: verify state (CSRF)\n    A->>G: exchange code (client_secret)\n    G-->>A: id_token\n    A->>A: check aud · iss · exp · email_verified\n    A->>D: find by google_id → else link by email → else create\n    A-->>B: 302 → /auth/callback#token=JWT\n    end\n```\n\nGoogle is simply **another way to obtain our JWT**. Everything downstream — the\ndashboard, MCP, every protected route — is unchanged. One token system.\n\nThree decisions worth reading:\n\n**Account linking happens only on a Google-verified email.** If you signed up with a\npassword and later click *Continue with Google* on the same address, the two are\nlinked and your password keeps working. That is safe **only** because a profile whose\nemail Google hasn't verified is refused outright. Without that check, this branch is\nan account-takeover primitive: register a Google account claiming someone else's\naddress, and walk into their account.\n\n**The `state` nonce is the CSRF defence**, and the attack it stops runs backwards\nfrom the usual one. Without it an attacker completes their own consent and tricks\n*your* browser into our callback with *their* code — silently signing you into the\nattacker's account, where everything you then shorten lands in their dashboard. It's\nforgotten precisely because the damage flows the other way.\n\n**The JWT comes back in the URL fragment**, not the query string. A fragment is never\nsent to any server — not ours, not in a `Referer` header, not into access or proxy\nlogs. The callback page strips it with `history.replaceState` the moment it has read\nit. (A cookie would be tidier, but the token must be readable by JS: the `/mcp` page\nshows it to you so you can paste it into a client config.)\n\nThe whole feature is **optional**. With no client id configured the routes aren't\nmounted and the frontend hides the button — because it *asks* (`GET /api/auth/providers`)\nrather than assuming.\n\n---\n\n## What the rewrite fixed\n\nTrunc started as a Mongoose app. These were real defects in it, each now fixed and\n**verified against a live Postgres in CI** — not asserted in a README.\n\n**`url_code` had no unique constraint.**\nIt is the redirect key. Two links could be handed the same generated code, and one of\nthem would send visitors to the wrong site. It's now `UNIQUE`, and the service retries\non collision rather than pre-checking — pre-checking races.\n\n**Click counting lost increments under load.**\nThe redirect did read → `clickCount++` → `save()`. Two concurrent clicks both read the\nsame value; one increment vanished. It's now one atomic statement:\n\n```sql\nUPDATE urls SET click_count = click_count + 1 WHERE id = $1\n```\n\n> **Verified: 50 concurrent requests → exactly 51 counted.**\n\n**Analytics didn't scale.**\n`clicks-by-day` loaded every URL row — each carrying its full array of click timestamps\n— into Node, then counted them in a JS `Map`. That's O(every click ever) work and memory\nper dashboard load. It's now one SQL aggregation over a `generate_series` date spine,\nwhich zero-fills quiet days for free.\n\n**The URL validator accepted dangerous input.**\nSee [Chain of Responsibility](#08--chain-of-responsibility) — `javascript:` URLs and the\ncloud metadata endpoint both sailed through.\n\n**Auth was documented but not enforced.**\nThe old middleware called `next()` when no token was present, so `/api/shorten` was wide\nopen despite the docs, and every protected controller re-checked `if (!req.user)` by hand.\nThere are now two explicit middlewares, `requireAuth` and `optionalAuth`.\n\n**Custom aliases could be claimed anonymously.**\nA generated code is drawn from a space nobody wants. An alias is a claim on a scarce,\n*global* namespace — there is exactly one `/google`, one `/paypal`, one `/launch`. Handing\nthose to anonymous callers is handing out a squatting tool, and there is nobody to take\nthem back from. Aliases now require an account.\n\n**The redirect was a 301.**\nA 301 is cached by the browser *forever*, so every click after the first never reaches the\nserver. The count would freeze at 1, and the link could never be repointed. It's a 302.\n\n---\n\n## API reference\n\n| Method | Endpoint | Auth | Description |\n|:--|:--|:--|:--|\n| `GET` | `/health` | — | Health check. |\n| `POST` | `/api/auth/register` | — | Register. Returns a JWT so the client auto-logs in. |\n| `POST` | `/api/auth/login` | — | Log in. Returns a JWT. |\n| `GET` | `/api/auth/me` | **required** | The signed-in user. The JWT carries only an id. |\n| `GET` | `/api/auth/providers` | — | Which sign-in methods are configured. |\n| `GET` | `/api/auth/google` | — | Start the OAuth flow. *(only if configured)* |\n| `GET` | `/api/auth/google/callback` | — | OAuth return. *(only if configured)* |\n| `POST` | `/api/shorten` | optional | Shorten a URL. `customAlias` **requires** an account. |\n| `GET` | `/:code` | — | Redirect (302) and record the click. |\n| `GET` | `/api/links/my-links` | **required** | Your links. |\n| `GET` | `/api/links/clicks-by-day?days=30` | **required** | Clicks per day (1–90). Zero-filled. |\n| `GET` | `/api/links/overview` | **required** | Totals + your top 5 links. |\n\nEvery response uses the same envelope:\n\n```jsonc\n{ \"success\": true, \"message\": \"Short URL created.\", \"data\": { \"url\": { … } } }\n```\n\n4xx errors echo a clean message. 5xx errors return a generic one and **never leak\ninternals** — connection strings, table names and hostnames all live in 5xx messages.\n\n---\n\n## Testing and CI\n\nThree jobs, on every push and pull request.\n\n```mermaid\ngraph LR\n    Push[\"git push\"] --> CI{\"GitHub Actions\"}\n    CI --> B[\"backend<br/>42 unit tests + 54-check smoke test\"]\n    CI --> M[\"mcp-server<br/>typecheck · 28 tests · build\"]\n    CI --> F[\"frontend<br/>lint · build\"]\n\n    B --> PG[(\"postgres:17<br/>service container\")]\n\n    classDef j fill:#111,stroke:#37f712,color:#fff\n    class B,M,F j\n```\n\nThe backend is tested at two levels, because the two catch different things.\n\n**42 unit tests** — fast, isolated, no database. They finish in ~200ms, and that\nspeed is the dependency-injection seam paying off: every service takes its\ncollaborators through its constructor, so a test hands `UrlService` a fake\nrepository and a fake clock and drives it with no Postgres, no Express, no network.\n\n```bash\ncd app\nnpm test           # 42 tests, ~200ms\n```\n\nThey cover the logic that has edges — collision retry, the login enumeration guard,\nthe Google-account bcrypt crash, keyset pagination's off-by-one, and the token\nbucket's refill curve (tested against an injected clock, so *\"one token every six\nseconds\"* is verified in microseconds rather than by waiting six seconds).\n\n**A 54-check smoke test** — the real server, a real Postgres, over HTTP. This is for\nthe things a unit test *structurally cannot* reach:\n\n- the **click race** only appears under genuine concurrency\n- the **unique constraint** on `url_code` only exists in the database\n- the **SSRF/XSS rules** only matter against the real validation chain\n- the **cache eviction** on delete only matters against a warmed cache\n- the **rate-limit 429s** only happen with the real middleware on the real routes\n\n```bash\ncd app\nnpm run smoke      # against a running server\n```\n\n> The concurrency check **polls for convergence** rather than sleeping. Clicks are\n> written *after* the response — that is the whole point of the Observer seam, and it\n> makes the count eventually consistent. A fixed sleep raced the writes and would have\n> failed in CI at random. **A flaky check is worse than no check**: people learn to\n> re-run it. And this is *the* check guarding the lost-increment fix.\n\n\n---\n\n## Project layout\n\n```\napp/                        Backend — Express + PostgreSQL\n├─ config/                  Env parsing + boot-time validation      → Singleton\n├─ core/\n│  ├─ errors.js             AppError hierarchy\n│  ├─ logger.js             Level-filtered, redacts secrets         → Singleton\n│  ├─ ApiResponse.js        One response envelope                   → Builder\n│  └─ EventBus.js           Publish/subscribe                       → Observer\n├─ db/\n│  ├─ pool.js               Connection pool                         → Singleton\n│  ├─ migrate.js            Migration runner (advisory-locked)\n│  └─ migrations/           Plain SQL, applied in order\n├─ cache/\n│  ├─ InMemoryCache.js      TTL + LRU\n│  └─ NullCache.js                                                  → Null Object\n├─ repositories/\n│  ├─ BaseRepository.js     Query + transaction plumbing            → Template Method\n│  ├─ UserRepository.js                                             → Repository\n│  ├─ UrlRepository.js                                              → Repository\n│  ├─ ClickRepository.js                                            → Repository\n│  └─ CachedUrlRepository.js                                        → Decorator\n├─ strategies/shortcode/    NanoId · Base62 · CustomAlias           → Strategy + Factory\n├─ validation/              The URL rule chain                      → Chain of Responsibility\n├─ services/                Auth · Google · Url · Analytics\n├─ observers/               Post-response side-effects\n├─ controllers/             Transport only\n├─ routes/                  HTTP surface\n├─ scripts/smoke.mjs        37 end-to-end checks\n├─ server.js                App factory\n└─ container.js             Composition root                        → Dependency Injection\n\nfrontend/                   Next.js 15 · TypeScript · Tailwind v4\n└─ src/\n   ├─ app/\n   │  ├─ page.tsx           Landing — the live pipeline\n   │  ├─ chat/              The MCP-client assistant\n   │  ├─ api/chat/          Groq + tool loop — server-only\n   │  ├─ dashboard/         Links, clicks, analytics\n   │  ├─ mcp/               Token + client configs\n   │  └─ auth/callback/     OAuth return\n   ├─ components/\n   │  ├─ Pipeline.tsx       The terminal — layers print as they run\n   │  ├─ ScrambleText.tsx   The short code decrypting into place\n   │  └─ Shortener.tsx      Form → pipeline → code → link\n   ├─ context/              AuthProvider\n   └─ lib/\n      ├─ api.ts             One typed entry point to the API        → Facade\n      ├─ mcpClient.ts       JSON-RPC to the MCP server (server-only)\n      └─ tokenStore.ts      The only module that knows where the JWT lives\n\nmcp-server/                 TypeScript — stdio + HTTP transports\n└─ src/tools.ts             The four tools\n```\n\n---\n\n## License\n\nMIT — see [LICENSE](LICENSE).\n\n<div align=\"center\">\n<br />\n<sub>Built by <a href=\"https://github.com/subhm2004\">Shubham Malik</a></sub>\n</div>\n"}