<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ Node.js - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ Node.js - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Mon, 28 Sep 2026 21:39:18 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/nodejs/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Build an AI Chatbot with Gemini and Vercel Serverless Functions 🚀  ]]>
                </title>
                <description>
                    <![CDATA[ A couple of months back, I built a chatbot application using React, Node.js, and Vercel Serverless Functions, which I used in my web app, buildcv.makeadifference.app. In this tutorial, I'll walk you t ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-an-ai-chatbot-with-gemini-and-vercel-serverless-functions/</link>
                <guid isPermaLink="false">6a9f3c3bbf19ac7f324e6191</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ aitools ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Vercel ]]>
                    </category>
                
                    <category>
                        <![CDATA[ gemini ]]>
                    </category>
                
                    <category>
                        <![CDATA[ chatbot development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Johnson Samuel ]]>
                </dc:creator>
                <pubDate>Mon, 07 Sep 2026 22:35:39 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/4062fae6-db2f-47b8-add8-30d68a223f5b.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>A couple of months back, I built a chatbot application using React, Node.js, and Vercel Serverless Functions, which I used in my web app, <a href="http://buildcv.makeadifference.app">buildcv.makeadifference.app</a>.</p>
<p>In this tutorial, I'll walk you through exactly how I built it, from the backend serverless function that talks to Google's Gemini API, to the React chat widget that displays the AI's response as it's being generated.</p>
<p>By the end of this tutorial, you'll understand:</p>
<ol>
<li><p>How to set up a Vercel Serverless function that calls Gemini and streams its response back to the browser in small text chunks.</p>
</li>
<li><p>Why that approach makes a chatbot feel faster and more responsive than waiting for the entire reply to come back at once.</p>
</li>
<li><p>How to build a React component that reads that streaming response and updates the chat window in real time as new text arrives.</p>
</li>
</ol>
<p>We'll also test the endpoint with curl before wiring up the UI, and we'll cover how to actually deploy the whole thing to Vercel.</p>
<p>All of this revolves around a technique called <strong>plain-text chunk streaming</strong>. Rather than waiting for the AI to generate its entire response and then sending it all back to the browser at once, the serverless function streams the response as a series of small text chunks, writing each one to the browser as soon as it's ready.</p>
<p>This is what gives the chatbot that responsive, "typing" feel you've probably seen in modern AI chat interfaces, instead of a long pause followed by the full answer appearing all at once.</p>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ul>
<li><p><a href="#heading-architecture-overview">🧩 Architecture Overview</a></p>
</li>
<li><p><a href="#heading-prerequisites">✅ Prerequisites</a></p>
</li>
<li><p><a href="#heading-api-contract">📜 API Contract</a></p>
</li>
<li><p><a href="#heading-backend-the-vercel-serverless-function">⚙️ Backend (the Vercel Serverless Function)</a></p>
</li>
<li><p><a href="#heading-testing-the-endpoint-before-wiring-up-the-ui">🧪 Testing the Endpoint Before Wiring Up the UI</a></p>
</li>
<li><p><a href="#heading-frontend-reading-the-response-stream">🎨 Frontend: Reading the Response Stream</a></p>
</li>
<li><p><a href="#heading-deploying">🚀 Deploying</a></p>
</li>
<li><p><a href="#heading-a-note-on-cors-for-embedded-widgets">⚠️ A Note on CORS for Embedded Widgets</a></p>
</li>
<li><p><a href="#heading-conclusion">🎉 Conclusion</a></p>
</li>
</ul>
<h2 id="heading-architecture-overview">🧩 Architecture Overview</h2>
<p>The application is built from two main pieces. The UI component is a React chat widget, where the user types a message and the AI's response gets rendered back to them as it streams in. The backend is a Vercel serverless function (for example, <code>api/chat</code>) that validates the incoming request, calls Gemini, and streams the output back to the browser.</p>
<p>You can read more about Vercel's serverless functions in <a href="https://vercel.com/docs/functions">their official documentation</a>.</p>
<p>Here's how the whole flow works, from start to finish: the browser sends a request to the Vercel function, the Vercel function calls the Gemini API, and Gemini's response is streamed back to the browser chunk by chunk.</p>
<p>In practice, this means the backend sends the response to the frontend in small pieces as they become available, rather than making the user wait for the entire response to be generated before they see anything at all.</p>
<h2 id="heading-prerequisites">✅ Prerequisites</h2>
<p>Before you get started, make sure you have the following in place:</p>
<ul>
<li><p>Node.js installed locally (Node 18 or later)</p>
</li>
<li><p>A Gemini API key 🔑 from <a href="https://aistudio.google.com/">Google AI Studio</a></p>
</li>
<li><p>A <a href="https://vercel.com/">Vercel account</a> and the <a href="https://vercel.com/docs/cli">Vercel CLI</a></p>
</li>
<li><p>The <code>@google/genai</code> package installed in your function's project, which you can add by running <code>npm install @google/genai</code></p>
</li>
</ul>
<p>You'll also want to store your key in an environment variable called <code>process.env.GOOGLE_API_KEY</code> for local development, and add that same variable in your Vercel project's dashboard (under Settings and then Environment Variables) before deploying, so the API key gets picked up correctly once the application is hosted.</p>
<h2 id="heading-api-contract">📜 API Contract</h2>
<p>Before diving into the code, it's worth taking a moment to understand the "contract" between the frontend and backend – in other words, what the frontend is expected to send, and what the backend expects to receive in return.</p>
<p>When a user sends a message in the chat widget, the frontend makes a POST request to <code>/api/chat</code> with a JSON body containing two things: the message the user just typed, and the résumé data that's already loaded into the app (since this particular chatbot is acting as a résumé coach). The backend then uses both pieces of information together to generate a response that's actually relevant to that user's résumé, rather than a generic reply.</p>
<p><strong>Request:</strong></p>
<pre><code class="language-plaintext">POST /api/chat
{
  "message": "How can I improve my resume summary?",
  "resume": { "name": "...", "experience": [...], "skills": [...] }
}
</code></pre>
<h2 id="heading-backend-the-vercel-serverless-function">⚙️ Backend (the Vercel Serverless Function)</h2>
<p>This is the heart of the application: a single Vercel serverless function that receives the chat request, validates it, passes it along to Gemini, and streams the AI's response back to the browser as it's generated.</p>
<p>Below is the full handler, and I'll walk through what each part of it is doing once you've had a look at the whole thing.</p>
<pre><code class="language-jsx">const { GoogleGenAI } = require("@google/genai");
const ai = new GoogleGenAI({
    apiKey: process.env.GOOGLE_API_KEY,  // GOOGLE_API_KEY can be configured in Vercel
});
const MAX_TEXT_LENGTH = 2000;
const MAX_ARRAY_LENGTH = 50;

function sanitizeString(input = "") {
    // sanitize your input string here
}

function sanitizeObject(obj = {}) {
    // sanitize your resume object
}

const allowCors = fn =&gt; async (req, res) =&gt; {

    res.setHeader('Access-Control-Allow-Origin', '&lt;&lt;your web app url goes here&gt;&gt;');
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');

    // ✅ Handle preflight request
    if (req.method === 'OPTIONS') {
        return res.status(200).end();
    }
    // another option
    res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS, POST, PUT, DELETE')
    res.setHeader(
        'Access-Control-Allow-Headers',
        'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version'
    )
    if (req.method === 'OPTIONS') {
        res.status(200).end()
        return
    }
    return await fn(req, res)
};

const handler = async (req, res) =&gt; {

    if (req.url === '/api/chat' &amp;&amp; req.method === 'POST') {

        const { message, resume } = req.body || {};

        const safeMessage = sanitizeString(message);
        const safeResume = sanitizeObject(resume);

        if (!safeMessage) {
            return res.status(400).json({ error: "Invalid message" });
        }

        if (!safeMessage || !safeResume) {
            return res.status(400).json({ error: 'Missing message or resume data' });
        }

        try {
            const stream = await ai.models.generateContentStream({
                model: "&lt;&lt;GEMINI MODEL GOES HERE&gt;&gt;",
                contents: `
            You are a professional Resume Coach AI.

            - Always respond clearly, politely, and professionally.
            - &lt;&lt;Add descriptive instruction for the prompt based on your requirement&gt;&gt;
            - Resume data:
${JSON.stringify(safeResume, null, 2)}

User question:
${safeMessage}
`,
            });
            res.setHeader("Content-Type", "text/plain; charset=utf-8");
            res.setHeader("Cache-Control", "no-cache");

            for await (const chunk of stream) {
                const text = chunk.text;
                if (text) {
                    res.write(text);
                }
            }

            res.end();
        } catch (error) {
            console.error('Gemini Error:', error);
            res.status(500).json({ error: 'AI request failed' });
        }
    }

    // I have added a health check for testing the handler
    if (req.url === '/api/chat?type=healthcheck' &amp;&amp; req.method === 'GET') {
        res.status(200).json({ message: 'Hello from the chat endpoint!' });
    }
}

module.exports = allowCors(handler)
</code></pre>
<p>Now let's break that down piece by piece:</p>
<ul>
<li><p><strong>Setting up the Gemini client:</strong> At the top of the file, we create a <code>GoogleGenAI</code> client using the API key stored in the <code>GOOGLE_API_KEY</code> environment variable. This client is what we'll use to actually talk to Gemini throughout the rest of the function.</p>
</li>
<li><p><strong>Sanitizing the input:</strong> Before we do anything with the incoming request, we run the message and résumé through <code>sanitizeString</code> and <code>sanitizeObject</code>. These functions strip out anything unexpected or oversized, so we're never passing untrusted user input straight through to the AI without a check first.</p>
</li>
<li><p><strong>Handling CORS:</strong> The <code>allowCors</code> wrapper function sits around our handler and takes care of Cross-Origin Resource Sharing. Since the chat widget might be embedded on a different domain than the Vercel function itself, we need to explicitly allow requests from that domain and handle the <code>OPTIONS</code> preflight request that browsers automatically send before the real <code>POST</code> request goes through.</p>
</li>
<li><p><strong>Validating the request:</strong> Inside the handler itself, we check that both <code>message</code> and <code>resume</code> made it through sanitization successfully. If either one is missing or invalid, we return a 400 error right away instead of wasting a call to Gemini on a request we already know is bad.</p>
</li>
<li><p><strong>Calling Gemini and streaming the response:</strong> This is the key part of the whole tutorial. Instead of calling a regular "generate content" method and waiting for the full response to come back, we call <code>generateContentStream</code>, which returns an async iterable. We loop over that stream with a <code>for await...of</code> loop, and every time a new chunk of text comes in, we immediately write it to the response with <code>res.write(text)</code>. This is exactly what lets the browser start receiving text before Gemini has even finished generating the full answer.</p>
</li>
<li><p><strong>Error handling and the health check:</strong> If anything goes wrong while talking to Gemini, we catch the error, log it, and return a 500 response so the frontend knows something failed. There's also a simple <code>GET</code> health check endpoint you can hit to confirm the function is up and running before you start testing the actual chat flow.</p>
</li>
</ul>
<h2 id="heading-testing-the-endpoint-before-wiring-up-the-ui">🧪 Testing the Endpoint Before Wiring Up the UI</h2>
<p>Before you start building out the frontend, it's a good idea to confirm that the serverless function is actually streaming chunks the way you expect. You can do that with a simple curl request.</p>
<pre><code class="language-bash">curl -N -X POST "https://YOUR_APP.vercel.app/api/chat?type=chat" \
  -H "Content-Type: application/json" \
  -d '{"message":"Give me 3 resume summary tips","resume":{"name":"Test"}}'
</code></pre>
<p>The <code>-N</code> flag disables curl's output buffering, so you'll see the text appear incrementally in your terminal rather than all at once. That's your confirmation that the streaming is working end to end, before you've written a single line of frontend code.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6093d844d1bbc47b91a66bb6/03f59037-0d2b-49eb-9bb2-87716698d5d2.png" alt="Streaming working" style="display: block;" width="600" height="400" loading="lazy">

<h2 id="heading-frontend-reading-the-response-stream">🎨 Frontend: Reading the Response Stream</h2>
<p>Here's a screenshot of the chat widget UI I built in React:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6093d844d1bbc47b91a66bb6/3c726767-0fbc-4784-9ce0-24f9d94ae549.png" alt="3c726767-0fbc-4784-9ce0-24f9d94ae549" style="display: block;" width="600" height="400" loading="lazy">

<p>We won't walk through building that UI from scratch here. The layout, styling, and message list are really up to you and your own design preferences.</p>
<p>What we will cover is the function that actually powers it: the code behind the send button, which takes what the user typed, sends it off to our serverless function, and reads back the streaming response so the widget can display the AI's reply as it arrives, piece by piece, the way you see it happening in the screenshot above.</p>
<p>Before jumping into that function, here's the shell it lives inside, so you can see where it fits in the component:</p>
<pre><code class="language-jsx">function ChatWidget({ resume }) {
  const [messages, setMessages] = useState([]);
  const [input, setInput] = useState("");
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  async function sendMessage() {
    // ...actual function is added below
  }

  return (
    &lt;div className="chat-widget"&gt;
      {/* message list, input box, and a send button that calls sendMessage() */}
    &lt;/div&gt;
  );
}
</code></pre>
<p>That's the shell the function lives in: state for the message list, the input box, a loading flag, and an error slot. <code>sendMessage</code> is what fires when the send button is clicked.</p>
<p>Here's the full function, <code>sendMessage</code>, which runs whenever the user types a prompt and clicks send. This is the piece of code that connects the UI shown above to the backend we just built.</p>
<pre><code class="language-jsx">async function sendMessage() {
    const messageText = input.trim();
    if (!messageText || loading) return;

    const userMessage = { role: "user", text: messageText };
    setMessages((message) =&gt; [...message, userMessage]);
    setInput("");
    setLoading(true);
    setError(null);

    try {
      const res = await fetch("&lt;&lt;YOUR VERCEL SERVERLESS FUNCTION URL GOES HERE&gt;&gt;/api/chat", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ message: messageText, resume })
      });

      if (!res.ok) {
        throw new Error(`Failed to get response: ${res.status}`);
      }

      const reader = res.body.getReader();
      const decoder = new TextDecoder();

      let fullText = "";
      setMessages((message) =&gt; [...message, { role: "assistant", text: "" }]);

      while (true) {
        const { value, done } = await reader.read();
        if (done) break;

        const chunk = decoder.decode(value);
        fullText += chunk;

        setMessages((message) =&gt; {
          const updated = [...message];
          updated[updated.length - 1] = { role: "assistant", text: fullText };
          return updated;
        });
      }

    } catch (err) {
      setError("Failed to send message. Please try again.");
      setMessages((m) =&gt; [...m, {
        role: "assistant",
        text: "Sorry, I encountered an error. Please try again later."
      }]);
    } finally {
      setLoading(false);
    }
  }
</code></pre>
<p>Here's what's happening step by step:</p>
<p>First, when the user clicks send, we grab their typed message and immediately add it to the <code>messages</code> array so it shows up in the chat right away.</p>
<p>Then we send a <code>POST</code> request to our <code>/api/chat</code> endpoint with the message and the résumé data. Once we get a response back, we call <code>res.body.getReader()</code> to get a <code>ReadableStreamDefaultReader</code> on the response body, and we use a <code>TextDecoder</code> to convert each raw chunk of bytes into readable text.</p>
<p>From there, we loop, reading one chunk at a time and appending it to a growing <code>fullText</code> string, and on every iteration we update the last message in the chat with that text.</p>
<p>That loop is exactly what creates the "typing" effect you see in the screenshot above: the assistant's message in the chat widget visibly grows word by word as new chunks arrive, instead of popping in all at once. This pattern is a bit different from the typical <code>fetch().then(res =&gt; res.json())</code> call you're probably used to, so it's worth pausing on if you haven't streamed a fetch response before.</p>
<h2 id="heading-deploying">🚀 Deploying</h2>
<p>Once you've confirmed that the function and UI both work locally, deploying comes down to three steps:</p>
<ol>
<li><p><strong>Set your environment variable in Vercel.</strong> In your project's dashboard, go to Settings and then Environment Variables and add <code>GOOGLE_API_KEY</code> with the same value you used locally.</p>
</li>
<li><p><strong>Update your CORS origin and fetch URL.</strong> In the backend, point <code>allowCors</code>'s <code>Access-Control-Allow-Origin</code> header at your real deployed domain. In the frontend, point the <code>fetch</code> call at that same deployed function URL instead of localhost.</p>
</li>
<li><p><strong>Deploy</strong>, using whichever of the two options below fits your workflow.</p>
</li>
</ol>
<h4 id="heading-option-a-deploy-from-the-cli">Option A: Deploy from the CLI</h4>
<pre><code class="language-bash">npm install -g vercel   # if you haven't already
vercel login
vercel --prod
</code></pre>
<p><code>vercel login</code> authenticates your machine, and <code>vercel --prod</code> builds and ships straight to production from your project directory. This is a good fit for one-off deploys, or when you want full control over exactly when a deploy happens.</p>
<h4 id="heading-option-b-deploy-via-github-integration">Option B: Deploy via GitHub integration</h4>
<ol>
<li><p>Push your project to a GitHub repository (if it isn't already).</p>
</li>
<li><p>In the Vercel dashboard, click <strong>Add New</strong> and then <strong>Project</strong>, then select your repository.</p>
</li>
<li><p>Vercel auto-detects your framework settings. Confirm them and click <strong>Deploy</strong>.</p>
</li>
<li><p>From then on, every push to your main branch triggers an automatic redeploy.</p>
</li>
</ol>
<p>I've always preferred this option and it has been more convenient if you're actively iterating on the project, since you never have to remember to run a deploy command yourself.</p>
<p>Once the deploy finishes, Vercel will give you a live URL for your app. Test it with the same curl command from earlier, swapping in your production URL, to confirm that streaming is working correctly in production before you consider yourself done.</p>
<h2 id="heading-a-note-on-cors-for-embedded-widgets">⚠️ A Note on CORS for Embedded Widgets</h2>
<p>If you're planning to embed this chat widget on a website that lives on a different domain than your Vercel app (for example, embedding it as a widget on a marketing site while the function itself is deployed elsewhere), you'll run into CORS restrictions by default. Browsers block requests to a different origin unless the server explicitly allows them.</p>
<p>To handle this correctly, your serverless function needs to respond to the <code>OPTIONS</code> preflight request that the browser automatically sends before the real <code>POST</code> request, and it needs to set the <code>Access-Control-Allow-Origin</code> header to match the domain your widget is actually running on. That's exactly what the <code>allowCors</code> wrapper in the backend code above is doing for you.</p>
<h2 id="heading-conclusion">🎉 Conclusion</h2>
<p>Plain-text chunk streaming is what makes a chatbot feel responsive on Vercel. Because the user sees the response appear gradually, they aren't stuck waiting for the entire answer to be generated before anything shows up on screen.</p>
<p>If you'd like to keep building on this, here are a few natural next steps to consider: adding rate limiting to prevent abuse of the endpoint, adding conversation persistence with something like KV, Redis, or Postgres so chat history isn't lost on refresh, and adding a stop button to the widget so users can cancel a response that's already in the middle of generating.</p>
<p>If you build something with this approach, I'd love to hear about it! What are you planning to build with Gemini and Vercel?</p>
<p>Hope you have a blessed week! 😇</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Scholarship Research MCP Server with Node.js, Express, and MongoDB ]]>
                </title>
                <description>
                    <![CDATA[ Scholarship hunting is a research job, not a single search box. You filter awards by field, GPA, citizenship, and deadline. You keep a shortlist. You write notes about essays and recommenders. Then yo ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-scholarship-research-mcp-server-with-node-js-express-and-mongodb/</link>
                <guid isPermaLink="false">6a9b2db334fe985abf2f91a9</guid>
                
                    <category>
                        <![CDATA[ mcp ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Model Context Protocol ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Express.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ MongoDB ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Chinedu Otutu ]]>
                </dc:creator>
                <pubDate>Fri, 04 Sep 2026 20:44:35 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/bc3b99da-15f4-4366-aede-4a2b567724aa.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Scholarship hunting is a research job, not a single search box. You filter awards by field, GPA, citizenship, and deadline. You keep a shortlist. You write notes about essays and recommenders. Then you come back a week later and try to remember why you saved a particular program.</p>
<p>An AI assistant can help with that workflow, but only if it can query a real catalog and persist what you already decided. Chat history isn't a database. A hallucinated deadline is worse than no deadline at all.</p>
<p>The <a href="https://modelcontextprotocol.io/docs/learn/architecture">Model Context Protocol (MCP)</a> is the standard way to give AI apps that kind of access. In this tutorial, you'll build a scholarship research MCP server with Node.js, Express, and MongoDB.</p>
<p>When you finish, Cursor, Claude Desktop, or any other MCP host will be able to search awards, match them to a student profile, bookmark a shortlist, and store research notes. The model stays the reasoning layer while your server owns the data.</p>
<p>You'll build:</p>
<ul>
<li><p>A MongoDB catalog of scholarships, plus saved-list and notes collections</p>
</li>
<li><p>Nine MCP tools for search, matching, deadlines, comparison, and research tracking</p>
</li>
<li><p>Resources so a client can read the catalog without calling a tool</p>
</li>
<li><p>Prompts that turn a student profile into a research plan</p>
</li>
<li><p>An Express app that serves MCP over Streamable HTTP</p>
</li>
</ul>
<p>The sample catalog in this project is a teaching dataset. Amounts, dates, and eligibility rules are simplified. Always confirm details on the official application page before applying.</p>
<h2 id="heading-what-you-need">What You Need</h2>
<p>You should be comfortable with JavaScript and basic Express routing. You don't need prior MCP experience.</p>
<p>Install:</p>
<ul>
<li><p><a href="https://nodejs.org/">Node.js 20</a> or later</p>
</li>
<li><p><a href="https://www.mongodb.com/docs/manual/installation/">MongoDB</a> running locally, or a free <a href="https://www.mongodb.com/atlas">MongoDB Atlas</a> cluster</p>
</li>
<li><p>An MCP client if you want to try the last section. <a href="https://cursor.com/">Cursor</a> and <a href="https://claude.ai/download">Claude Desktop</a> both work.</p>
</li>
</ul>
<p>Docker is enough for MongoDB:</p>
<pre><code class="language-bash">docker run -d --name mongo -p 27017:27017 mongo:7
</code></pre>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-you-need">What You Need</a></p>
</li>
<li><p><a href="#heading-what-is-the-model-context-protocol">What Is the Model Context Protocol?</a></p>
</li>
<li><p><a href="#heading-why-a-scholarship-research-server">Why a Scholarship Research Server?</a></p>
</li>
<li><p><a href="#heading-how-the-architecture-fits-together">How the Architecture Fits Together</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-the-project">How to Set Up the Project</a></p>
</li>
<li><p><a href="#heading-how-to-connect-mongodb">How to Connect MongoDB</a></p>
</li>
<li><p><a href="#heading-how-to-model-scholarship-data">How to Model Scholarship Data</a></p>
</li>
<li><p><a href="#heading-how-to-write-the-scholarship-service">How to Write the Scholarship Service</a></p>
</li>
<li><p><a href="#heading-how-to-register-mcp-tools">How to Register MCP Tools</a></p>
</li>
<li><p><a href="#heading-how-to-expose-resources-and-prompts">How to Expose Resources and Prompts</a></p>
</li>
<li><p><a href="#heading-how-to-serve-mcp-over-express">How to Serve MCP Over Express</a></p>
</li>
<li><p><a href="#heading-how-to-seed-the-catalog">How to Seed the Catalog</a></p>
</li>
<li><p><a href="#heading-how-to-test-the-server">How to Test the Server</a></p>
</li>
<li><p><a href="#heading-how-to-connect-cursor-and-claude-desktop">How to Connect Cursor and Claude Desktop</a></p>
</li>
<li><p><a href="#heading-how-a-research-session-runs">How a Research Session Runs</a></p>
</li>
<li><p><a href="#heading-how-the-matching-logic-works">How the Matching Logic Works</a></p>
</li>
<li><p><a href="#heading-what-you-can-build-next">What You Can Build Next</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-is-the-model-context-protocol">What Is the Model Context Protocol?</h2>
<p>MCP is an open protocol that lets an AI application talk to external tools and data sources through a shared contract. Anthropic introduced it in 2024, and it's now maintained as an open standard.</p>
<p>Anthropic described MCP as "a USB-C port for AI applications": one connector, many hosts. (Source: <a href="https://www.anthropic.com/news/model-context-protocol">Introducing the Model Context Protocol</a>) Instead of writing one integration for Cursor, another for Claude Desktop, and a third for a custom agent, you implement the protocol once.</p>
<p>The <a href="https://modelcontextprotocol.io/docs/learn/architecture">official architecture overview</a> splits MCP into two layers:</p>
<ul>
<li><p>The <strong>data layer</strong> is JSON-RPC 2.0. Clients and servers exchange requests such as <code>tools/list</code> and <code>tools/call</code>.</p>
</li>
<li><p>The <strong>transport layer</strong> moves those messages. Local servers usually use stdio. Remote or long-running servers use Streamable HTTP.</p>
</li>
</ul>
<p>This tutorial uses Streamable HTTP, because Express is already an HTTP server and you want the catalog available to any client on your machine.</p>
<h3 id="heading-hosts-clients-and-servers">Hosts, Clients, and Servers</h3>
<p>Three roles show up in every MCP setup:</p>
<ul>
<li><p>The <strong>host</strong> is the AI app. Cursor and Claude Desktop are hosts.</p>
</li>
<li><p>The <strong>client</strong> lives inside the host. The host creates one client per connected server.</p>
</li>
<li><p>The <strong>server</strong> is your program. It advertises tools, resources, and prompts, then handles calls.</p>
</li>
</ul>
<p>Your scholarship app is the server. You never talk to the model SDK directly. The host does that.</p>
<h3 id="heading-tools-resources-and-prompts">Tools, Resources, and Prompts</h3>
<p>MCP servers expose three primitives. You'll use all three.</p>
<p><strong>Tools</strong> are actions. The model decides to call them, the way it might call a function in a regular tool-calling API. Search, save, and compare belong here.</p>
<p><strong>Resources</strong> are data the host can read and attach as context. A catalog URI and a per-scholarship URI belong here. The model doesn't have to "take an action" to see them.</p>
<p><strong>Prompts</strong> are named templates. People usually invoke them from a slash command or a menu. A "research plan" prompt belongs here, because it's a workflow you want to start on purpose.</p>
<p>That split matters. If you put everything in tools, the model has to guess when to look things up. Resources and prompts give the host better knobs.</p>
<h2 id="heading-why-a-scholarship-research-server">Why a Scholarship Research Server?</h2>
<p>A weather MCP demo is a single API call. Scholarship research is closer to a real product:</p>
<ul>
<li><p>The catalog must be queryable. Keyword search, GPA filters, and deadline windows all live in the database.</p>
</li>
<li><p>The workflow must persist. A saved shortlist and research notes should survive a new chat.</p>
</li>
<li><p>Eligibility is logic, not prose. A GPA minimum is a number. First-generation-only is a boolean. Put those checks in code so the model can't invent a match.</p>
</li>
<li><p>The output must be inspectable. Students should be able to open the official URL and verify every claim.</p>
</li>
</ul>
<p>MongoDB fits this well. Each scholarship is a document with nested arrays for fields of study, citizenship, and requirements. Saved items and notes are separate collections with references back to the catalog.</p>
<p>You could wrap a public API instead of storing documents. That's a good follow-up. Starting with your own catalog keeps the tutorial self-contained and makes the MCP contract obvious.</p>
<h2 id="heading-how-the-architecture-fits-together">How the Architecture Fits Together</h2>
<p>The finished project looks like this:</p>
<pre><code class="language-text">MCP host (Cursor or Claude Desktop)
        |
        |  Streamable HTTP  POST /mcp
        v
Express app  (createMcpExpressApp)
        |
        |  createMcpHandler factory
        v
McpServer  tools / resources / prompts
        |
        v
Scholarship service
        |
        v
MongoDB  scholarships, savedScholarships, researchnotes
</code></pre>
<p>A few design choices are worth calling out before you write code.</p>
<p>The MCP handler is <strong>stateless</strong>. The SDK runs your server factory once per HTTP request. That's the recommended v2 pattern for Streamable HTTP. Don't keep tool state on the <code>McpServer</code> instance. Keep it in MongoDB.</p>
<p>The database connection is <strong>process-wide</strong>. Connecting on every request would be slow and pointless. You connect once at startup and close over that pool from the tools.</p>
<p>The HTTP surface is small on purpose. <code>/health</code> is for you. <code>/mcp</code> is for the protocol. You don't need a REST API in front of the same data unless you want one later.</p>
<h2 id="heading-how-to-set-up-the-project">How to Set Up the Project</h2>
<p>Create a folder and initialize a Node.js project. ESM is required because the MCP SDK is ESM-first.</p>
<pre><code class="language-bash">mkdir scholarship-research-mcp-server
cd scholarship-research-mcp-server
npm init -y
</code></pre>
<p>Open <code>package.json</code> and set <code>"type": "module"</code>. Then install the SDK, Express, Mongoose, Zod, and dotenv:</p>
<pre><code class="language-bash">npm install @modelcontextprotocol/server @modelcontextprotocol/express @modelcontextprotocol/node express mongoose dotenv zod
</code></pre>
<p>The SDK split into packages in v2:</p>
<ul>
<li><p><code>@modelcontextprotocol/server</code> is the <code>McpServer</code> class and <code>createMcpHandler</code></p>
</li>
<li><p><code>@modelcontextprotocol/express</code> gives you <code>createMcpExpressApp</code>, including DNS rebinding protection</p>
</li>
<li><p><code>@modelcontextprotocol/node</code> adapts the web-standard handler to Node's <code>req</code>/<code>res</code></p>
</li>
</ul>
<p>Create a <code>.env</code> file:</p>
<pre><code class="language-bash">MONGODB_URI=mongodb://127.0.0.1:27017/scholarship_research
PORT=3000
HOST=127.0.0.1
</code></pre>
<p>Add a <code>.gitignore</code> that excludes <code>node_modules</code> and <code>.env</code>.</p>
<p>Your source layout can stay small:</p>
<pre><code class="language-text">src/
  index.js
  config.js
  db.js
  models/
  services/
  mcp/
  utils/
data/
  scholarships.json
scripts/
  seed.js
  smoke-test.js
</code></pre>
<p><code>src/config.js</code> reads environment variables with defaults:</p>
<pre><code class="language-javascript">export const config = {
  mongodbUri: process.env.MONGODB_URI ?? "mongodb://127.0.0.1:27017/scholarship_research",
  port: Number(process.env.PORT ?? 3000),
  host: process.env.HOST ?? "127.0.0.1",
};
</code></pre>
<p>Keep configuration in one file. Tools shouldn't read <code>process.env</code> directly.</p>
<h2 id="heading-how-to-connect-mongodb">How to Connect MongoDB</h2>
<p>Mongoose 9 works cleanly with ESM. A short <code>src/db.js</code> is enough:</p>
<pre><code class="language-javascript">import mongoose from "mongoose";
import { config } from "./config.js";

export async function connectDatabase() {
  mongoose.set("strictQuery", true);
  await mongoose.connect(config.mongodbUri);
  return mongoose.connection;
}
</code></pre>
<p>Call this once in <code>src/index.js</code> before <code>app.listen</code>. If the connection fails, the process should exit. A running Express server with a dead database is harder to debug than a failed startup.</p>
<h2 id="heading-how-to-model-scholarship-data">How to Model Scholarship Data</h2>
<p>You need three collections.</p>
<h3 id="heading-scholarship">Scholarship</h3>
<p>This is the catalog. Store the fields a matching engine actually uses, not a blob of markdown.</p>
<pre><code class="language-javascript">import mongoose from "mongoose";

const scholarshipSchema = new mongoose.Schema(
  {
    title: { type: String, required: true, trim: true },
    provider: { type: String, required: true, trim: true },
    description: { type: String, required: true },
    amountMin: { type: Number, default: 0 },
    amountMax: { type: Number, default: 0 },
    currency: { type: String, default: "USD" },
    deadline: { type: Date, default: null },
    rolling: { type: Boolean, default: false },
    educationLevels: { type: [String], default: ["undergraduate"] },
    fieldsOfStudy: { type: [String], default: ["any"] },
    gpaMinimum: { type: Number, default: null },
    citizenship: { type: [String], default: ["any"] },
    countries: { type: [String], default: ["any"] },
    firstGenerationOnly: { type: Boolean, default: false },
    womenOnly: { type: Boolean, default: false },
    numberOfAwards: { type: Number, default: 1 },
    renewable: { type: Boolean, default: false },
    applicationUrl: { type: String, required: true },
    applicationRequirements: { type: [String], default: [] },
  },
  { timestamps: true },
);

scholarshipSchema.index({
  title: "text",
  provider: "text",
  description: "text",
  fieldsOfStudy: "text",
});
scholarshipSchema.index({ deadline: 1 });
scholarshipSchema.index({ amountMax: -1 });

export const Scholarship = mongoose.model("Scholarship", scholarshipSchema);
</code></pre>
<p>A few field choices are doing real work:</p>
<ul>
<li><p><code>fieldsOfStudy: ["any"]</code> means the award is field-open. The matcher treats <code>any</code> as a wildcard.</p>
</li>
<li><p><code>deadline: null</code> plus <code>rolling: true</code> covers programs that accept applications year-round.</p>
</li>
<li><p><code>applicationUrl</code> is mandatory. Every tool result should point back to a human-verifiable source.</p>
</li>
<li><p><code>gpaMinimum: null</code> means the provider didn't publish a cutoff. That's different from <code>0</code>.</p>
</li>
</ul>
<h3 id="heading-saved-scholarship">Saved Scholarship</h3>
<p>A research list is a join between a person and a catalog row.</p>
<pre><code class="language-javascript">const savedScholarshipSchema = new mongoose.Schema(
  {
    researcherId: { type: String, required: true, default: "default" },
    scholarship: {
      type: mongoose.Schema.Types.ObjectId,
      ref: "Scholarship",
      required: true,
    },
    status: {
      type: String,
      enum: ["saved", "applying", "submitted", "won", "rejected"],
      default: "saved",
    },
  },
  { timestamps: true },
);

savedScholarshipSchema.index({ researcherId: 1, scholarship: 1 }, { unique: true });
</code></pre>
<p>The unique index makes <code>save_scholarship</code> idempotent. Saving the same award twice updates the status instead of creating duplicates.</p>
<p><code>researcherId</code> is a plain string. For a tutorial, that's enough. In production you would take it from an auth token.</p>
<h3 id="heading-research-note">Research Note</h3>
<p>Notes are a separate collection so one scholarship can have many of them.</p>
<pre><code class="language-javascript">const researchNoteSchema = new mongoose.Schema(
  {
    researcherId: { type: String, required: true, default: "default" },
    scholarship: {
      type: mongoose.Schema.Types.ObjectId,
      ref: "Scholarship",
      required: true,
    },
    body: { type: String, required: true, trim: true },
  },
  { timestamps: true },
);
</code></pre>
<p>You now have a catalog, a shortlist, and a notebook. That's the whole product surface the MCP tools will wrap.</p>
<h2 id="heading-how-to-write-the-scholarship-service">How to Write the Scholarship Service</h2>
<p>Keep MongoDB queries out of the MCP layer. Tools should call a service, get plain objects back, and format text. That makes the same functions reusable from a seed script, a smoke test, or a future REST route.</p>
<h3 id="heading-search">Search</h3>
<p>Search is a filter builder. Each optional argument adds a clause. Open or rolling awards stay in the result set. Closed deadlines drop out.</p>
<pre><code class="language-javascript">const OPEN_DEADLINE_FILTER = {
  $or: [{ rolling: true }, { deadline: null }, { deadline: { $gte: new Date() } }],
};

export async function searchScholarships(filters) {
  const query = { ...OPEN_DEADLINE_FILTER };
  const and = [query];

  if (filters.keyword) {
    and.push({
      $or: [
        { title: { $regex: escapeRegex(filters.keyword), $options: "i" } },
        { provider: { $regex: escapeRegex(filters.keyword), $options: "i" } },
        { description: { $regex: escapeRegex(filters.keyword), $options: "i" } },
        { fieldsOfStudy: { $regex: escapeRegex(filters.keyword), $options: "i" } },
      ],
    });
  }

  if (filters.fieldOfStudy) {
    and.push({
      $or: [
        { fieldsOfStudy: { $regex: `^any$`, $options: "i" } },
        { fieldsOfStudy: { $regex: escapeRegex(filters.fieldOfStudy), $options: "i" } },
      ],
    });
  }

  // educationLevel, citizenship, country, minAmount, gpa, flags...

  const results = await Scholarship.find({ $and: and })
    .sort({ deadline: 1, amountMax: -1 })
    .limit(100)
    .lean();

  return rankByFieldMatch(results, filters.fieldOfStudy).slice(0, filters.limit ?? 10);
}
</code></pre>
<p>Two details are easy to skip and worth keeping.</p>
<p>Escape user input before you drop it into <code>$regex</code>. A keyword of <code>(</code> shouldn't become a broken regular expression.</p>
<p>When a student searches for <code>computer science</code>, field-open awards (<code>any</code>) are eligible, but the specific CS scholarships should appear first. Rank in memory after the query. MongoDB already did the eligibility filter. You're only adjusting display order.</p>
<h3 id="heading-match">Match</h3>
<p>Matching is not the same as search. Search is "find documents that look like this." Matching is "here is a student, score every open award."</p>
<p>The service loads open scholarships, then applies hard filters and a score:</p>
<table>
<thead>
<tr>
<th>Signal</th>
<th>Effect</th>
</tr>
</thead>
<tbody><tr>
<td>Education level mismatch</td>
<td>Skip</td>
</tr>
<tr>
<td>GPA below the minimum</td>
<td>Skip</td>
</tr>
<tr>
<td>Citizenship mismatch</td>
<td>Skip</td>
</tr>
<tr>
<td>First-generation-only and student is not</td>
<td>Skip</td>
</tr>
<tr>
<td>Women-only and student is not</td>
<td>Skip</td>
</tr>
<tr>
<td>Education level match</td>
<td>+20</td>
</tr>
<tr>
<td>GPA eligible</td>
<td>+15</td>
</tr>
<tr>
<td>Citizenship eligible</td>
<td>+15</td>
</tr>
<tr>
<td>Field of study match</td>
<td>+30</td>
</tr>
<tr>
<td>Preferred country match</td>
<td>+10</td>
</tr>
<tr>
<td>Amount meets the student's minimum</td>
<td>+10</td>
</tr>
<tr>
<td>First-generation or women-only match</td>
<td>+10</td>
</tr>
</tbody></table>
<p>Hard filters prevent false hope. Soft scores rank the rest. Each result also returns a <code>reasons</code> array, so the model can explain the match instead of inventing one.</p>
<p>That last point is the whole reason to put matching on the server. If you only return raw documents, the model will sometimes "helpfully" include an award the student can't apply for. Returning <code>score</code> and <code>reasons</code> keeps the explanation grounded in code.</p>
<h3 id="heading-save-notes-deadlines-compare">Save, Notes, Deadlines, Compare</h3>
<p>The remaining functions are thin:</p>
<ul>
<li><p><code>saveScholarship</code> upserts by <code>(researcherId, scholarshipId)</code></p>
</li>
<li><p><code>addResearchNote</code> inserts a note after confirming the scholarship exists</p>
</li>
<li><p><code>getUpcomingDeadlines</code> queries <code>deadline</code> between now and <code>now + N days</code></p>
</li>
<li><p><code>compareScholarships</code> loads two or three documents and returns the same fields for each</p>
</li>
</ul>
<p>Validate MongoDB ids with <code>mongoose.Types.ObjectId.isValid</code> before you query. An LLM will occasionally pass a title where you asked for an id. Fail clearly. Don't throw a CastError into the MCP transport.</p>
<h2 id="heading-how-to-register-mcp-tools">How to Register MCP Tools</h2>
<p>Create <code>src/mcp/server.js</code> as a factory. The HTTP handler will call it on every request.</p>
<pre><code class="language-javascript">import { McpServer } from "@modelcontextprotocol/server";
import { registerPrompts } from "./prompts.js";
import { registerResources } from "./resources.js";
import { registerTools } from "./tools.js";

export function createScholarshipServer() {
  const server = new McpServer({
    name: "scholarship-research",
    version: "1.0.0",
  });

  registerTools(server);
  registerResources(server);
  registerPrompts(server);

  return server;
}
</code></pre>
<p>Keep this factory cheap. No database connections, no file reads, and no caches that belong at module scope. The <a href="https://ts.sdk.modelcontextprotocol.io/v2/serving/http.html">HTTP serving guide</a> is explicit about this: create connection pools once at startup, and close over them.</p>
<h3 id="heading-one-tool-fully">One Tool, Fully</h3>
<p><code>registerTool</code> takes a name, a config object, and a handler. The <code>inputSchema</code> is a Zod object. The SDK turns that schema into JSON Schema for <code>tools/list</code>, validates arguments before your handler runs, and infers types if you're on TypeScript.</p>
<pre><code class="language-javascript">import * as z from "zod/v4";

server.registerTool(
  "search_scholarships",
  {
    title: "Search scholarships",
    description:
      "Search the scholarship catalog by keyword, field of study, education level, citizenship, country, GPA, and award amount.",
    inputSchema: z.object({
      keyword: z.string().min(1).optional().describe("Free-text search across title, provider, description, and fields"),
      fieldOfStudy: z.string().optional().describe("For example computer science, public health, or engineering"),
      educationLevel: z.enum(["undergraduate", "graduate", "doctoral"]).optional(),
      citizenship: z.string().optional(),
      country: z.string().optional(),
      minAmount: z.number().nonnegative().optional(),
      gpa: z.number().min(0).max(4).optional(),
      firstGeneration: z.boolean().optional(),
      womenOnly: z.boolean().optional(),
      limit: z.number().int().min(1).max(25).optional(),
    }),
    annotations: { readOnlyHint: true, openWorldHint: false },
  },
  async (args) =&gt; {
    const results = await searchScholarships(args);
    return toolText(formatScholarshipList(results));
  },
);
</code></pre>
<p>Write descriptions as if the model is the only docs the tool will ever get. <code>.describe()</code> on a Zod field survives conversion to JSON Schema. That's how the host tells the model what <code>fieldOfStudy</code> means.</p>
<p><code>title</code> is the human label. <code>description</code> is the model-facing contract. They're not the same string.</p>
<h3 id="heading-annotations">Annotations</h3>
<p>Annotations don't change how the SDK runs the tool. Hosts use them to decide how cautious to be.</p>
<ul>
<li><p><code>readOnlyHint: true</code> for search, get, match, list, compare, and deadlines</p>
</li>
<li><p><code>readOnlyHint: false</code> for save and add-note</p>
</li>
<li><p><code>idempotentHint: true</code> on save, because of the unique index</p>
</li>
<li><p><code>openWorldHint: false</code> because this server talks to your database, not the open web</p>
</li>
</ul>
<p>A host can auto-approve a read-only search and ask the user before a write. That's worth five extra keys in the config.</p>
<h3 id="heading-return-shape">Return Shape</h3>
<p>Every tool returns MCP content blocks:</p>
<pre><code class="language-javascript">export function toolText(text, isError = false) {
  return {
    content: [{ type: "text", text }],
    isError,
  };
}
</code></pre>
<p>Return <code>isError: true</code> for domain failures such as "scholarship not found." Throw only for unexpected failures. The spec treats those differently. A validation error from Zod never reaches your handler. The SDK already converts it into an <code>isError</code> result.</p>
<p>Format lists for a person who is skimming a chat transcript. Include the MongoDB id on every row. Later tools need that id, and the model can't invent a valid ObjectId.</p>
<h3 id="heading-the-full-tool-set">The Full Tool Set</h3>
<p>The server registers nine tools:</p>
<table>
<thead>
<tr>
<th>Tool</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>search_scholarships</code></td>
<td>Filter the catalog</td>
</tr>
<tr>
<td><code>get_scholarship</code></td>
<td>Return one full record</td>
</tr>
<tr>
<td><code>match_scholarships</code></td>
<td>Score awards against a student profile</td>
</tr>
<tr>
<td><code>save_scholarship</code></td>
<td>Bookmark an award</td>
</tr>
<tr>
<td><code>list_saved_scholarships</code></td>
<td>Show the shortlist</td>
</tr>
<tr>
<td><code>add_research_note</code></td>
<td>Attach a note</td>
</tr>
<tr>
<td><code>list_research_notes</code></td>
<td>Read notes back</td>
</tr>
<tr>
<td><code>get_upcoming_deadlines</code></td>
<td>Deadline window</td>
</tr>
<tr>
<td><code>compare_scholarships</code></td>
<td>Side-by-side of two or three ids</td>
</tr>
</tbody></table>
<p>That's enough for a research loop: find, inspect, match, save, annotate, and compare.</p>
<p>Resist the urge to add a <code>delete_everything</code> tool. Destructive tools need extra confirmation and aren't part of this workflow.</p>
<h2 id="heading-how-to-expose-resources-and-prompts">How to Expose Resources and Prompts</h2>
<p>Tools aren't the only way a host gets context.</p>
<h3 id="heading-resources">Resources</h3>
<p>A static resource is a fixed URI. The catalog fits that:</p>
<pre><code class="language-javascript">server.registerResource(
  "scholarship-catalog",
  "scholarship://catalog",
  {
    title: "Scholarship catalog",
    description: "Open scholarships currently stored in MongoDB",
    mimeType: "application/json",
  },
  async (uri) =&gt; {
    const catalog = await listCatalog();
    return {
      contents: [
        {
          uri: uri.href,
          mimeType: "application/json",
          text: JSON.stringify(catalog, null, 2),
        },
      ],
    };
  },
);
</code></pre>
<p>A resource template covers a family of URIs. Use <code>scholarship://item/{id}</code> rather than <code>scholarship://{id}</code>. If the pattern is <code>scholarship://{id}</code>, the URI <code>scholarship://catalog</code> becomes ambiguous.</p>
<pre><code class="language-javascript">import { ResourceTemplate } from "@modelcontextprotocol/server";

server.registerResource(
  "scholarship-record",
  new ResourceTemplate("scholarship://item/{id}", {
    list: async () =&gt; {
      const catalog = await listCatalog(20);
      return {
        resources: catalog.map((scholarship) =&gt; ({
          uri: `scholarship://item/${scholarship._id}`,
          name: scholarship.title,
          mimeType: "text/plain",
        })),
      };
    },
  }),
  {
    title: "Scholarship record",
    description: "Full details for one scholarship",
    mimeType: "text/plain",
  },
  async (uri, { id }) =&gt; {
    const scholarship = await getScholarshipById(id);
    return {
      contents: [
        {
          uri: uri.href,
          mimeType: "text/plain",
          text: scholarship
            ? formatScholarship(scholarship)
            : `No scholarship found with id ${id}.`,
        },
      ],
    };
  },
);
</code></pre>
<p><code>list</code> is required on a template. Pass <code>undefined</code> if you can't enumerate instances. Here you can, so the host can show a picker.</p>
<h3 id="heading-prompts">Prompts</h3>
<p>Prompts are workflows you want a person to start. The research plan prompt doesn't query MongoDB itself. It tells the model to use the tools, then structure the answer.</p>
<pre><code class="language-javascript">server.registerPrompt(
  "research-plan",
  {
    title: "Scholarship research plan",
    description: "Build a week-by-week research and application plan from a student profile.",
    argsSchema: z.object({
      fieldOfStudy: z.string(),
      educationLevel: z.string(),
      citizenship: z.string(),
      gpa: z.string(),
      weeks: z.string().optional(),
    }),
  },
  ({ fieldOfStudy, educationLevel, citizenship, gpa, weeks }) =&gt; ({
    messages: [
      {
        role: "user",
        content: {
          type: "text",
          text: `Create a ${weeks || "6"}-week scholarship research plan for this student.

Field of study: ${fieldOfStudy}
Education level: ${educationLevel}
Citizenship: ${citizenship}
GPA: ${gpa}

Use the scholarship research tools to find real awards first. Then produce a shortlist, a week-by-week plan, and risks. Name actual scholarships and dates from the tool results.`,
        },
      },
    ],
  }),
);
</code></pre>
<p>Notice the instruction "use the scholarship research tools." A prompt isn't a substitute for tools. It's a script that makes tool use more likely and the output shape more consistent.</p>
<p>A second prompt, <code>application-checklist</code>, takes a scholarship id and asks for a document list and a backward calendar. That's the kind of repetitive work MCP prompts are good at.</p>
<p>Prompt arguments are strings in many hosts, even when the value is a number. Typing <code>gpa</code> as a string avoids a frustrating <code>expected number, received string</code> error from a slash-command form.</p>
<h2 id="heading-how-to-serve-mcp-over-express">How to Serve MCP Over Express</h2>
<p>This is the part that used to be a page of session-handling code. In SDK v2 it's a factory plus one route.</p>
<pre><code class="language-javascript">import "dotenv/config";
import { createMcpExpressApp } from "@modelcontextprotocol/express";
import { toNodeHandler } from "@modelcontextprotocol/node";
import { createMcpHandler } from "@modelcontextprotocol/server";
import { config } from "./config.js";
import { connectDatabase } from "./db.js";
import { createScholarshipServer } from "./mcp/server.js";

const mcpHandler = createMcpHandler(() =&gt; createScholarshipServer());
const nodeHandler = toNodeHandler(mcpHandler);

const app = createMcpExpressApp({
  host: config.host,
  allowedHosts: ["127.0.0.1", "localhost"],
});

app.get("/health", (_req, res) =&gt; {
  res.json({
    status: "ok",
    service: "scholarship-research-mcp",
    transport: "streamable-http",
  });
});

app.all("/mcp", (req, res) =&gt; {
  void nodeHandler(req, res, req.body);
});

async function start() {
  await connectDatabase();
  app.listen(config.port, config.host, () =&gt; {
    console.log(`Scholarship research MCP server listening on http://${config.host}:${config.port}/mcp`);
  });
}

start();
</code></pre>
<p>Walk through what each helper is doing.</p>
<p><code>createMcpHandler</code> takes a function that returns a fresh <code>McpServer</code>. It exposes a web-standard <code>fetch</code>. That's the same handler you would export from a Cloudflare Worker.</p>
<p><code>toNodeHandler</code> adapts that <code>fetch</code> to Express <code>(req, res)</code>. You pass <code>req.body</code> as the third argument because <code>createMcpExpressApp</code> already ran <code>express.json()</code>. If you omit the body, the adapter tries to read a stream Express already consumed.</p>
<p><code>createMcpExpressApp</code> is <code>express()</code> with two extras: JSON parsing, and Host/Origin checks. Those checks exist because of DNS rebinding. A malicious page can point its own domain at <code>127.0.0.1</code> and, without a Host check, your browser would treat the local MCP server as same-origin. The default bind is <code>127.0.0.1</code> for that reason. The <a href="https://ts.sdk.modelcontextprotocol.io/v2/serving/express.html">Express serving guide</a> covers this in more detail.</p>
<p><code>app.all("/mcp", ...)</code> is intentional. Streamable HTTP uses POST for JSON-RPC, and GET for SSE streams. Registering only POST will break some clients.</p>
<p>Shut the handler down on <code>SIGINT</code>:</p>
<pre><code class="language-javascript">process.on("SIGINT", async () =&gt; {
  await mcpHandler.close();
  process.exit(0);
});
</code></pre>
<p><code>close()</code> waits for in-flight requests. Then you can exit.</p>
<p>Add npm scripts:</p>
<pre><code class="language-json">{
  "scripts": {
    "start": "node src/index.js",
    "dev": "node --watch src/index.js",
    "seed": "node scripts/seed.js",
    "smoke": "node scripts/smoke-test.js"
  }
}
</code></pre>
<p><code>node --watch</code> is enough for local development. You don't need nodemon for this project.</p>
<h2 id="heading-how-to-seed-the-catalog">How to Seed the Catalog</h2>
<p>MCP tools against an empty database will work and return "no scholarships matched." That's correct, and also a bad first impression.</p>
<p>Put 20 to 30 realistic records in <code>data/scholarships.json</code>. Mix:</p>
<ul>
<li><p>Undergraduate and graduate awards</p>
</li>
<li><p>STEM and field-open awards</p>
</li>
<li><p>Country-specific programs and global ones</p>
</li>
<li><p>Rolling deadlines and hard dates</p>
</li>
<li><p>First-generation and women-only flags</p>
</li>
</ul>
<p>A seed script should replace the catalog, not append:</p>
<pre><code class="language-javascript">import "dotenv/config";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { connectDatabase } from "../src/db.js";
import { Scholarship } from "../src/models/index.js";

const __dirname = path.dirname(fileURLToPath(import.meta.url));

async function seed() {
  await connectDatabase();
  const raw = await readFile(path.join(__dirname, "..", "data", "scholarships.json"), "utf8");
  await Scholarship.deleteMany({});
  const inserted = await Scholarship.insertMany(JSON.parse(raw));
  console.log(`Seeded ${inserted.length} scholarships.`);
  process.exit(0);
}

seed();
</code></pre>
<p>Run it:</p>
<pre><code class="language-bash">npm run seed
</code></pre>
<p>Treat the JSON as sample data. Names of well-known programs help the tutorial feel real. They also create a duty to say, clearly, that students must verify every number and date on the official site. The <code>applicationUrl</code> field exists so that reminder has somewhere to point.</p>
<p>If you later replace the JSON with a live source, keep the same schema. The MCP tools shouldn't care where the documents came from.</p>
<h2 id="heading-how-to-test-the-server">How to Test the Server</h2>
<p>Start MongoDB, seed, then start the process:</p>
<pre><code class="language-bash">npm run seed
npm start
</code></pre>
<p>You should see:</p>
<pre><code class="language-text">Scholarship research MCP server listening on http://127.0.0.1:3000/mcp
</code></pre>
<h3 id="heading-health-check">Health Check</h3>
<pre><code class="language-bash">curl -s http://127.0.0.1:3000/health
</code></pre>
<p>A JSON <code>status: ok</code> means Express is up. It doesn't mean MCP is wired correctly. For that, send a JSON-RPC request.</p>
<h3 id="heading-list-tools">List Tools</h3>
<pre><code class="language-bash">curl -s -X POST http://127.0.0.1:3000/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
</code></pre>
<p>The response is an SSE event whose <code>data:</code> line contains the JSON-RPC result. You should see all nine tools, each with a JSON Schema derived from Zod.</p>
<p>The <code>Accept</code> header matters. MCP Streamable HTTP can return JSON or an event stream. Asking for both is the compatible choice.</p>
<h3 id="heading-call-a-tool">Call a Tool</h3>
<pre><code class="language-bash">curl -s -X POST http://127.0.0.1:3000/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{
    "jsonrpc":"2.0",
    "id":2,
    "method":"tools/call",
    "params": {
      "name": "search_scholarships",
      "arguments": {
        "fieldOfStudy": "computer science",
        "limit": 3
      }
    }
  }'
</code></pre>
<p>You should get a numbered list with ids, deadlines, and GPA minimums. Copy one id and pass it to <code>get_scholarship</code>.</p>
<p>A small Node smoke test is nicer than raw curl once you're calling several methods. Parse the <code>data:</code> line, then print <code>result.content[0].text</code>. The repo includes <code>scripts/smoke-test.js</code> for that.</p>
<h3 id="heading-inspector">Inspector</h3>
<p>The <a href="https://modelcontextprotocol.io/docs/tools/inspector">MCP Inspector</a> is the official GUI for servers. Point it at <code>http://127.0.0.1:3000/mcp</code> and you can list tools, fill in arguments, and read resources without a host app in the way.</p>
<p>Use Inspector when a host "can't see" your server. If Inspector works and the host doesn't, the bug is in the host config. If Inspector fails, the bug is in your process.</p>
<h2 id="heading-how-to-connect-cursor-and-claude-desktop">How to Connect Cursor and Claude Desktop</h2>
<p>Keep <code>npm start</code> running. MCP over HTTP is a live server, not a one-shot CLI.</p>
<h3 id="heading-cursor">Cursor</h3>
<p>Add a server entry in Cursor's MCP settings. A Streamable HTTP server looks like this:</p>
<pre><code class="language-json">{
  "mcpServers": {
    "scholarship-research": {
      "url": "http://127.0.0.1:3000/mcp"
    }
  }
}
</code></pre>
<p>Restart the MCP session if Cursor had a previous failed connection cached. Then ask:</p>
<blockquote>
<p>I am a first-generation undergraduate studying computer science in the United States, GPA 3.6. Use the scholarship research tools to build a shortlist and a six-week plan.</p>
</blockquote>
<p>You should see the host call <code>match_scholarships</code> or <code>search_scholarships</code>, then <code>get_scholarship</code> for the interesting rows, then maybe <code>save_scholarship</code>. If it never calls a tool, the server isn't actually connected. Check the MCP logs in Cursor before you change code.</p>
<p>You can also invoke the <code>research-plan</code> prompt from the host's prompt menu if it surfaces prompts.</p>
<h3 id="heading-claude-desktop">Claude Desktop</h3>
<p>Claude Desktop's config file lives at:</p>
<ul>
<li><p>macOS: <code>~/Library/Application Support/Claude/claude_desktop_config.json</code></p>
</li>
<li><p>Windows: <code>%APPDATA%\Claude\claude_desktop_config.json</code></p>
</li>
</ul>
<p>Claude Desktop prefers stdio. Bridge your HTTP server with <a href="https://www.npmjs.com/package/mcp-remote"><code>mcp-remote</code></a>:</p>
<pre><code class="language-json">{
  "mcpServers": {
    "scholarship-research": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "http://127.0.0.1:3000/mcp"]
    }
  }
}
</code></pre>
<p>Restart Claude Desktop after you save the file. The scholarship tools should appear in the tool list.</p>
<p>Don't put MongoDB credentials in the Claude config. The Node process already loaded <code>.env</code>. The host only needs the URL.</p>
<h2 id="heading-how-a-research-session-runs">How a Research Session Runs</h2>
<p>Here's a realistic session against the seed catalog. The student is a first-generation undergraduate in computer science, a US citizen, GPA 3.6.</p>
<p>The host calls <code>match_scholarships</code> with that profile. The server returns ranked rows. A women-in-technology award and a first-generation program both score well, for different reasons. A graduate-only award never appears.</p>
<p>The host then calls <code>get_scholarship</code> on the top two ids. Each result includes the official URL, the requirement list, and the deadline. That's the moment to tell the student to open the URL. The model shouldn't be the last word on eligibility.</p>
<p>If an award is worth pursuing, the host calls <code>save_scholarship</code> with a <code>researcherId</code> such as <code>ada</code> and status <code>saved</code>. Later it can set <code>applying</code>. <code>list_saved_scholarships</code> is how a new chat picks up the shortlist. Persistence is the whole point of MongoDB here. Without it, every conversation starts from zero.</p>
<p><code>add_research_note</code> is for the messy human details: "Ask Dr. Chen for a recommendation by October 1." <code>get_upcoming_deadlines</code> is the weekly sweep. <code>compare_scholarships</code> is for the moment the student has two finalists and needs amount, GPA, and requirements in one view.</p>
<p>The <code>research-plan</code> prompt packages that loop. It injects the profile into a user message that tells the model to call tools first and then produce a week-by-week plan. If you invoke the prompt without a connected server, you get a generic essay. If you invoke it with this server running, you get named awards and real dates.</p>
<p>That's the product: a catalog the model can query, a shortlist it can't forget, and prompts that make the workflow repeatable.</p>
<h2 id="heading-how-the-matching-logic-works">How the Matching Logic Works</h2>
<p>It's worth slowing down on matching, because this is the part people are tempted to hand to the model.</p>
<p>Suppose the student is:</p>
<ul>
<li><p>GPA 3.6</p>
</li>
<li><p>Computer science</p>
</li>
<li><p>Undergraduate</p>
</li>
<li><p>United States citizen</p>
</li>
<li><p>First-generation</p>
</li>
<li><p>A woman</p>
</li>
</ul>
<p>The matcher walks every open award.</p>
<p>A women-in-technology scholarship with a 3.3 GPA minimum, CS as a listed field, and US eligibility scores high: education, GPA, citizenship, women-only flag, and field all hit. A first-generation program that is field-open also scores high, because <code>any</code> counts as a field match. A graduate-only award is skipped, even if the title looks relevant. A 3.8 GPA cutoff is skipped, even if everything else fits.</p>
<p>The tool then returns ranked rows with reasons:</p>
<pre><code class="language-text">1. Palantir Women in Technology Scholarship — score 90
   Why: education level matches; GPA 3.6 meets the 3.3 minimum; citizenship is eligible; women-only award matches; field of study matches
</code></pre>
<p>The model can still write a warm paragraph around that. It shouldn't be the component that decided eligibility.</p>
<p>If you extend this later, keep the same split. New eligibility rules belong in the service. New prose belongs in the prompt.</p>
<h2 id="heading-what-you-can-build-next">What You Can Build Next</h2>
<p>The server you have is complete enough to use. It's also a base for a more serious research tool.</p>
<p>First, you could replace the seed file with a live source. Official feeds such as <a href="https://www.grants.gov/">Grants.gov</a> and college-maintained lists are safer than scraping commercial aggregators. Keep your schema. Write an importer that upserts by a stable external id.</p>
<p>You could also add authentication. <code>createMcpExpressApp</code> works with <code>requireBearerAuth</code>. Map <code>researcherId</code> from the verified token instead of a tool argument. The <a href="https://www.npmjs.com/package/@modelcontextprotocol/express">Express adapter</a> documents that middleware.</p>
<p>Try adding full-text search. The schema already has a text index. For a large catalog, Atlas Search or a dedicated search engine will beat a pile of regex filters.</p>
<p>You can track documents, not just notes. A <code>documents</code> collection for transcripts, recommendation status, and essay drafts turns the shortlist into an application tracker.</p>
<p>You could also write tests around the matcher. Eligibility code is where silent bugs hurt people. A table of profiles and expected include/exclude lists will pay for itself.</p>
<p>And you could deploy it. Bind to <code>127.0.0.1</code> on your laptop. If you put this on a network, set <code>allowedHosts</code>, terminate TLS, and require a bearer token. An open MCP server is an open database with a helpful English interface.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this guide, you built a scholarship research MCP server that's more than a toy tool list.</p>
<p>You stored awards, shortlists, and notes in MongoDB. You exposed search, matching, and research-tracking as MCP tools, with Zod schemas the host can advertise to a model. You added resources for the catalog and prompts for repeatable workflows. You served the whole thing over Streamable HTTP with Express, including the Host header checks the SDK enables for localhost.</p>
<p>The pattern transfers. Any research workflow with a catalog and a personal working set can use the same three layers: a service that owns the rules, an <code>McpServer</code> factory that registers primitives, and a small Express app that speaks the protocol.</p>
<p>If you take one idea from this tutorial, take this one: let the model write the plan, and let your server decide what's true.</p>
<p>The sample catalog is for learning. Confirm every scholarship on its official application page before you apply, and before you tell someone else to apply.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Why You Should Never Send Emails Inside Your API Requests ]]>
                </title>
                <description>
                    <![CDATA[ Your signup endpoint looks fine in development. A user registers, you save the row, you call your email provider, and you return 201 Created. Everyone gets a welcome email. You ship it. Then productio ]]>
                </description>
                <link>https://www.freecodecamp.org/news/why-you-should-never-send-emails-inside-your-api-requests/</link>
                <guid isPermaLink="false">6a97368f8a7f1f043a223d7c</guid>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                    <category>
                        <![CDATA[ backend ]]>
                    </category>
                
                    <category>
                        <![CDATA[ architecture ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Chinedu Otutu ]]>
                </dc:creator>
                <pubDate>Tue, 01 Sep 2026 20:33:19 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/8da5a9af-d7e5-4d0e-94cc-a11d24923023.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Your signup endpoint looks fine in development. A user registers, you save the row, you call your email provider, and you return <code>201 Created</code>. Everyone gets a welcome email. You ship it.</p>
<p>Then production traffic arrives.</p>
<p>The email API takes 2 seconds instead of 200 milliseconds. A few requests time out. During a provider outage, every signup starts returning <code>500</code>. Support asks why users can create accounts but never get the confirmation link.</p>
<p>The bug isn't your email template. The bug is where you decided to send the email.</p>
<p>In this article, you'll learn why sending email inside the HTTP request path is a production trap, what goes wrong as you scale, and how to move email (and other side effects) into a background job queue so your API stays fast and reliable.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You'll get more out of this article if you know:</p>
<ul>
<li><p>How REST APIs work (request, response, status codes)</p>
</li>
<li><p>Basic Node.js or any backend language that can call an email API</p>
</li>
<li><p>What a background job or queue is at a high level</p>
</li>
</ul>
<p>You don't need prior experience with BullMQ, Redis, or a specific email provider.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-1-what-inside-the-request-really-means">1. What "Inside the Request" Really Means</a></p>
</li>
<li><p><a href="#heading-2-the-tempting-code-that-causes-the-problem">2. The Tempting Code That Causes the Problem</a></p>
</li>
<li><p><a href="#heading-3-why-this-breaks-in-production">3. Why This Breaks in Production</a></p>
</li>
<li><p><a href="#heading-4-the-rule-your-api-should-enqueue-work-not-do-it">4. The Rule: Your API Should Enqueue Work, Not Do It</a></p>
</li>
<li><p><a href="#heading-5-how-to-move-email-to-a-background-queue">5. How to Move Email to a Background Queue</a></p>
</li>
<li><p><a href="#heading-6-how-to-handle-retries-failures-and-idempotency">6. How to Handle Retries, Failures, and Idempotency</a></p>
</li>
<li><p><a href="#heading-7-what-else-should-leave-the-request-path">7. What Else Should Leave the Request Path</a></p>
</li>
<li><p><a href="#heading-8-a-practical-checklist">8. A Practical Checklist</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-1-what-inside-the-request-really-means">1. What "Inside the Request" Really Means</h2>
<p>When a client hits your API, the request path is everything that must finish before you send the HTTP response.</p>
<p>That usually includes:</p>
<ul>
<li><p>validating input</p>
</li>
<li><p>checking auth</p>
</li>
<li><p>writing to your database</p>
</li>
<li><p>returning JSON</p>
</li>
</ul>
<p>It should almost never include:</p>
<ul>
<li><p>calling an SMTP server</p>
</li>
<li><p>waiting on Resend, SendGrid, SES, Mailgun, or Postmark</p>
</li>
<li><p>rendering a big HTML template and uploading attachments</p>
</li>
<li><p>retrying a flaky third-party network call</p>
</li>
</ul>
<p>Email providers are external systems. They have their own latency, rate limits, and outages. If your API waits on them, their problems become your users' problems.</p>
<pre><code class="language-text">Bad path (synchronous):

Client ──▶ API ──▶ Database ──▶ Email provider ──▶ Client
                         ▲
                         └── user waits for all of this
</code></pre>
<h2 id="heading-2-the-tempting-code-that-causes-the-problem">2. The Tempting Code That Causes the Problem</h2>
<p>Here's the pattern most teams start with. It's clear, short, and works on your laptop.</p>
<pre><code class="language-typescript">app.post('/signup', async (c) =&gt; {
  const body = await c.req.json();

  const user = await db.users.create({
    email: body.email,
    passwordHash: await hash(body.password),
  });

  // This is the trap.
  await emailClient.send({
    to: user.email,
    subject: 'Confirm your account',
    html: renderConfirmEmail(user),
  });

  return c.json({ id: user.id }, 201);
});
</code></pre>
<p>The request can't finish until the email provider answers. If that call is slow, your response is slow. If that call throws, your signup either fails or you catch the error and silently skip the email.</p>
<p>Neither option is good.</p>
<h2 id="heading-3-why-this-breaks-in-production">3. Why This Breaks in Production</h2>
<h3 id="heading-your-response-time-becomes-someone-elses-sla">Your Response Time Becomes Someone Else's SLA</h3>
<p>A healthy signup endpoint should often respond in tens or hundreds of milliseconds. Email APIs regularly take longer than that. Under load, they take much longer.</p>
<p>Your p95 latency is no longer owned by your code. It's owned by the slowest third-party call on the path.</p>
<h3 id="heading-failures-create-ugly-tradeoffs">Failures Create Ugly Tradeoffs</h3>
<p>If <code>send()</code> throws, you have two bad choices:</p>
<ol>
<li><p>Fail the whole request: the user sees an error even though the account may already exist</p>
</li>
<li><p>Swallow the error: the user is created, but never gets the email</p>
</li>
</ol>
<p>That second case is especially painful with confirmation or password-reset flows. The database says success but the inbox says nothing.</p>
<h3 id="heading-timeouts-cascade">Timeouts Cascade</h3>
<p>API gateways, load balancers, and browsers all have timeouts. A slow email call can turn a successful business action into a client-side failure. The user retries. Now you may create duplicate users or send duplicate emails.</p>
<h3 id="heading-rate-limits-punish-traffic-spikes">Rate Limits Punish Traffic Spikes</h3>
<p>Email providers rate-limit you. Product launches, digests, and password-reset spikes can all hit those limits. If sending happens inside the request, a rate limit error becomes a user-facing API error.</p>
<h3 id="heading-you-cant-retry-cleanly">You Can't Retry Cleanly</h3>
<p>Background jobs can retry with backoff. HTTP requests can't. Once you've returned a response, the request is over. If the email failed and you already told the client "success," you need another system to repair that. That system is a queue.</p>
<pre><code class="language-text">What the user experiences during a provider slowdown:

Signup click
   │
   ▼
API waits on email provider .............. 2s ... 5s ... timeout
   │
   ▼
"Something went wrong"
   │
   ▼
User clicks again → duplicate attempts, confused support tickets
</code></pre>
<h2 id="heading-4-the-rule-your-api-should-enqueue-work-not-do-it">4. The Rule: Your API Should Enqueue Work, Not Do It</h2>
<p>Treat email as asynchronous work that must happen <em>because of</em> the request, not <em>inside</em> the request.</p>
<p>The request path should:</p>
<ol>
<li><p>validate the input</p>
</li>
<li><p>commit the business change</p>
</li>
<li><p>enqueue a job that describes the email</p>
</li>
<li><p>return quickly</p>
</li>
</ol>
<p>A worker somewhere else should:</p>
<ol>
<li><p>pick up the job</p>
</li>
<li><p>render the template</p>
</li>
<li><p>call the email provider</p>
</li>
<li><p>retry on failure</p>
</li>
<li><p>record the result</p>
</li>
</ol>
<pre><code class="language-text">Good path (asynchronous):

Client ──▶ API ──▶ Database ──▶ Queue ──▶ Client (fast response)
                                   │
                                   ▼
                                Worker ──▶ Email provider
</code></pre>
<p>This is the same idea behind processing uploads, generating PDFs, calling AI APIs, or syncing data to a CRM. Email is just the most common example.</p>
<h2 id="heading-5-how-to-move-email-to-a-background-queue">5. How to Move Email to a Background Queue</h2>
<p>Here's a minimal Node.js shape using <a href="https://docs.bullmq.io/">BullMQ</a> and Redis. The exact library matters less than the boundary.</p>
<h3 id="heading-define-the-job">Define the Job</h3>
<pre><code class="language-typescript">// jobs/email.ts
export type SendEmailJob = {
  to: string;
  template: 'confirm-account' | 'password-reset' | 'welcome';
  variables: Record&lt;string, string&gt;;
  idempotencyKey: string;
};
</code></pre>
<h3 id="heading-enqueue-from-the-api">Enqueue from the API</h3>
<pre><code class="language-typescript">import { emailQueue } from '../queues/email-queue';

app.post('/signup', async (c) =&gt; {
  const body = await c.req.json();

  const user = await db.users.create({
    email: body.email,
    passwordHash: await hash(body.password),
  });

  await emailQueue.add(
    'send-email',
    {
      to: user.email,
      template: 'confirm-account',
      variables: {
        name: user.name,
        confirmUrl: `https://example.com/confirm/${user.confirmToken}`,
      },
      idempotencyKey: `confirm-account:${user.id}`,
    },
    {
      jobId: `confirm-account:${user.id}`,
      attempts: 5,
      backoff: { type: 'exponential', delay: 2000 },
      removeOnComplete: 1000,
      removeOnFail: 5000,
    },
  );

  return c.json({ id: user.id }, 201);
});
</code></pre>
<p>Notice what changed. The API still creates the user. It no longer waits for SMTP. It waits for Redis to accept a job, which is usually far cheaper and more predictable than waiting for an email provider.</p>
<h3 id="heading-process-the-job-in-a-worker">Process the Job in a Worker</h3>
<pre><code class="language-typescript">// worker/email-worker.ts
import { Worker } from 'bullmq';
import { emailClient } from '../lib/email-client';
import { renderTemplate } from '../lib/templates';

new Worker(
  'email',
  async (job) =&gt; {
    const { to, template, variables } = job.data;

    await emailClient.send({
      to,
      subject: subjectFor(template),
      html: renderTemplate(template, variables),
    });
  },
  {
    connection: redisConnection,
    concurrency: 5,
  },
);
</code></pre>
<p>Run the worker as a separate process from the API. That way an email outage slows the worker, not every HTTP response.</p>
<h3 id="heading-return-the-right-status">Return the Right Status</h3>
<p>For signup, <code>201 Created</code> is still correct if the account exists and the confirmation email is queued.</p>
<p>For endpoints that only trigger work, prefer <code>202 Accepted</code> and return a job or delivery id when the client needs to track progress.</p>
<h2 id="heading-6-how-to-handle-retries-failures-and-idempotency">6. How to Handle Retries, Failures, and Idempotency</h2>
<p>Moving email to a queue isn't enough. You also need failure rules.</p>
<h3 id="heading-retry-transient-errors">Retry Transient Errors</h3>
<p>Network blips, <code>429 Too Many Requests</code>, and <code>503 Service Unavailable</code> should retry with backoff. Permanent errors like an invalid recipient should fail fast and land in a dead-letter queue or failed-jobs table.</p>
<h3 id="heading-make-jobs-idempotent">Make Jobs Idempotent</h3>
<p>Workers can run more than once. Use a stable <code>jobId</code> or <code>idempotencyKey</code> so a retry doesn't send five welcome emails for one signup.</p>
<p>A practical approach:</p>
<ul>
<li><p>derive the key from the business event: <code>confirm-account:user_123</code></p>
</li>
<li><p>store provider message ids when a send succeeds</p>
</li>
<li><p>skip work if that key was already delivered</p>
</li>
</ul>
<h3 id="heading-dont-lose-the-business-event">Don't Lose the Business Event</h3>
<p>If the process crashes between <code>db.users.create()</code> and <code>emailQueue.add()</code>, you can lose the email.</p>
<p>There are two common ways to reduce that risk:</p>
<ol>
<li><p><strong>Outbox pattern</strong>: write the email intent into an <code>outbox</code> table in the same database transaction as the user row. A separate publisher reads the outbox and enqueues jobs.</p>
</li>
<li><p><strong>Transactional enqueue where supported</strong>: keep the write and the enqueue as close together as possible, and run reconciliation jobs for missed sends.</p>
</li>
</ol>
<p>For many apps, a simple queue plus monitoring is enough at first. Add an outbox when missed emails become a real reliability requirement.</p>
<h3 id="heading-separate-urgent-mail-from-bulk-mail">Separate Urgent Mail from Bulk Mail</h3>
<p>Password reset and OTP emails shouldn't sit behind a newsletter blast.</p>
<p>Use priorities or separate queues:</p>
<ul>
<li><p>high priority: OTP, password reset, confirmation</p>
</li>
<li><p>normal priority: receipts, onboarding</p>
</li>
<li><p>low priority: digests, marketing</p>
</li>
</ul>
<p>That keeps user-blocking email fast even when bulk traffic spikes.</p>
<h2 id="heading-7-what-else-should-leave-the-request-path">7. What Else Should Leave the Request Path</h2>
<p>If a task calls a third-party network API, can take more than a few hundred milliseconds, or it needs retries, keep it out of the request path. Same if it can fail without undoing the main business action or it does CPU-heavy work like image or PDF generation.</p>
<p>Here are some common examples of these types of tasks:</p>
<ul>
<li><p>emails and SMS</p>
</li>
<li><p>webhook fan-out</p>
</li>
<li><p>search indexing</p>
</li>
<li><p>thumbnail generation</p>
</li>
<li><p>invoice PDF creation</p>
</li>
<li><p>CRM sync</p>
</li>
<li><p>AI transcription or summarization</p>
</li>
<li><p>slow report generation</p>
</li>
</ul>
<p>Your API should confirm the intent and persist the state. Workers should do the slow parts.</p>
<h2 id="heading-8-a-practical-checklist">8. A Practical Checklist</h2>
<p>Before you ship a feature that sends email, ask:</p>
<ol>
<li><p>Does the user need the email to finish before seeing success?</p>
</li>
<li><p>Can the email provider being down break this endpoint?</p>
</li>
<li><p>If sending fails after the database write, how do we recover?</p>
</li>
<li><p>If the job runs twice, will the user get duplicate messages?</p>
</li>
<li><p>Do OTP and password-reset emails have a faster lane than bulk email?</p>
</li>
<li><p>Can we see failed jobs in logs, metrics, or a dashboard?</p>
</li>
<li><p>Do we alert when the queue depth or failure rate spikes?</p>
</li>
</ol>
<p>If you can't answer those questions, the design isn't ready for production.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Sending email inside your API request couples your response time and your error rate to a third-party system you don't control. It feels simple in a demo and becomes expensive in production.</p>
<p>The better default is simple:</p>
<ul>
<li><p>commit the business change</p>
</li>
<li><p>enqueue the email</p>
</li>
<li><p>return quickly</p>
</li>
<li><p>let a worker send, retry, and report failures</p>
</li>
</ul>
<p>That pattern makes your API faster, your email delivery more reliable, and your failure modes much easier to reason about.</p>
<p>Once you make that split for email, you'll start seeing the same boundary everywhere: if the user shouldn't wait for it, it doesn't belong in the request.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Get Reliable Structured Data Out of an LLM ]]>
                </title>
                <description>
                    <![CDATA[ Most tutorials about calling a language model end at JSON.parse(response.content). That line works on your first ten test cases. Then you ship, and somewhere around request four hundred the model retu ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-get-reliable-structured-data-out-of-an-llm/</link>
                <guid isPermaLink="false">6a908843ac9ce68300a83a4c</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Vineeth Pawar ]]>
                </dc:creator>
                <pubDate>Thu, 27 Aug 2026 18:56:03 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/be161971-7a90-496c-899a-526492046265.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most tutorials about calling a language model end at <code>JSON.parse(response.content)</code>. That line works on your first ten test cases. Then you ship, and somewhere around request four hundred the model returns a date it invented, or eight array items when your schema allows five, or a perfectly valid JSON object with one field quietly missing.</p>
<p>I ran into this while building Temploracraft, a résumé tool that takes an uploaded document and turns it into structured data the application can edit.</p>
<p>The input is genuinely unpredictable: two-column PDFs, tables that aren't really tables, and dates written in about fourteen different formats. The output has to be strict, because every extracted field lands in a form that a person is going to look at. When the model gets a date wrong, the user notices in about two seconds.</p>
<p>This article is about the layer that sits between "the model returned some text" and "my application has data it can trust." It covers the three mechanisms for constraining output, why designing the schema first beats writing a longer prompt, how to build a retry loop that doesn't set money on fire, and what to do about the failures that no retry will ever fix.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-why-prompting-for-json-isnt-enough">Why Prompting for JSON Isn't Enough</a></p>
</li>
<li><p><a href="#heading-the-three-ways-to-constrain-output">The Three Ways to Constrain Output</a></p>
</li>
<li><p><a href="#heading-start-with-the-schema-not-the-prompt">Start with the Schema, Not the Prompt</a></p>
</li>
<li><p><a href="#heading-validation-is-two-jobs-not-one">Validation Is Two Jobs, Not One</a></p>
</li>
<li><p><a href="#heading-building-a-retry-loop-that-doesnt-burn-tokens">Building a Retry Loop That Doesn't Burn Tokens</a></p>
</li>
<li><p><a href="#heading-streaming-structured-output">Streaming Structured Output</a></p>
</li>
<li><p><a href="#heading-the-failures-you-cant-retry-away">The Failures You Can't Retry Away</a></p>
</li>
<li><p><a href="#heading-what-this-actually-costs">What This Actually Costs</a></p>
</li>
<li><p><a href="#heading-when-you-dont-need-any-of-this">When You Don't Need Any of This</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
<li><p><a href="#heading-references">References</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along comfortably, you'll want a few things in place:</p>
<ul>
<li><p><strong>Working knowledge of TypeScript:</strong> The examples use type inference and generics lightly, and you should be able to read a type annotation without pausing.</p>
</li>
<li><p><strong>You have called a language model API at least once:</strong> You don't need to be an expert, but you should know what a system prompt is and roughly what a token is.</p>
</li>
<li><p><strong>Familiarity with JSON Schema is helpful but not required:</strong> I explain the parts that matter as they come up.</p>
</li>
<li><p><strong>Node.js 18 or later</strong> if you want to run the examples, since they use the native <code>fetch</code> and async iterators.</p>
</li>
</ul>
<p>The code samples use <a href="https://zod.dev">Zod</a> for schema definition and the Anthropic SDK for model calls, but every technique here translates directly to other validation libraries and other providers. The ideas matter more than the specific packages.</p>
<h2 id="heading-why-prompting-for-json-isnt-enough">Why Prompting for JSON Isn't Enough</h2>
<p>The first instinct when you need structured data is to ask for it politely. You write something like "Respond with valid JSON matching this shape, and do not include any other text," you paste an example, and it works. It keeps working through development. It works in your demo.</p>
<p>The problem is that a language model generates one token at a time based on probability, and your instruction is only one influence among many. It competes with the model's training, the shape of the input document, and whatever the model produced in the preceding few hundred tokens. Most of the time your instruction wins. Occasionally it does not.</p>
<p>Here are failures I've actually collected from a production extraction pipeline, all from a model that was explicitly told to return strict JSON:</p>
<ul>
<li><p>The model wrapped the response in a Markdown code fence, despite being told twice not to.</p>
</li>
<li><p>It returned <code>"2019 - Present"</code> as a single string where the schema defined separate <code>startDate</code> and <code>endDate</code> fields.</p>
</li>
<li><p>It invented an <code>endDate</code> of <code>"2024-12-31"</code> for a role the document clearly marked as current.</p>
</li>
<li><p>It returned the string <code>"null"</code> instead of an actual <code>null</code>.</p>
</li>
<li><p>It emitted seven bullet points for a role where the schema set a maximum of five.</p>
</li>
<li><p>It truncated mid-object because the response hit the output token limit.</p>
</li>
</ul>
<p>Notice that these aren't all the same kind of failure. The code fence and the truncation are syntax problems, and you can often fix them locally without calling the model again. The merged date string and the extra bullets are schema problems, where the JSON parses fine but doesn't match the shape you need. The invented end date is a semantic problem, where the output is both valid JSON and schema-conformant but factually wrong about the source document.</p>
<p>Those three categories need three different responses, and conflating them is the most common architectural mistake I see in extraction code. The rest of this article is largely about separating them.</p>
<h2 id="heading-the-three-ways-to-constrain-output">The Three Ways to Constrain Output</h2>
<p>Before writing any validation code, it's worth understanding what the API itself can enforce for you. There are three mechanisms, and they give you meaningfully different guarantees.</p>
<p><strong>JSON mode</strong> is the weakest of the three. You set a flag such as <code>response_format: { type: "json_object" }</code>, and the provider guarantees the response will be syntactically valid JSON. That's genuinely useful, because it eliminates the code fence and truncation problems in one move. What it doesn't do is guarantee anything about the shape. You can get valid JSON with the wrong keys, the wrong types, or a completely different structure than you asked for.</p>
<p><strong>Tool calling</strong> is the mechanism I reach for most often. You describe a function with a JSON Schema for its parameters, then force the model to call it. The model returns arguments matching that schema rather than free text. Support is broad, the schema travels with the request so you're not duplicating it in the prompt, and providers tend to constrain the output more aggressively than they do in plain JSON mode.</p>
<p>Here's what that looks like with the Anthropic SDK:</p>
<pre><code class="language-ts">const response = await client.messages.create({
  model: "claude-sonnet-5",
  max_tokens: 4096,
  tools: [
    {
      name: "emit_resume",
      description: "Return the parsed resume as structured data.",
      input_schema: jsonSchema,
    },
  ],
  tool_choice: { type: "tool", name: "emit_resume" },
  messages: [{ role: "user", content: resumeText }],
});

const block = response.content.find((b) =&gt; b.type === "tool_use");
const candidate = block?.input;
</code></pre>
<p>The <code>tool_choice</code> field is the important part. Without it the model decides whether to call the tool, and sometimes it will just answer in prose instead. Forcing the specific tool removes that branch entirely.</p>
<p><strong>Constrained decoding</strong> is the strongest option and the least widely available. Instead of asking the model to follow a schema, the runtime masks the token sampler at every step so that tokens which would produce invalid output can't be selected at all. Invalid output becomes impossible rather than unlikely.</p>
<p>OpenAI exposes a version of this through strict structured outputs, and if you run models locally you can use GBNF grammars in llama.cpp or a library such as Outlines.</p>
<p>The tradeoff is that constrained decoding can push the model into awkward corners. If the schema demands a field the document genuinely doesn't contain, the model can't decline, so it fills the slot with something. You've traded a parsing failure for a hallucination, which is harder to detect. I make required fields nullable for exactly this reason, which I'll come back to shortly.</p>
<p>My default is tool calling with a nullable-heavy schema, plus validation on top. That combination gives most of the benefit without the issues.</p>
<h2 id="heading-start-with-the-schema-not-the-prompt">Start with the Schema, Not the Prompt</h2>
<p>The instinct when output quality is poor is to write a longer prompt. More examples, more emphasis, and more capital letters. This helps a little and scales badly, because the prompt is prose and prose isn't enforceable.</p>
<p>A better approach is to treat the schema as the primary artifact and let everything else derive from it. Define it once, and use that single definition to generate the TypeScript type, the JSON Schema sent to the API, and the runtime validator. When the shape changes, all three change together, and there's no way for them to drift apart.</p>
<p>With Zod that looks like this:</p>
<pre><code class="language-ts">import { z } from "zod";

const YearMonth = z
  .string()
  .regex(/^\d{4}-\d{2}$/, "Expected a YYYY-MM date");

const Role = z.object({
  company: z.string().min(1),
  title: z.string().min(1),
  startDate: YearMonth,
  endDate: YearMonth.nullable(),
  bullets: z.array(z.string().min(1)).min(1).max(5),
});

const Resume = z.object({
  name: z.string().min(1),
  email: z.string().email().nullable(),
  roles: z.array(Role),
});

export type Resume = z.infer&lt;typeof Resume&gt;;
</code></pre>
<p>Three details in there are doing real work.</p>
<p>The <code>YearMonth</code> regex is narrower than <code>z.string()</code>, and that narrowness is the point. A bare string field invites the model to return <code>"January 2019"</code> or <code>"2019 - Present"</code> or <code>"01/2019"</code>, and all of those pass validation. Constraining the format at the schema level means the mismatch surfaces immediately instead of three layers deeper in your date-handling code.</p>
<p>The <code>endDate</code> field is nullable rather than optional. This is the single change that most improved extraction quality for me. An optional field lets the model quietly omit it, and you can't distinguish "the document did not say" from "the model forgot." A nullable field forces an explicit decision, and <code>null</code> is a meaningful answer that means the role is current.</p>
<p>The <code>max(5)</code> on bullets encodes a product constraint directly into the contract rather than trimming the array afterward. If the model exceeds it, you want to know, because it usually means the model is padding rather than extracting.</p>
<p>To send the schema to the API, derive the JSON Schema from the same definition:</p>
<pre><code class="language-ts">import { zodToJsonSchema } from "zod-to-json-schema";

const jsonSchema = zodToJsonSchema(Resume, { target: "openApi3" });
</code></pre>
<p>One habit worth adopting: write a <code>.describe()</code> on any field where the name alone is ambiguous. Those descriptions end up in the JSON Schema, which means they reach the model as part of the tool definition, which means they function as targeted, structured prompt instructions attached to exactly the field they concern.</p>
<pre><code class="language-ts">const Role = z.object({
  company: z.string().min(1),
  title: z.string().min(1).describe("The person's job title, not the team name"),
  startDate: YearMonth,
  endDate: YearMonth.nullable().describe("null if this role is current"),
  bullets: z
    .array(z.string().min(1))
    .min(1)
    .max(5)
    .describe("Verbatim from the document. Do not rewrite or summarise."),
});
</code></pre>
<p>That last description eliminated an entire class of failure for me, where the model would helpfully improve the wording of a person's own bullet points.</p>
<h2 id="heading-validation-is-two-jobs-not-one">Validation Is Two Jobs, Not One</h2>
<p>Once the response comes back, it's tempting to run <code>schema.parse()</code> and consider the job done. Zod will tell you whether the shape is right, and if it is, you move on.</p>
<p>But shape validation and semantic validation are different jobs, and only the first one is free. Zod can tell you that <code>endDate</code> is a string matching <code>YYYY-MM</code>. It can't tell you that the end date falls before the start date, or that the date is in the future, or that a role listed as current also has an end date. Those are all schema-valid and all wrong.</p>
<p>So run two passes. The first is structural and comes from the schema. The second is a plain function that encodes what you know about the domain:</p>
<pre><code class="language-ts">function findSemanticProblems(resume: Resume): string[] {
  const problems: string[] = [];
  const currentMonth = new Date().toISOString().slice(0, 7);

  for (const role of resume.roles) {
    if (role.startDate &gt; currentMonth) {
      problems.push(`${role.company}: start date is in the future`);
    }
    if (role.endDate &amp;&amp; role.endDate &lt; role.startDate) {
      problems.push(`${role.company}: end date precedes start date`);
    }
  }

  const currentRoles = resume.roles.filter((r) =&gt; r.endDate === null);
  if (currentRoles.length &gt; 1) {
    problems.push("More than one role is marked as current");
  }

  return problems;
}
</code></pre>
<p>None of this is clever code, and that's rather the point. It's ordinary business logic that happens to be checking a model's work rather than a user's. Write it as you discover failures, and treat each new check as a permanent regression test for a mistake the model made once.</p>
<p>There's a useful asymmetry here. Semantic problems are often better surfaced to the user than retried, because the model frequently can't do better with the information available. If a document genuinely lists two current roles, that's what the document says, and asking the model again won't change it.</p>
<h2 id="heading-building-a-retry-loop-that-doesnt-burn-tokens">Building a Retry Loop That Doesn't Burn Tokens</h2>
<p>When validation fails, the naïve fix is to call the model again with the same prompt and hope for a different sample. That works often enough to feel reasonable and is wasteful enough to notice on the bill, because you're paying full input cost for a request that has already mostly succeeded.</p>
<p>Two changes make a large difference.</p>
<p>The first is to attempt a local repair before spending anything. A meaningful share of failures are cosmetic, and you can fix them with string handling:</p>
<pre><code class="language-ts">function salvage(raw: string): string {
  let text = raw.trim();

  // Strip a Markdown fence the model added despite instructions.
  const fenced = text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/);
  if (fenced) text = fenced[1];

  // Drop any prose before the first brace or after the last one.
  const start = text.indexOf("{");
  const end = text.lastIndexOf("}");
  if (start !== -1 &amp;&amp; end &gt; start) text = text.slice(start, end + 1);

  return text;
}
</code></pre>
<p>The second is that when you do call the model again, you should send a repair request rather than a fresh attempt. Include the original prompt, the output that failed, and the specific validation errors. The model has already done the hard extraction work, and you're asking it to fix a small number of named problems rather than start over. Repairs converge faster and produce shorter outputs, which means they cost less.</p>
<pre><code class="language-ts">async function requestRepair(
  originalPrompt: string,
  badOutput: string,
  error: z.ZodError,
): Promise&lt;string&gt; {
  const issues = error.issues
    .map((issue) =&gt; `${issue.path.join(".") || "root"}: ${issue.message}`)
    .join("\n");

  const response = await client.messages.create({
    model: "claude-sonnet-5",
    max_tokens: 4096,
    messages: [
      { role: "user", content: originalPrompt },
      { role: "assistant", content: badOutput },
      {
        role: "user",
        content:
          `That output failed validation with these problems:\n${issues}\n\n` +
          `Return the corrected JSON only. Keep everything that was already correct.`,
      },
    ],
  });

  return response.content[0].type === "text" ? response.content[0].text : "";
}
</code></pre>
<p>Putting it together, the full loop separates the failure classes and caps the spend:</p>
<pre><code class="language-ts">type Outcome&lt;T&gt; =
  | { ok: true; value: T; attempts: number }
  | { ok: false; error: string; attempts: number };

async function extract&lt;T&gt;(
  schema: z.ZodType&lt;T&gt;,
  prompt: string,
  maxAttempts = 3,
): Promise&lt;Outcome&lt;T&gt;&gt; {
  let lastRaw = "";
  let lastError: z.ZodError | null = null;

  for (let attempt = 1; attempt &lt;= maxAttempts; attempt++) {
    lastRaw =
      attempt === 1
        ? await callModel(prompt)
        : await requestRepair(prompt, lastRaw, lastError!);

    let candidate: unknown;
    try {
      candidate = JSON.parse(salvage(lastRaw));
    } catch {
      continue; // Syntax failure. Try again without a schema error to report.
    }

    const result = schema.safeParse(candidate);
    if (result.success) {
      return { ok: true, value: result.data, attempts: attempt };
    }
    lastError = result.error;
  }

  return {
    ok: false,
    error: lastError?.message ?? "Output was never parseable",
    attempts: maxAttempts,
  };
}
</code></pre>
<p>Two things about this loop are deliberate. It returns the attempt count, which you should log, because attempts per success is the single most useful health metric for an extraction pipeline. And it caps attempts at three. If three tries haven't produced valid output, a fourth rarely helps, and by then you're better off degrading gracefully than continuing to pay.</p>
<p>One more distinction worth respecting: a rate limit error and a schema validation error aren't the same failure and shouldn't share a retry policy. Rate limits want exponential backoff, because the problem is timing. Schema failures want an immediate repair request, because the problem is content, and waiting changes nothing.</p>
<h2 id="heading-streaming-structured-output">Streaming Structured Output</h2>
<p>Streaming and structured output pull against each other. Streaming exists so the user sees progress before the response finishes, but you can't parse a JSON object until the closing brace arrives. If your extraction takes twelve seconds, the best options are to show a spinner for twelve seconds or find a way to stream something meaningful.</p>
<p>There are two reasonable approaches.</p>
<p>The first is a partial JSON parser, which takes an incomplete string and returns the largest valid structure it can infer, closing open braces and dropping the trailing incomplete value. Libraries such as <code>best-effort-json-parser</code> do this. It works, and it's the right choice when the output is genuinely one large object. The cost is that intermediate states can be misleading, because a field can appear with a truncated value that looks complete.</p>
<p>The second approach, which I prefer when the output is a list, is to change the output format so that streaming is natural. Instead of asking for one array of objects, ask for one object per line. Each line is independently parseable, so you can validate and emit items the moment they complete:</p>
<pre><code class="language-ts">const stream = client.messages.stream({
  model: "claude-sonnet-5",
  max_tokens: 4096,
  messages: [{ role: "user", content: prompt }],
});

let buffer = "";

for await (const event of stream) {
  if (event.type !== "content_block_delta") continue;
  buffer += event.delta.text ?? "";

  const lines = buffer.split("\n");
  buffer = lines.pop() ?? ""; // Keep the incomplete tail for the next chunk.

  for (const line of lines) {
    if (!line.trim()) continue;

    try {
      const parsed = Role.safeParse(JSON.parse(line));
      if (parsed.success) onRole(parsed.data);
    } catch {
      // A malformed line is dropped rather than failing the whole stream.
    }
  }
}
</code></pre>
<p>The buffer handling is the part that people get wrong. Network chunks don't align with line boundaries, so a chunk will frequently end mid-object. Popping the final element back into the buffer and carrying it forward is what makes the loop correct.</p>
<p>This pattern gives you per-item validation for free, which is a real advantage over parsing one large object. One bad item doesn't invalidate the rest of the extraction.</p>
<h2 id="heading-the-failures-you-cant-retry-away">The Failures You Can't Retry Away</h2>
<p>Everything so far assumes the model can produce the right answer if you ask correctly. Some failures don't work that way, and treating them as retry candidates wastes money while producing confidently wrong data.</p>
<p>The most important of these is extraction from something the source doesn't contain. If a résumé genuinely has no email address and your schema requires one, the model will produce something plausible.</p>
<p>Retrying produces a different plausible thing. This is the failure mode that constrained decoding makes worse rather than better, because the grammar guarantees a well-formed value in a slot that should've been empty. The fix is at the schema level, which is why nearly every field in my extraction schemas is nullable.</p>
<p>Ambiguity in the source is a similar case. When a document lists a date range next to two different job titles, there's no correct extraction, only a guess. Retrying gives you a different guess with the same confidence. These situations want a confidence signal in the schema and a review step in the interface, not another API call.</p>
<p>Then there's silent truncation. If the response hits the output token limit mid-object, you get syntactically broken JSON, and a naïve retry loop treats it as a transient parse failure and tries again with the same limit, failing identically each time. Check the stop reason on the response. If the model stopped because it ran out of tokens, retrying without raising the limit or splitting the input is guaranteed to fail again.</p>
<p>The general principle is that your retry policy should ask what kind of failure this is before deciding whether repeating the call could possibly help.</p>
<h2 id="heading-what-this-actually-costs">What This Actually Costs</h2>
<p>Every layer above has a price, and it's worth measuring rather than assuming.</p>
<p>The main figure to track is <strong>attempts per successful extraction</strong>. Log it on every request and watch the p95 rather than the mean, because the mean hides the tail where the money goes. When a schema change causes a quality regression, this number moves before anything else does.</p>
<p>Prompt caching matters more here than in most workloads. Extraction prompts are unusually cache-friendly because the system prompt, the schema, and any few-shot examples are byte-identical on every request, and only the document changes.</p>
<p>On a pipeline where the schema and instructions ran to a couple of thousand tokens, moving that block into a cached prefix cut input cost substantially, and the saving compounds with every retry because repairs re-send the same prefix.</p>
<p>Repair requests are cheaper than fresh attempts for a reason worth understanding. The input grows, since you're now sending the original prompt plus the failed output plus the error list. But the output shrinks considerably, because the model is correcting a handful of fields rather than generating the full document again. Output tokens are the expensive direction on most providers, so the trade is usually favourable.</p>
<p>Two other things are worth instrumenting from the start: the distribution of validation error paths, which tells you exactly which schema fields are causing trouble and is far more actionable than an overall failure rate, and your local-repair hit rate, since if <code>salvage()</code> is fixing a large fraction of responses you have a prompt problem you can solve once rather than paying for repeatedly.</p>
<h2 id="heading-when-you-dont-need-any-of-this">When You Don't Need Any of This</h2>
<p>This machinery earns its place in a specific set of circumstances, and it's genuinely overkill outside them.</p>
<p>If the model's output goes straight to a human who will read it as prose, you don't need structured output at all. A chat interface, a summary, or a draft email all have a person as the validator, and adding a schema in front of that just constrains the model for no benefit.</p>
<p>If you're extracting one or two fields rather than a document's worth, a well-targeted prompt with a light <code>safeParse</code> and a single retry will serve you fine. The retry loop, semantic validation layer, and streaming parser are answers to problems that appear at scale and with schema complexity.</p>
<p>If your volume is low and a human reviews every result anyway, the validation layer is duplicating work someone is already doing. Surface the raw output, let the reviewer correct it, and spend the engineering time elsewhere.</p>
<p>And if you're still exploring what the feature should be, resist building this early. The schema is the most expensive thing to change once extraction code, validation rules, and stored data all depend on it. Prototype loosely, learn what fields you actually need, and tighten afterwards.</p>
<p>The safest rule is to start with a schema and <code>safeParse</code>, and add each subsequent layer only when a real failure justifies it. Every technique in this article came from a specific production incident rather than from a design document.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>The gap between a working demo and a reliable feature is almost entirely in this layer. The model isn't the hard part any more, and the prompt isn't usually the hard part either. The hard part is deciding what you'll accept, detecting when you didn't get it, and responding sensibly when that happens.</p>
<p>Three ideas carry most of the weight:</p>
<ol>
<li><p><strong>The schema is the contract, and everything derives from it.</strong> One definition producing your type, your API schema, and your validator means those three can never drift apart.</p>
</li>
<li><p><strong>Separate syntax failures, schema failures, and semantic failures.</strong> They have different causes and different fixes, and a retry loop that treats them identically will waste money on problems that repeating the call can't solve.</p>
</li>
<li><p><strong>Make fields nullable rather than optional.</strong> Forcing the model to say "this was not present" rather than allowing it to quietly omit the field converts a whole class of silent hallucination into an explicit, checkable value.</p>
</li>
</ol>
<p>Get those right and the rest is ordinary engineering. You're writing validation code and retry logic, which developers have been doing against unreliable inputs for decades. A language model is just a new kind of unreliable input, and it responds well to the same discipline.</p>
<h2 id="heading-references">References</h2>
<ul>
<li><p><a href="https://zod.dev">Zod</a>: the schema library used throughout these examples.</p>
</li>
<li><p><a href="https://github.com/StefanTerdell/zod-to-json-schema">zod-to-json-schema</a>: converts a Zod schema into the JSON Schema an API expects.</p>
</li>
<li><p><a href="https://docs.claude.com/en/docs/agents-and-tools/tool-use/overview">Anthropic tool use documentation</a>: forcing structured arguments through a tool definition.</p>
</li>
<li><p><a href="https://platform.openai.com/docs/guides/structured-outputs">OpenAI structured outputs</a>: strict schema conformance through constrained decoding.</p>
</li>
<li><p><a href="https://github.com/dottxt-ai/outlines">Outlines</a>: grammar-constrained generation for locally hosted models.</p>
</li>
<li><p><a href="https://github.com/beenotung/best-effort-json-parser">best-effort-json-parser</a>: parsing incomplete JSON while a response is still streaming.</p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an AI Agent with Per-User OAuth Access [Full Handbook] ]]>
                </title>
                <description>
                    <![CDATA[ When your AI agent serves more than one person, every tool call must answer: who's the agent acting for? Let's learn how to solve this by building an AI agent that connects with Slack and GitHub. A Sl ]]>
                </description>
                <link>https://www.freecodecamp.org/news/ai-agent-per-user-oauth-slack-github/</link>
                <guid isPermaLink="false">6a7c95758a35a7792fd567c3</guid>
                
                    <category>
                        <![CDATA[ ai agents ]]>
                    </category>
                
                    <category>
                        <![CDATA[ authentication ]]>
                    </category>
                
                    <category>
                        <![CDATA[ tool calling ]]>
                    </category>
                
                    <category>
                        <![CDATA[ oauth ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Saif Ali Shaik ]]>
                </dc:creator>
                <pubDate>Wed, 12 Aug 2026 15:47:01 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/889cf8c8-41f9-4dec-aa5f-128cb24f0082.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>When your AI agent serves more than one person, every tool call must answer: who's the agent acting for? Let's learn how to solve this by building an AI agent that connects with Slack and GitHub.</p>
<p>A Slack read uses that user's workspace. A GitHub issue is created as that user, in a repository they can access. An agent can make the wrong call, but it must never act with the wrong user's access.</p>
<p>The fix has two parts, and both appear in the first half of this tutorial:</p>
<ol>
<li><p><strong>Each user grants access separately.</strong> Alice authorizes Slack for herself. Bob authorizes it for himself.</p>
</li>
<li><p><strong>Your agent passes an identifier, not a token.</strong> A string like <code>alice@example.com</code> selects whose grant to use. One function turns it into a token at the moment of the call, and that token never reaches your model inputs, your tool schemas, or your logs.</p>
</li>
</ol>
<p>Most agent tutorials stop before either point. They hand you an API key, wire up one function, and the model calls it. The design works until a second person shows up.</p>
<p>To make the pattern concrete, you'll build a command-line agent that watches a Slack channel, decides on its own which messages describe real work, files a GitHub issue for those, and replies in the Slack thread with the issue link. Every call runs as one user's own OAuth grant.</p>
<p>You'll write the OAuth flow yourself: the consent redirect, the <code>state</code> check, the token exchange, an encrypted store, and the refresh path. None of it is long, and seeing it whole is what makes the identity argument checkable instead of a claim you take on faith.</p>
<p>Two topics stay out of scope here: we won't cover Model Context Protocol servers or voice or realtime hosts. The identity pattern holds in both settings, but the surrounding plumbing deserves its own article.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-youll-build">What You'll Build</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-are-ai-agent-tools">What Are AI Agent Tools?</a></p>
</li>
<li><p><a href="#heading-why-a-shared-token-breaks">Why a Shared Token Breaks</a></p>
</li>
<li><p><a href="#heading-architecture-overview">Architecture Overview</a></p>
</li>
<li><p><a href="#heading-how-to-register-the-slack-and-github-oauth-apps">How to Register the Slack and GitHub OAuth Apps</a></p>
</li>
<li><p><a href="#heading-how-to-run-the-consent-flow">How to Run the Consent Flow</a></p>
</li>
<li><p><a href="#heading-how-to-store-tokens-encrypted-keyed-by-user">How to Store Tokens Encrypted, Keyed by User</a></p>
</li>
<li><p><a href="#heading-how-to-run-tool-calls-as-the-current-user">How to Run Tool Calls as the Current User</a></p>
</li>
<li><p><a href="#heading-how-to-handle-refresh-and-revocation">How to Handle Refresh and Revocation</a></p>
</li>
<li><p><a href="#heading-how-to-add-a-second-provider">How to Add a Second Provider</a></p>
</li>
<li><p><a href="#heading-full-walkthrough">Full Walkthrough</a></p>
</li>
<li><p><a href="#heading-how-to-apply-the-pattern-to-other-use-cases">How to Apply the Pattern to Other Use Cases</a></p>
</li>
<li><p><a href="#heading-what-went-wrong-when-i-built-this">What Went Wrong When I Built This</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-youll-build">What You'll Build</h2>
<p>The agent is called <code>channel-watcher-agent</code>. Each run does four things:</p>
<ol>
<li><p>Reads recent messages from a Slack channel.</p>
</li>
<li><p>Asks a model, message by message, whether the text describes a bug or a concrete action item.</p>
</li>
<li><p>Files a GitHub issue for the messages that qualify.</p>
</li>
<li><p>Replies in the original Slack thread with a link to the new issue.</p>
</li>
</ol>
<p><strong>Nobody clicks a button to start any of it.</strong> Slack already ships a "create an issue from this message" action, which is a different product. Here the agent reads the channel, forms its own judgment, and acts only on what it judges worth acting on.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5d426742d3ccd88c5676d4a2/cb98e59f-ac1a-48c7-9ab3-46a49f78af58.png" alt="Example of the tool in action" style="display: block;" width="1014" height="1028" loading="lazy">

<p>The stack stays small on purpose:</p>
<table>
<thead>
<tr>
<th>Piece</th>
<th>Role</th>
</tr>
</thead>
<tbody><tr>
<td>Node.js, plain ES modules</td>
<td>No web framework, no queue</td>
</tr>
<tr>
<td><code>node:http</code></td>
<td>The OAuth callback server</td>
</tr>
<tr>
<td><code>node:crypto</code></td>
<td>Token encryption</td>
</tr>
<tr>
<td><code>node:sqlite</code></td>
<td>The token store, with no dependency to install</td>
</tr>
<tr>
<td><a href="https://ai-sdk.dev/">Vercel AI SDK</a></td>
<td>The model call and the tool loop</td>
</tr>
</tbody></table>
<p>Three of those five ship with Node. The only packages you install are the AI SDK and its friends.</p>
<p>By the end you'll have:</p>
<ul>
<li><p>Two OAuth apps, Slack and GitHub, that a user consents to once.</p>
</li>
<li><p>An encrypted token store keyed by user and provider.</p>
</li>
<li><p>An agent that resolves the current user to an identifier and never lets a token reach the model.</p>
</li>
<li><p>A tool loop where the model decides whether to file an issue at all.</p>
</li>
<li><p>A demonstration that a second user's run stops instead of reading the first user's data.</p>
</li>
</ul>
<p>The finished code lives at <a href="https://github.com/saif-shines/channel-watcher-agent">github.com/saif-shines/channel-watcher-agent</a>.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Accounts and tools:</p>
<ul>
<li><p><strong>Node.js 22.13 or newer</strong>, plus npm. The token store uses <a href="https://nodejs.org/api/sqlite.html"><code>node:sqlite</code></a>, which is stable from that version on.</p>
</li>
<li><p><strong>A Slack workspace</strong> where you can install apps, and a channel to watch. A throwaway channel works best.</p>
</li>
<li><p><strong>A GitHub account</strong> and a repository that can absorb test issues.</p>
</li>
<li><p><strong>An API key for a model provider</strong> the AI SDK supports. Anthropic is used in the examples.</p>
</li>
<li><p><a href="https://github.com/FiloSottile/mkcert"><strong>mkcert</strong></a>, to issue a local HTTPS certificate. <a href="#heading-how-to-register-the-slack-and-github-oauth-apps">How to Register the Slack and GitHub OAuth Apps</a> explains why an ordinary <code>http://localhost</code> callback will not do.</p>
</li>
</ul>
<p>Useful background, though none of it is a hard requirement:</p>
<ul>
<li><p><code>async</code> and <code>await</code>, and reading a small Node script.</p>
</li>
<li><p>OAuth 2.0 at a high level: an app redirects a user to a provider, the user consents, the app receives a token.</p>
</li>
<li><p>Tool calling, sometimes called function calling. The next section covers what the tutorial needs.</p>
</li>
</ul>
<p><strong>One warning before starting:</strong> The agent writes to real systems. It opens real GitHub issues and posts real Slack messages. Use a test Slack channel and a throwaway GitHub repository while you're still checking that it only acts on messages you intend.</p>
<h2 id="heading-what-are-ai-agent-tools">What Are AI Agent Tools?</h2>
<p><strong>A tool is a function you hand the model along with your input.</strong> The model can't run that function itself. It can only ask: call <code>fileGithubIssue</code> with this title and this body. Your code performs the call, returns the result, and the model uses that result to choose the next step.</p>
<p>Request, execute, return. The exchange is the whole mechanism, and everything called an "agent" is a loop around it.</p>
<h3 id="heading-how-a-tool-differs-from-an-api">How a Tool Differs from an API</h3>
<p>Tools and APIs wrap the same call but are written for different readers.</p>
<p>An API is written for you. It assumes you read the documentation, and that you know <code>thread_ts</code> is the field that turns a Slack message into a threaded reply.</p>
<p>A tool is written for a model that has read nothing. So a tool carries its own explanation:</p>
<ul>
<li><p>A <strong>name</strong> the model can reason about, like <code>fileGithubIssue</code>.</p>
</li>
<li><p>A <strong>description</strong> in plain language, including when not to use the tool.</p>
</li>
<li><p>A <strong>schema</strong> for the inputs, so the model knows <code>title</code> is a required string.</p>
</li>
</ul>
<p>Below is one tool from the project. Most of the code is explanation rather than logic:</p>
<pre><code class="language-javascript">const fileGithubIssue = tool({
  description: 'File a GitHub issue for an actionable Slack message',
  inputSchema: z.object({
    title: z.string(),
    body: z.string(),
  }),
  execute: async ({ title, body }) =&gt; {
    // ... the actual API call goes here
  },
});
</code></pre>
<p>The <code>description</code> and <code>inputSchema</code> are the parts the model sees. The <code>execute</code> function is yours alone. Identity gets settled inside <code>execute</code>, so the model never learns which account the call ran against.</p>
<h3 id="heading-why-models-handle-tools-better-than-raw-api-calls">Why Models Handle Tools Better Than Raw API Calls</h3>
<p>Pasting a curl command into the input and asking the model to fill in the blanks is possible. But this approach fails in predictable ways.</p>
<p>Tools work better for three reasons:</p>
<ol>
<li><p>The schema is enforced before your code runs. A malformed tool call gets rejected and retried by the SDK. A malformed URL fails at runtime instead.</p>
</li>
<li><p>Results return to the model. After <code>fileGithubIssue</code> returns, the model can read the new issue URL and use it in the Slack reply. The chaining is what makes the second step possible.</p>
</li>
<li><p>Credentials stay out of the conversation. The model asks for an action by name and never sees a token. A token it never sees can't leak into a completion, a log line, or a prompt-injection payload.</p>
</li>
</ol>
<p>Reason three is what the rest of this tutorial builds toward. You'll keep tokens out of the model on purpose: the agent holds an identifier, and a token appears only at the moment of the provider call.</p>
<h3 id="heading-most-agents-need-more-than-one-app">Most Agents Need More Than One App</h3>
<p>Few useful agents talk to a single app. A support agent reads Zendesk and updates Salesforce. A standup agent reads GitHub and posts to Slack. A scheduling agent reads Gmail and writes to Google Calendar.</p>
<p>Each app brings its own OAuth registration, scope names, token lifetime, and refresh behavior. Multiply the list by every user of the agent, and the real problem appears.</p>
<h2 id="heading-why-a-shared-token-breaks">Why a Shared Token Breaks</h2>
<p>One shared credential for everybody works in a demo and fails once a second person shows up. Picture the quick version of the Slack half: create a Slack app, install it, copy the bot token into <code>.env</code>, and let every tool call use it.</p>
<p>Three problems arrive together.</p>
<p>First, every run uses the same permissions. The bot sees every channel it was invited to, no matter who triggered the run. Ask the agent about a channel you were never in, and the bot reads it anyway. The agent has become a way around your own workspace permissions.</p>
<p>Second, the audit trail is also wrong. Every GitHub issue says the bot opened it. Every Slack reply comes from the bot. Asked why an issue exists, the honest answer is "an agent filed it for somebody, and we can't tell who."</p>
<p>And third, revocation stops working. A user leaves the company and their Slack account is deactivated. The agent keeps running, because it never used their credentials.</p>
<p>The alternative is per-user grants. Each user authorizes the apps for themselves. That creates a new requirement, though: somewhere to keep those grants.</p>
<h3 id="heading-the-distinction-is-one-field-in-one-response">The Distinction is One Field in One Response</h3>
<p>Slack makes the difference unusually easy to see. When a user finishes the consent screen, <a href="https://docs.slack.dev/authentication/installing-with-oauth">the token exchange</a> returns both kinds of token in the same JSON object:</p>
<pre><code class="language-json">{
  "ok": true,
  "access_token": "xoxb-REDACTED-BOT-TOKEN",
  "token_type": "bot",
  "authed_user": {
    "id": "U0A1B2C3D",
    "scope": "channels:history,chat:write,users:read",
    "access_token": "xoxp-REDACTED-USER-TOKEN",
    "token_type": "user"
  }
}
</code></pre>
<p>The top-level <code>access_token</code> is the bot. The nested <code>authed_user.access_token</code> is the person who just consented. Reading <code>conversations.history</code> with the first one returns every channel the app was invited to. Reading it with the second returns only the channels that users can already see. The same split governs writes: <a href="https://docs.slack.dev/reference/methods/chat.postMessage"><code>chat.postMessage</code></a> with a user token posts under that person's name.</p>
<p>Two fields, one letter apart in the prefix, and the entire permission model of your agent hangs on which one you store. This tutorial requests only user scopes, so Slack issues no bot token at all.</p>
<h3 id="heading-tokens-must-stay-out-of-the-model-and-the-logs">Tokens Must Stay Out of the Model and the Logs</h3>
<p>Per-user tokens become the most sensitive data in the system. Two destinations are off limits:</p>
<ul>
<li><p><strong>The model:</strong> Keep tokens out of inputs, tool descriptions, and tool return values. A model that has seen a token can repeat it, and prompt injection turns any tool result into untrusted input.</p>
</li>
<li><p><strong>Your logs:</strong> Tool inputs and outputs are exactly what you want to log while debugging an agent. Tokens traveling in those payloads land in your log store permanently.</p>
</li>
</ul>
<p>This tutorial keeps tokens on one narrow path. Your code passes an identifier, a stable reference to one user. One helper turns that identifier into a token, and from there the token goes straight into a provider call and nowhere else. It's never named in a tool schema, never attached to anything the model can read, and never returned from a tool.</p>
<h3 id="heading-why-you-own-the-oauth-apps-and-the-store">Why You Own the OAuth Apps and the Store</h3>
<p>The point of writing the flow yourself isn't the plumbing. It's control over who may use whose grant.</p>
<p>In this tutorial the users are teammates. Each person connects their own Slack and GitHub, and the agent acts as whoever triggered the run. The same design holds when those users are customers of your product: each person still has their own grant, and a wrong mapping means one person's run using someone else's access. Only the source of the identifier changes. A session for teammates, a tenant record for customers.</p>
<h2 id="heading-architecture-overview">Architecture Overview</h2>
<p>Two flows matter, and they happen at different times. Keeping them separate is most of the work.</p>
<p>Connection time happens once per user, per app. The user consents, and tokens land in your store. The agent isn't running.</p>
<p>Runtime happens on every execution. The agent resolves the current user to an identifier and does its work. No consent screens and no browser.</p>
<pre><code class="language-text">CONNECTION TIME (once per user, per app)

  Your user              connect.js              Slack / GitHub
     |                       |                         |
     |-- "connect Slack" ---&gt;|                         |
     |&lt;--- consent link -----|                         |
     |----------------------- OAuth consent ----------&gt;|
     |                       |&lt;--- redirect + code ----|
     |                       |---- exchange code -----&gt;|
     |                       |&lt;---- tokens ------------|
     |                       |                         |
     |                  [encrypt, store                |
     |                   under (identifier,            |
     |                   provider)]                    |
     |                       |                         |


RUNTIME (every agent run)

  Your agent             Token store             Slack / GitHub
     |                       |                         |
  [resolve identifier        |                         |
   from your own session]    |                         |
     |                       |                         |
     |-- getAccessToken( ---&gt;|                         |
     |     identifier,       |                         |
     |     provider )        |                         |
     |&lt;---- token -----------|                         |
     |                       |                         |
     |------------------ API call as user ------------&gt;|
     |&lt;----------------- result -----------------------|
     |                       |                         |
  [model sees result,        |                         |
   never a token]            |                         |
</code></pre>
<p>Three properties follow from the shape.</p>
<p>The identifier replaces the token in your agent code. Everything above the token store handles a string like <code>alice@example.com</code> or <code>user_8f21c</code>. The string is worthless on its own: without the store and its encryption key, it opens nothing.</p>
<p>One identity spans many apps. A single identifier has a Slack row and a GitHub row beneath it. A third app doesn't create a third identity to reconcile.</p>
<p>Authorization stays in your code. The store answers which tokens belong to an identifier. The store can't know whether the request deserved an answer. Deciding that the caller may act as that identifier happens before any call.</p>
<p>One rule follows, and bending it defeats the whole design: resolve the identifier server-side from an authenticated session. Never accept an identifier from a request body, a query parameter, or a browser. An identifier accepted from a client is an "act as any user" endpoint.</p>
<h2 id="heading-how-to-register-the-slack-and-github-oauth-apps">How to Register the Slack and GitHub OAuth Apps</h2>
<p>The walkthrough uses Slack and GitHub as the two providers end to end. Both need the same three things: a registered app, a redirect URI, and a set of scopes. The details differ enough to be worth walking through separately.</p>
<h3 id="heading-the-redirect-uri-has-to-use-https">The Redirect URI Has to Use HTTPS</h3>
<p>Most tutorials that touch OAuth hand you <code>http://localhost:3000/callback</code> and move on. Slack rejects it. <a href="https://docs.slack.dev/authentication/installing-with-oauth">Slack's documentation</a> states flatly that "a Redirect URL must also use HTTPS", and it makes no exception for <code>localhost</code>. GitHub is more relaxed and accepts either, so a single HTTPS callback satisfies both.</p>
<p>The rule looks pedantic, because on <code>localhost</code> the request never leaves your machine and there's nothing on the wire to intercept. Slack applies it uniformly anyway, and a uniform rule with no exemptions is a defensible choice for a provider handing out credentials: every exemption is a branch somebody has to get right, and "is this really localhost" is a question that has been answered incorrectly before.</p>
<p><a href="https://github.com/FiloSottile/mkcert">mkcert</a> issues a certificate signed by a local authority it adds to your system trust store, so the browser accepts it without a warning:</p>
<pre><code class="language-bash">mkcert -install
mkcert localhost
</code></pre>
<p>That writes <code>localhost.pem</code> and <code>localhost-key.pem</code> into the current directory. A tunneling service such as ngrok also works, but its free URLs rotate, which means re-editing both app registrations every session.</p>
<h3 id="heading-the-slack-app-and-the-one-setting-that-matters">The Slack App, and the One Setting That Matters</h3>
<p>At <a href="https://api.slack.com/apps">api.slack.com/apps</a>, create an app in your workspace. Then open <strong>OAuth &amp; Permissions</strong> and set two things.</p>
<p>Add <code>https://localhost:3000/callback</code> under <strong>Redirect URLs</strong>.</p>
<p>Then find the scopes. The page has two sections, and choosing the wrong one silently rebuilds the shared-bot design:</p>
<table>
<thead>
<tr>
<th>Section</th>
<th>What it grants</th>
<th>Use it here?</th>
</tr>
</thead>
<tbody><tr>
<td>Bot Token Scopes</td>
<td>A <code>xoxb-</code> token that acts as the app</td>
<td>No</td>
</tr>
<tr>
<td>User Token Scopes</td>
<td>A <code>xoxp-</code> token that acts as the person</td>
<td>Yes</td>
</tr>
</tbody></table>
<p>Under <strong>User Token Scopes</strong>, add:</p>
<ul>
<li><p><code>channels:history</code>: read messages in public channels the user belongs to</p>
</li>
<li><p><code>chat:write</code>: post as the user</p>
</li>
<li><p><code>users:read</code>: turn user IDs into names</p>
</li>
</ul>
<p>Leave Bot Token Scopes empty. Copy the Client ID and Client Secret from <strong>Basic Information</strong>.</p>
<h3 id="heading-the-github-oauth-app">The GitHub OAuth App</h3>
<p>Under Settings → Developer settings → OAuth Apps → New OAuth App, set the Authorization callback URL to the same <code>https://localhost:3000/callback</code>, then generate a client secret. GitHub documents <a href="https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps">the web application flow</a> in full if you want the surrounding detail.</p>
<p>GitHub's <a href="https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps">scope</a> for issue creation depends on the repository:</p>
<ul>
<li><p><code>repo</code> covers private repositories, and grants read and write access to code along with it.</p>
</li>
<li><p><code>public_repo</code> is the narrower choice, and enough when your test repository is public.</p>
</li>
</ul>
<p>Take the narrower one when you can. A scope you didn't need is a scope you have to explain later.</p>
<h3 id="heading-the-environment-file">The Environment File</h3>
<p>Both apps produce a client ID and a client secret, and the store needs an encryption key. Generate the key first:</p>
<pre><code class="language-bash">node -e "console.log(require('node:crypto').randomBytes(32).toString('base64'))"
</code></pre>
<p>Then fill in <code>.env</code>:</p>
<pre><code class="language-bash">OAUTH_REDIRECT_URI=https://localhost:3000/callback
TLS_CERT_PATH=./localhost.pem
TLS_KEY_PATH=./localhost-key.pem

SLACK_CLIENT_ID=
SLACK_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=

TOKEN_ENCRYPTION_KEY=

SLACK_CHANNEL_ID=C0XXXXXXXXX
GITHUB_REPO=your-name/your-test-repo
</code></pre>
<p>Those client secrets authenticate <strong>your application</strong> to the providers. They're not user credentials, and they never belong in a browser.</p>
<h2 id="heading-how-to-run-the-consent-flow">How to Run the Consent Flow</h2>
<p>Everything provider-specific belongs in one place, so that adding a third provider later means adding an entry rather than a branch.</p>
<h3 id="heading-step-1-describe-each-provider-once">Step 1: Describe Each Provider Once</h3>
<pre><code class="language-javascript">const REDIRECT_URI = process.env.OAUTH_REDIRECT_URI;

export const providers = {
  slack: {
    label: 'Slack',
    authorizeUrl: 'https://slack.com/oauth/v2/authorize',
    tokenUrl: 'https://slack.com/api/oauth.v2.access',

    // These go in `user_scope`, not `scope`. Scopes listed under `scope` grant
    // a bot token, and a bot token is what this project exists to avoid.
    userScopes: ['channels:history', 'chat:write', 'users:read'],

    buildAuthorizeUrl(state) {
      const url = new URL(this.authorizeUrl);
      url.searchParams.set('client_id', process.env.SLACK_CLIENT_ID);
      url.searchParams.set('user_scope', this.userScopes.join(','));
      url.searchParams.set('redirect_uri', REDIRECT_URI);
      url.searchParams.set('state', state);
      return url.toString();
    },
    // exchangeCode and refresh follow below
  },
};
</code></pre>
<p><strong>The</strong> <code>user_scope</code> <strong>parameter is the whole argument in one line.</strong> Slack reads <code>scope</code> for bot permissions and <code>user_scope</code> for user permissions. This project sets only the second, so the response comes back with no bot token in it at all.</p>
<p>The <code>state</code> parameter isn't optional. It's a random string you generate, send to the provider, and check on the way back. Without it, any page on the internet can point a browser at your callback URL with an attacker's <code>code</code> attached, and your server will happily exchange it and store the attacker's token under your user's identifier.</p>
<h3 id="heading-step-2-exchange-the-code-and-take-the-right-token">Step 2: Exchange the Code, and Take the Right Token</h3>
<pre><code class="language-javascript">async exchangeCode(code) {
  const response = await fetch(this.tokenUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      code,
      client_id: process.env.SLACK_CLIENT_ID,
      client_secret: process.env.SLACK_CLIENT_SECRET,
      redirect_uri: REDIRECT_URI,
    }),
  });

  const json = await response.json();

  // Slack answers HTTP 200 even when the exchange failed. The `ok` field
  // is the real status.
  if (!json.ok) {
    throw new Error(`Slack token exchange failed: ${json.error}`);
  }

  return normalizeSlackTokens(json.authed_user);
}
</code></pre>
<p>Two details in that function cost real debugging time when missed.</p>
<p><strong>Slack returns HTTP 200 for failures.</strong> Checking <code>response.ok</code> tells you the HTTP request succeeded, which it did. The <code>json.ok</code> field is the one that reports whether the OAuth exchange worked.</p>
<p><code>json.authed_user</code><strong>, not</strong> <code>json</code><strong>.</strong> This is the fork from the section above, expressed as one property access. Reading <code>json.access_token</code> here would compile, run, store a token, and quietly give every user of your agent the same bot identity.</p>
<p>Normalizing the result keeps the rest of the codebase provider-agnostic:</p>
<pre><code class="language-javascript">function normalizeSlackTokens(authedUser) {
  return {
    accessToken: authedUser.access_token,
    refreshToken: authedUser.refresh_token ?? null,
    expiresAt: authedUser.expires_in
      ? Date.now() + authedUser.expires_in * 1000
      : null,
    scope: authedUser.scope,
  };
}
</code></pre>
<p>GitHub's version of the same function differs in two ways worth noting:</p>
<pre><code class="language-javascript">async exchangeCode(code) {
  const response = await fetch(this.tokenUrl, {
    method: 'POST',
    // Without this header GitHub answers with a form-encoded body.
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      Accept: 'application/json',
    },
    body: new URLSearchParams({
      code,
      client_id: process.env.GITHUB_CLIENT_ID,
      client_secret: process.env.GITHUB_CLIENT_SECRET,
      redirect_uri: REDIRECT_URI,
    }),
  });

  const json = await response.json();
  if (json.error) {
    throw new Error(
      `GitHub token exchange failed: ${json.error_description ?? json.error}`
    );
  }

  // OAuth App tokens carry no expiry, so there is nothing to refresh.
  return {
    accessToken: json.access_token,
    refreshToken: null,
    expiresAt: null,
    scope: json.scope,
  };
}
</code></pre>
<p>The <code>Accept: application/json</code> header is easy to skip and produces a confusing failure: <code>response.json()</code> throws on a body that came back as <code>access_token=gho_...&amp;scope=repo</code>.</p>
<h3 id="heading-step-3-catch-the-redirect">Step 3: Catch the Redirect</h3>
<p>OAuth needs somewhere to land. For a command-line tool, a server that starts, handles one callback per provider, and exits is enough. Because Slack demands HTTPS, the scheme in <code>OAUTH_REDIRECT_URI</code> decides which kind of server to start:</p>
<pre><code class="language-javascript">function createCallbackServer(handler) {
  if (redirect.protocol !== 'https:') {
    return createHttpServer(handler);
  }

  try {
    return createHttpsServer(
      {
        cert: readFileSync(process.env.TLS_CERT_PATH),
        key: readFileSync(process.env.TLS_KEY_PATH),
      },
      handler
    );
  } catch (err) {
    throw new Error(
      `Could not read the TLS certificate (${err.code ?? err.message}).\n` +
        'Generate a locally-trusted one with mkcert:\n' +
        '  mkcert -install\n' +
        '  mkcert localhost\n' +
        'then point TLS_CERT_PATH and TLS_KEY_PATH at the two files it writes.'
    );
  }
}
</code></pre>
<p>A missing certificate is going to happen to somebody, and <code>ENOENT</code> on its own explains nothing about OAuth. The catch block spends four lines saying what to run instead.</p>
<p>The handler itself is where <code>state</code> gets checked:</p>
<pre><code class="language-javascript">const pending = new Map();

function handleCallback(request, response) {
  const url = new URL(request.url, redirect.origin);

  if (url.pathname !== redirect.pathname) {
    response.writeHead(404).end('Not found');
    return;
  }

  const state = url.searchParams.get('state');
  const entry = pending.get(state);

  if (!entry) {
    response.writeHead(400).end('State mismatch. Start the flow again.');
    return;
  }

  pending.delete(state);

  const error = url.searchParams.get('error');
  if (error) {
    response.writeHead(400).end(`Authorization denied: ${error}`);
    entry.reject(new Error(`[${entry.provider}] authorization denied: ${error}`));
    return;
  }

  entry.finish(url.searchParams.get('code'), response);
}
</code></pre>
<p><strong>The</strong> <code>pending</code> <strong>map is the</strong> <code>state</code> <strong>check.</strong> A state value gets into that map only when this process generated it, and it's deleted the moment it's used. An unrecognized state means the callback didn't come from a flow you started, and a state that arrives twice means a replay. Both fall out of one <code>Map</code> lookup.</p>
<p>Generating the state and waiting for its callback:</p>
<pre><code class="language-javascript">function connect(providerName) {
  const provider = providers[providerName];
  const state = randomBytes(16).toString('hex');

  console.log(`\n[${providerName}] authorize as "${IDENTIFIER}":`);
  console.log(provider.buildAuthorizeUrl(state));

  return new Promise((resolve, reject) =&gt; {
    pending.set(state, {
      provider: providerName,
      reject,
      async finish(code, response) {
        const tokens = await provider.exchangeCode(code);
        saveGrant(IDENTIFIER, providerName, tokens);
        response
          .writeHead(200, { 'Content-Type': 'text/html' })
          .end(`&lt;p&gt;${provider.label} connected. You can close this tab.&lt;/p&gt;`);
        resolve();
      },
    });
  });
}
</code></pre>
<p><code>randomBytes(16)</code> and not <code>Math.random()</code>. A predictable state parameter is the same as no state parameter.</p>
<p>Running it walks each unconnected provider in turn:</p>
<pre><code class="language-text">[slack] authorize as "alice@example.com":
https://slack.com/oauth/v2/authorize?client_id=123.456&amp;user_scope=channels%3Ahistory%2Cchat%3Awrite%2Cusers%3Aread&amp;redirect_uri=https%3A%2F%2Flocalhost%3A3000%2Fcallback&amp;state=1159699dbf1a808fd33ba31c7b643505
</code></pre>
<p>Notice what that URL doesn't contain: any <code>scope</code> parameter. Slack has no instruction to mint a bot token, so it won't.</p>
<h2 id="heading-how-to-store-tokens-encrypted-keyed-by-user">How to Store Tokens Encrypted, Keyed by User</h2>
<p>The store answers one question: which token belongs to this user, for this provider? Everything else about it follows from keeping that answer safe.</p>
<p><code>node:sqlite</code> has shipped with Node since v22.5, and stopped requiring a flag in v22.13. That makes a real database available with nothing to install:</p>
<pre><code class="language-javascript">import { DatabaseSync } from 'node:sqlite';
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';

const KEY = Buffer.from(process.env.TOKEN_ENCRYPTION_KEY ?? '', 'base64');

if (KEY.length !== 32) {
  throw new Error(
    'TOKEN_ENCRYPTION_KEY must be 32 bytes, base64-encoded. ' +
      `Got ${KEY.length} bytes.`
  );
}

const db = new DatabaseSync(
  process.env.TOKEN_DB_PATH ?? new URL('../tokens.db', import.meta.url).pathname
);

// One row per user, per provider. expires_at stays outside the ciphertext so
// a token's freshness can be checked without decrypting it.
db.exec(`
  CREATE TABLE IF NOT EXISTS grants (
    identifier TEXT    NOT NULL,
    provider   TEXT    NOT NULL,
    ciphertext BLOB    NOT NULL,
    iv         BLOB    NOT NULL,
    auth_tag   BLOB    NOT NULL,
    expires_at INTEGER,
    PRIMARY KEY (identifier, provider)
  )
`);
</code></pre>
<p><strong>The composite primary key is the isolation guarantee, written down.</strong> <code>(identifier, provider)</code> means Alice's Slack row and Bob's Slack row can't collide, and no query that supplies both parts can return somebody else's grant.</p>
<p><code>expires_at</code> <strong>sits outside the ciphertext deliberately.</strong> Checking whether a token needs refreshing is something you do before every call. Decrypting to find out would mean decrypting constantly, so the one field that isn't a secret stays readable.</p>
<p>Encryption is AES-256-GCM, which authenticates as well as encrypts:</p>
<pre><code class="language-javascript">function encrypt(payload) {
  const iv = randomBytes(12);
  const cipher = createCipheriv('aes-256-gcm', KEY, iv);
  const ciphertext = Buffer.concat([
    cipher.update(JSON.stringify(payload), 'utf8'),
    cipher.final(),
  ]);
  return { ciphertext, iv, authTag: cipher.getAuthTag() };
}

function decrypt({ ciphertext, iv, authTag }) {
  const decipher = createDecipheriv('aes-256-gcm', KEY, iv);
  decipher.setAuthTag(authTag);
  const plaintext = Buffer.concat([
    decipher.update(ciphertext),
    decipher.final(),
  ]);
  return JSON.parse(plaintext.toString('utf8'));
}
</code></pre>
<p>Three rules govern that pair, and breaking any one of them is worse than not encrypting at all, because it looks like it worked:</p>
<ol>
<li><p><strong>A fresh IV per encryption:</strong> Reusing an initialization vector with GCM is a catastrophic failure, not a minor one. <code>randomBytes(12)</code> on every call, stored beside the ciphertext.</p>
</li>
<li><p><strong>Keep the auth tag:</strong> GCM produces a tag that proves the ciphertext wasn't altered. Without <code>setAuthTag</code> on the way back, you have encryption without integrity, and <code>decipher.final()</code> won't complain.</p>
</li>
<li><p><strong>Encrypt the whole token object, not each field.</strong> One ciphertext for <code>{ accessToken, refreshToken, scope }</code> means one IV and one tag to manage rather than three of each.</p>
</li>
</ol>
<p>Writing and reading are then unremarkable:</p>
<pre><code class="language-javascript">export function saveGrant(identifier, provider, tokens) {
  const { ciphertext, iv, authTag } = encrypt(tokens);
  db.prepare(
    `INSERT INTO grants (identifier, provider, ciphertext, iv, auth_tag, expires_at)
     VALUES (?, ?, ?, ?, ?, ?)
     ON CONFLICT (identifier, provider) DO UPDATE SET
       ciphertext = excluded.ciphertext,
       iv         = excluded.iv,
       auth_tag   = excluded.auth_tag,
       expires_at = excluded.expires_at`
  ).run(identifier, provider, ciphertext, iv, authTag, tokens.expiresAt ?? null);
}
</code></pre>
<p>The <code>ON CONFLICT</code> clause matters more than it looks. Re-consenting has to replace a grant rather than fail or duplicate it, and re-consenting is exactly what a user does after a revocation or a scope change.</p>
<p>The encryption key itself lives in <code>.env</code> here, which is right for a tutorial and wrong for production, where it belongs in a secrets manager or a KMS. Losing it makes every stored grant unreadable and forces every user to consent again. That is a real outage, but it's a better one than the alternative: a stolen database file that hands over working tokens for every user of your agent.</p>
<h2 id="heading-how-to-run-tool-calls-as-the-current-user">How to Run Tool Calls as the Current User</h2>
<p>Runtime has three moves: resolve the identifier, fetch a token with it, and wrap the whole thing as a tool.</p>
<h3 id="heading-step-1-resolve-the-identifier-then-authorize">Step 1: Resolve the Identifier, Then Authorize</h3>
<p>An identifier is <strong>any stable string</strong> that represents one user, an email address, a user ID, a tenant-scoped key.</p>
<pre><code class="language-javascript">// In a real app this comes from your authenticated session, resolved
// server-side. Never accept it from client input.
const IDENTIFIER = process.argv[2] ?? 'channel-watcher-agent';
</code></pre>
<p>Reading the identifier from <code>argv</code> keeps the demo runnable without a login, and it makes the isolation test later in this tutorial a single command. A real application replaces the line:</p>
<pre><code class="language-javascript">// Real app: resolve from your authenticated session, server-side.
const session = await getSession(request);                  // your auth
const identifier = await lookupIdentifier(session.userId);  // your database
</code></pre>
<p><strong>Order matters in those two lines.</strong> Authenticate the caller first, then look up which identifier the caller may act as. An identifier arriving from a client turns the endpoint into a reader of any user's Slack.</p>
<h3 id="heading-step-2-turn-the-identifier-into-a-token-late">Step 2: Turn the Identifier into a Token, Late</h3>
<p>One function stands between the identifier and every provider call:</p>
<pre><code class="language-javascript">const REFRESH_WINDOW_MS = 60_000;

export async function getAccessToken(identifier, providerName) {
  const grant = readGrant(identifier, providerName);

  if (!grant) {
    throw new Error(
      `[${providerName}] no grant for "${identifier}".\n` +
        `Connect it first: node src/connect.js ${identifier}`
    );
  }

  const expiringSoon =
    grant.expiresAt !== null &amp;&amp;
    grant.expiresAt !== undefined &amp;&amp;
    grant.expiresAt - Date.now() &lt; REFRESH_WINDOW_MS;

  if (!expiringSoon) {
    return grant.accessToken;
  }

  if (!grant.refreshToken) {
    throw new Error(
      `[${providerName}] token for "${identifier}" expired and no refresh ` +
        'token is stored. The user has to consent again.'
    );
  }

  const refreshed = await providers[providerName].refresh(grant.refreshToken);
  saveGrant(identifier, providerName, refreshed);
  return refreshed.accessToken;
}
</code></pre>
<p><strong>Call this immediately before the API call, not once at startup.</strong> A long agent run can outlive a twelve-hour token, and resolving tokens up front means discovering that at the least convenient moment. Fetching late costs one cheap database read and removes the whole class of problem.</p>
<p>Also, the <strong>sixty-second window isn't padding for its own sake.</strong> A token with four seconds left passes a naive expiry check and then expires in flight. Refreshing anything inside the window means the token handed back is good for at least a minute of work.</p>
<p>Finally, a missing grant raises an error rather than falling back. There's nothing sensible to fall back to. The correct outcome for an unconnected user is a stop, with a message saying how to connect.</p>
<h3 id="heading-step-3-wrap-provider-calls-as-tools">Step 3: Wrap Provider Calls as Tools</h3>
<p>Identity gets injected here, one layer below anything the model can influence:</p>
<pre><code class="language-javascript">export function buildTools(identifier) {
  const [owner, repo] = process.env.GITHUB_REPO.split('/');

  const fileGithubIssue = tool({
    description: 'File a GitHub issue for an actionable Slack message',
    inputSchema: z.object({
      title: z.string(),
      body: z.string(),
    }),
    execute: async ({ title, body }) =&gt; {
      const token = await getAccessToken(identifier, 'github');
      return createIssue(token, owner, repo, { title, body });
    },
  });

  const replyInSlackThread = tool({
    description:
      'Reply in the original Slack thread (e.g. with the created issue link)',
    inputSchema: z.object({
      text: z.string(),
      thread_ts: z.string(),
    }),
    execute: async ({ text, thread_ts }) =&gt; {
      const token = await getAccessToken(identifier, 'slack');
      return postThreadReply(
        token,
        process.env.SLACK_CHANNEL_ID,
        text,
        thread_ts
      );
    },
  });

  return { fileGithubIssue, replyInSlackThread };
}
</code></pre>
<p>Compare what the model controls against what it can't. The model chooses <code>title</code>, <code>body</code>, and <code>text</code>. <strong>The model can't choose the user.</strong> <code>identifier</code> is a closure argument, fixed before the model ran, and it appears in no <code>inputSchema</code>. There's no input that makes the model file an issue as somebody else, because the account isn't one of its inputs.</p>
<p>Return values deserve one audit each. <code>createIssue</code> returns the issue number, URL, and title. <code>postThreadReply</code> returns a timestamp. Neither returns a token, and neither returns the raw provider response, which is where a token would hide if one were going to.</p>
<p>The provider calls themselves are ordinary HTTP:</p>
<pre><code class="language-javascript">export async function createIssue(token, owner, repo, { title, body }) {
  const response = await fetch(
    `https://api.github.com/repos/${owner}/${repo}/issues`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${token}`,
        Accept: 'application/vnd.github+json',
        'X-GitHub-Api-Version': '2022-11-28',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ title, body }),
    }
  );

  const json = await response.json();

  if (!response.ok) {
    // 403 here usually means the grant is missing the `repo` scope.
    throw new Error(
      `GitHub issue creation failed (${response.status}): ${json.message}`
    );
  }

  return { number: json.number, url: json.html_url, title: json.title };
}
</code></pre>
<h3 id="heading-step-4-read-the-channel">Step 4: Read the Channel</h3>
<p>Slack's <a href="https://docs.slack.dev/reference/methods/conversations.history"><code>conversations.history</code></a> returns clean JSON, with one gap: messages carry a user ID, never a display name. Turning those into names means a <a href="https://docs.slack.dev/reference/methods/users.info"><code>users.info</code></a> call each, which is what <code>users:read</code> was in the scope list for.</p>
<pre><code class="language-javascript">export async function readChannel(token, channelId, limit = 20) {
  const { messages } = await slackCall(token, 'conversations.history', {
    channel: channelId,
    limit: String(limit),
  });

  const authors = await resolveAuthors(
    token,
    messages.filter((m) =&gt; m.user).map((m) =&gt; m.user)
  );

  return messages
    .filter((message) =&gt; message.text)
    .map((message) =&gt; ({
      author: authors.get(message.user) ?? 'unknown',
      userId: message.user,
      text: message.text,
      ts: message.ts,
    }))
    .reverse(); // oldest first
}
</code></pre>
<p>Three small decisions in that function:</p>
<ol>
<li><p><strong>Names cost one</strong> <code>users.info</code> <strong>call per unique author.</strong> Caching them per run keeps a channel full of one person's messages from producing twenty identical lookups. A lookup that fails falls back to the user ID rather than throwing, since an unresolvable name isn't a reason to abandon the run.</p>
</li>
<li><p><strong>Messages without</strong> <code>text</code> <strong>get dropped.</strong> Channel joins and purpose changes arrive as message objects with no body, and there's nothing for the model to triage in them.</p>
</li>
<li><p><code>.reverse()</code> <strong>isn't cosmetic.</strong> Slack returns newest first. A model reading a conversation backwards will misread which message answered which.</p>
</li>
</ol>
<p>The <code>ts</code> field then does double duty. It identifies a message, which makes it both the thread anchor for replies and the key for remembering what the agent already handled:</p>
<pre><code class="language-javascript">const state = await loadState();
const processed = new Set(state[IDENTIFIER]?.processedTs ?? []);
const newMessages = messages.filter((m) =&gt; !processed.has(m.ts));
</code></pre>
<p><strong>Key that state by identifier</strong>, as the snippet does. A single flat list lets one user's processed messages hide another's, which reintroduces cross-user bleed in the one place the whole design exists to prevent.</p>
<h3 id="heading-step-5-run-the-tool-loop">Step 5: Run the Tool Loop</h3>
<p>Hand the model both tools and let it decide:</p>
<pre><code class="language-javascript">const { text } = await generateText({
  model: anthropic(process.env.MODEL),
  tools,
  stopWhen: stepCountIs(5),
  prompt: `You triage messages from a dev team's Slack channel.

Message from ${message.author}: "${message.text}"
Message timestamp (thread_ts): ${message.ts}

Decide if this message is actionable (a bug report or concrete action item) or just noise (chit-chat, join notices, already-resolved chatter).

If actionable: file a GitHub issue with a clear title and body drafted from the message, then reply in the original Slack thread (use the exact thread_ts above) with a short note and the created issue's URL.

If not actionable: do nothing and briefly say why.`,
});
</code></pre>
<p>The <strong>loop</strong> is what makes the second step possible. The model reads the message and may call <code>fileGithubIssue</code>. The AI SDK runs the tool, feeds the result back into context along with the new issue URL, and calls the model again. Now the model can reply in the thread with a URL it couldn't have known on the first pass. Then it stops.</p>
<p><code>stopWhen: stepCountIs(5)</code> caps the rounds. Without a bound, a confused model can retry a failing tool indefinitely. Five rounds is generous for two tools.</p>
<p>A deterministic version is also reasonable: classify with a structured-output call, then call both tools yourself in a fixed order when the message qualifies.</p>
<p>The fixed sequence is easier to test and gives up real flexibility. A loop lets the model skip the reply, or file without replying, and adding a third tool needs no new branching. Choose the loop when the set of actions varies per input, and the fixed sequence when it never does.</p>
<p>One note on the provider line, for accuracy about what ran. The snippet above uses <code>@ai-sdk/anthropic</code>, which suits a direct Anthropic API key. My own tests went through an OpenAI-compatible gateway, which changes only the provider construction:</p>
<pre><code class="language-javascript">import { createOpenAICompatible } from '@ai-sdk/openai-compatible';

const gateway = createOpenAICompatible({
  name: 'gateway',
  baseURL: `${process.env.GATEWAY_BASE_URL}/v1`,
  apiKey: process.env.GATEWAY_API_KEY,
});
// then: model: gateway(process.env.MODEL)
</code></pre>
<p>The tools, the loop, and the token handling are identical either way. Only the <code>model</code> argument changes.</p>
<h2 id="heading-how-to-handle-refresh-and-revocation">How to Handle Refresh and Revocation</h2>
<p><strong>Tokens end in two different ways,</strong> and only one of them is your code's problem**.** Expiry is routine and recoverable. Revocation is a decision somebody made, and the correct response is to ask for consent again.</p>
<p>The two providers in this tutorial sit at opposite ends of the range, which makes them a useful pair.</p>
<h3 id="heading-github-tokens-that-dont-expire-until-they-do">GitHub: Tokens That Don't Expire, Until They Do</h3>
<p>An OAuth App user token has no expiry timestamp. There's no refresh token to store and no refresh call to make, which is why <code>github.refresh()</code> in this project does nothing but explain itself:</p>
<pre><code class="language-javascript">async refresh() {
  throw new Error(
    'GitHub OAuth App tokens do not expire. A failure here means the ' +
      'grant was revoked — send the user through consent again.'
  );
}
</code></pre>
<p>"Does not expire" is not the same as "lasts forever," and GitHub <a href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/token-expiration-and-revocation">revokes tokens</a> for several reasons worth knowing:</p>
<ul>
<li><p>The user revokes the authorization from their account settings.</p>
</li>
<li><p>The token goes unused for one year.</p>
</li>
<li><p>The token gets pushed to a public repository or gist, at which point GitHub revokes it automatically.</p>
</li>
<li><p>The app accumulates more than ten tokens for the same user and scope combination, and the oldest are revoked.</p>
</li>
</ul>
<p>The third one deserves a moment. GitHub scans public pushes for its own token formats and kills what it finds. That is a safety net, not a strategy, and the one thing it can't protect is a token in a private repository or a log file.</p>
<p><strong>GitHub Apps behave differently from OAuth Apps</strong>, which is a common source of confusion when reading GitHub's documentation. A GitHub App's user access token expires in eight hours and comes with a refresh token good for six months. If you build on GitHub Apps instead, the Slack-shaped refresh path below is the one you want.</p>
<h3 id="heading-slack-rotation-is-opt-in-and-permanent">Slack: Rotation is Opt-in and Permanent</h3>
<p>By default, a Slack user token doesn't expire either. <a href="https://docs.slack.dev/authentication/using-token-rotation">Token rotation</a> changes that, and it comes with a warning worth repeating: <strong>rotation can't be turned off once it's turned on.</strong> Enable it on a test app first.</p>
<p>With rotation on, tokens live twelve hours and arrive with a refresh token. The refresh call reuses the same endpoint as the initial exchange, with a different grant type:</p>
<pre><code class="language-javascript">async refresh(refreshToken) {
  const response = await fetch(this.tokenUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: process.env.SLACK_CLIENT_ID,
      client_secret: process.env.SLACK_CLIENT_SECRET,
    }),
  });

  const json = await response.json();
  if (!json.ok) {
    throw new Error(`Slack token refresh failed: ${json.error}`);
  }

  return normalizeSlackTokens(json.authed_user ?? json);
}
</code></pre>
<p><strong>Store the new refresh token, not just the new access token.</strong> Refresh tokens rotate too. Writing back only the access token leaves you holding a spent refresh token, and the failure arrives twelve hours later, which is a long time to wait to learn something.</p>
<p>That write-back is why <code>getAccessToken</code> calls <code>saveGrant</code> after refreshing rather than returning the token and moving on.</p>
<h3 id="heading-treat-a-dead-grant-as-a-normal-state">Treat a Dead Grant as a Normal State</h3>
<p>A revoked grant isn't an exception in the exceptional sense. Users leave, administrators tighten scopes, and people change their minds about what an agent may do.</p>
<p>The shape that works is the one <code>getAccessToken</code> already uses: catch the failure, and surface a fresh authorization link rather than a stack trace. <code>connect.js</code> with the same identifier lets the user re-consent, <code>ON CONFLICT</code> overwrites the dead row, and nothing else in your user record changes.</p>
<h2 id="heading-how-to-add-a-second-provider">How to Add a Second Provider</h2>
<p><strong>A second provider costs one OAuth app, one entry in the providers object, and one tool.</strong> Keeping identity in a single string is what buys the discount.</p>
<p>The agent has used two providers all along. Worth noticing is what the second one didn't require: no second identity, no second consent server, and no second token table.</p>
<pre><code class="language-javascript">export const providers = {
  slack: { /* ... */ },
  github: { /* ... */ },
};
</code></pre>
<p>Google Calendar as a third means a third entry with its own <code>authorizeUrl</code>, <code>tokenUrl</code>, scopes, and <code>exchangeCode</code>. The consent server loops over <code>Object.keys(providers)</code>, so it picks the new one up without modification. The store already keys on <code>(identifier, provider)</code>, so it needs no migration. Then one more tool:</p>
<pre><code class="language-javascript">const createCalendarEvent = tool({
  description: 'Create a calendar event',
  inputSchema: z.object({ summary: z.string(), start: z.string() }),
  execute: async ({ summary, start }) =&gt; {
    const token = await getAccessToken(identifier, 'google-calendar');
    // ...one more provider call
  },
});
</code></pre>
<p>The identifier doesn't change, your user table doesn't change, and the model's view of the world grows by exactly one tool.</p>
<p>The cost that doesn't scale down is the <strong>provider-specific knowledge.</strong> Each new provider brings its own scope vocabulary, its own error format, and its own answer to whether tokens expire. Slack and GitHub disagreed on all three, and a third will disagree differently. The registry pattern contains that knowledge in one object per provider rather than spreading it through your agent, but it doesn't make the knowledge unnecessary.</p>
<p><strong>One caveat on consent:</strong> A grant is per user, per provider. Alice connecting Slack but not Calendar means her calendar tool calls fail, and failure is correct there, since she never consented. Treat it as a prompt to connect rather than an error, a point the Failure Modes section returns to.</p>
<h2 id="heading-full-walkthrough">Full Walkthrough</h2>
<p>Clone the repository, install, and fill in <code>.env</code>:</p>
<pre><code class="language-bash">git clone https://github.com/saif-shines/channel-watcher-agent.git
cd channel-watcher-agent
npm install
cp .env.example .env
# fill in both client IDs and secrets, the encryption key, channel ID, repo
</code></pre>
<p>Then connect. The command starts the callback server and prints one link per unconnected provider:</p>
<pre><code class="language-bash">npm run connect
</code></pre>
<pre><code class="language-text">[slack] authorize as "channel-watcher-agent":
https://slack.com/oauth/v2/authorize?client_id=123.456&amp;user_scope=channels%3Ahistory%2Cchat%3Awrite%2Cusers%3Aread&amp;redirect_uri=https%3A%2F%2Flocalhost%3A3000%2Fcallback&amp;state=1159699dbf1a808fd33ba31c7b643505
[slack] connected.

[github] authorize as "channel-watcher-agent":
https://github.com/login/oauth/authorize?client_id=Iv1.abc&amp;scope=repo&amp;redirect_uri=https%3A%2F%2Flocalhost%3A3000%2Fcallback&amp;state=e6d13461099c391367266235f8313630
[github] connected.

All providers connected. Run: node src/index.js channel-watcher-agent
</code></pre>
<p>Open each link, consent, and the tab confirms. The state parameter in those URLs is checked on the way back. A callback carrying anything else gets a 400 and never reaches the token exchange.</p>
<p>Then run the agent against a channel holding ordinary chatter:</p>
<pre><code class="language-bash">node src/index.js
</code></pre>
<p>The output from that run, against a channel with three unremarkable messages:</p>
<pre><code class="language-text">[channel-watcher-agent] 3 messages fetched, 3 new.

--- Alex: "Sending draft message" ---
The message "Sending draft message" is noise — it appears to be a test or
accidental send, not a bug report or concrete action item.

...
</code></pre>
<p><strong>No tools called, and no issues filed.</strong> The negative case matters more than it looks. An agent with write access that can't say no is a liability, and a run over ordinary chatter is the cheapest available test of its restraint.</p>
<p>Now post an actual bug report in the channel:</p>
<blockquote>
<p>hey the /export endpoint is timing out for any file over 50MB, been happening since yesterday's deploy</p>
</blockquote>
<p>Run the agent again, and the state file keeps the earlier messages from being triaged twice.</p>
<p><strong>Authorship is the part that matters.</strong> The GitHub account behind the identifier opens the issue, using that user's own OAuth grant, not a shared bot. The Slack reply comes from that person too. Revoke their access and the next run fails at <code>getAccessToken</code>, which is the correct outcome.</p>
<h3 id="heading-what-changes-for-a-second-user">What Changes for a Second User</h3>
<p>The identifier comes from the command line, so isolation is testable without building a login first:</p>
<pre><code class="language-bash">node src/index.js                     # the identifier you already authorized
node src/index.js alice@example.com   # a different user entirely
</code></pre>
<p>The second command never reads the channel. It stops:</p>
<pre><code class="language-text">[slack] no grant for "alice@example.com".
Connect it first: node src/connect.js alice@example.com
</code></pre>
<p><strong>The refusal is the whole point.</strong> Nothing about the agent changed between the two commands. Same providers, tools, and code. Only the identifier differed, and Alice hasn't consented, so no row exists to decrypt and the run stops before touching Slack.</p>
<p>A shared-bot version behaves differently. The second command would read the channel and file an issue as the bot, because no per-user grant was ever involved.</p>
<p>Once Alice consents, everything downstream follows her grant. <code>readGrant</code> returns her row. <code>getAccessToken</code> decrypts her token. The Slack read returns the channels she can see, and her GitHub account authors the issue.</p>
<p>Production replaces <code>argv</code> with a session lookup:</p>
<pre><code class="language-javascript">const identifier = await lookupIdentifier(session.userId);
</code></pre>
<h3 id="heading-testing-the-isolation-without-credentials">Testing the Isolation Without Credentials</h3>
<p>The repository includes a test suite that replaces <code>fetch</code> with stand-in Slack and GitHub endpoints, so the request building, response parsing, storage, and refresh logic all run without a single OAuth app registered:</p>
<pre><code class="language-bash">npm test
</code></pre>
<p>Three of those tests are worth naming, because they check the claims this tutorial makes rather than the code's internals:</p>
<ul>
<li><p><strong>Slack exchange keeps the user token and discards the bot token.</strong> The fixture returns both. The test asserts the stored value is the <code>xoxp-</code> one.</p>
</li>
<li><p><strong>Two users get two different tokens from identical tool inputs.</strong> Same <code>text</code>, same <code>thread_ts</code>, two identifiers, two different <code>Authorization</code> headers reaching the provider.</p>
</li>
<li><p><strong>A tool built for an unconnected user fails instead of falling back.</strong> It also asserts that zero provider calls were attempted, since failing after leaking a request isn't much of a failure.</p>
</li>
</ul>
<p>Tests that pass on the first run are worth distrusting, so I checked these by breaking the code on purpose. Substituting the bot token for the user token, ignoring the identifier in <code>buildTools</code>, and exposing <code>identifier</code> in the model-visible schema each fail at least one test.</p>
<h2 id="heading-how-to-apply-the-pattern-to-other-use-cases">How to Apply the Pattern to Other Use Cases</h2>
<p><strong>Nothing in the pattern is specific to Slack triage.</strong> The shape is: read from one app, decide with a model, write to another app, all as one user.</p>
<p>Swapping the providers produces a different product:</p>
<table>
<thead>
<tr>
<th>Read from</th>
<th>Write to</th>
<th>Result</th>
</tr>
</thead>
<tbody><tr>
<td>Slack</td>
<td>GitHub</td>
<td>Triage channel chatter into issues, as in this tutorial</td>
</tr>
<tr>
<td>Gmail</td>
<td>Linear</td>
<td>Turn support email into tracked work</td>
</tr>
<tr>
<td>Google Calendar</td>
<td>Notion</td>
<td>Meeting prep notes, drafted before the meeting</td>
</tr>
<tr>
<td>Zendesk</td>
<td>Salesforce</td>
<td>Log support signals against the right account</td>
</tr>
<tr>
<td>GitHub</td>
<td>Slack</td>
<td>A digest of what changed, in the channel that cares</td>
</tr>
</tbody></table>
<p>Every row uses the same three pieces: a provider entry, a token lookup by identifier, and a tool. Only three things change: the OAuth app registrations, the API calls inside <code>execute</code>, and the input you write for the model.</p>
<p><strong>The input is where your product lives.</strong> OAuth is plumbing. Deciding which messages deserve an issue, and what the issue should say, is judgment, and judgment is the part worth your weeks.</p>
<p>The same code supports two deployment shapes:</p>
<ul>
<li><p><strong>Internal team agent:</strong> The identifier is the teammate who triggered the run. Runs on a schedule or a command.</p>
</li>
<li><p><strong>Customer-facing agent:</strong> The identifier comes from your tenant and user records. Runs on customer data, inside customer accounts.</p>
</li>
</ul>
<p>The code stays identical. The consequences of a wrong identifier do not.</p>
<h2 id="heading-what-went-wrong-when-i-built-this">What Went Wrong When I Built This</h2>
<p>These are problems I hit while building the project, in roughly the order they showed up. If you hit the same ones, the fix is usually small.</p>
<h3 id="heading-slack-wont-save-the-redirect-url">Slack Won't Save the Redirect URL</h3>
<p>The symptom arrives before any code runs: the Slack app configuration page refuses to accept <code>http://localhost:3000/callback</code>.</p>
<p>Slack requires HTTPS on redirect URLs with no exception for <code>localhost</code>. Issue a local certificate with <code>mkcert</code>, register the <code>https://</code> form, and point <code>TLS_CERT_PATH</code> and <code>TLS_KEY_PATH</code> at the files it wrote. GitHub accepts either scheme, so the same HTTPS URL works for both apps.</p>
<h3 id="heading-the-browser-warns-that-the-certificate-isnt-trusted">The Browser Warns That the Certificate Isn't Trusted</h3>
<p><code>mkcert -install</code> is the step that adds mkcert's local authority to your system trust store, and skipping it leaves a certificate no browser recognises.</p>
<p>Running it once fixes every certificate mkcert issues afterwards. A self-signed certificate made with <code>openssl</code> will always warn, since nothing trusts it.</p>
<h3 id="heading-the-redirect-uri-doesnt-match">The Redirect URI Doesn't Match</h3>
<p>Both providers compare the <code>redirect_uri</code> you send against the one registered with the app, and the comparison is exact. A trailing slash, <code>127.0.0.1</code> in place of <code>localhost</code>, <code>http</code> where you registered <code>https</code>, or a different port all fail.</p>
<p>The error arrives before consent, on the provider's own page, which at least makes it easy to spot. Keep <code>OAUTH_REDIRECT_URI</code> as the single source and pass it in both the authorize URL and the token exchange, as the provider registry does.</p>
<h3 id="heading-the-callback-port-is-already-in-use">The Callback Port is Already in Use</h3>
<p><code>connect.js</code> binds the port from <code>OAUTH_REDIRECT_URI</code>, and port 3000 is popular. An unhandled <code>EADDRINUSE</code> produces a stack trace that says nothing about OAuth, so the project catches it and says what to do instead.</p>
<p>Changing the port means changing it in three places: <code>.env</code>, the Slack app's redirect URLs, and the GitHub app's callback URL. Missing one produces the previous failure.</p>
<h3 id="heading-the-state-check-rejects-a-legitimate-callback">The State Check Rejects a Legitimate Callback</h3>
<p>State values live in memory and are deleted once used. Restarting <code>connect.js</code> after opening the link, or refreshing the callback tab, both produce a state that's no longer in the map.</p>
<p>Both are correct rejections. Generate a fresh link and start again.</p>
<h3 id="heading-tool-calls-return-permission-errors-or-empty-results">Tool Calls Return Permission Errors or Empty Results</h3>
<p>A missing scope or a revoked grant causes both.</p>
<p>GitHub answers <code>403</code> with "Resource not accessible" when the grant lacks <code>repo</code>. Slack answers <code>200</code> with <code>ok: false</code> and an error like <code>missing_scope</code>. Fix the scope list, then send the user through consent again, since an existing grant doesn't gain scopes retroactively.</p>
<p><strong>A partially-scoped grant fails at the point of use rather than at connection time</strong>, which is what makes the symptom look mysterious. The consent screen succeeded, the token stored fine, and the failure arrives during a tool call hours later.</p>
<h3 id="heading-the-agent-reads-channels-it-shouldnt">The Agent Reads Channels it Shouldn't</h3>
<p>The single most likely cause is storing <code>json.access_token</code> instead of <code>json.authed_user.access_token</code> during the Slack exchange. Both are strings, both are truthy, and both work (one works as the app rather than the person).</p>
<p>The tell is the scope of what comes back. A user token returns only that person's channels. If <code>conversations.history</code> returns a channel the current user was never in, a bot token is in the store.</p>
<h3 id="heading-a-tool-call-runs-as-the-wrong-user">A Tool Call Runs as the Wrong User</h3>
<p>Passing a token or identifier belonging to somebody else will do the wrong thing correctly.</p>
<p>Two habits prevent it. Resolve the identifier server-side after authenticating the caller, never from client input. Then take the identifier as a closure argument in <code>buildTools</code> and let each <code>execute</code> fetch its own token, so no code path can pass a stray credential.</p>
<h3 id="heading-refresh-works-once-and-then-stops">Refresh Works Once and Then Stops</h3>
<p>Refresh tokens rotate. A refresh that writes back the new access token but keeps the old refresh token succeeds immediately and fails on the following cycle, which puts twelve hours between the bug and its symptom.</p>
<p><code>saveGrant</code> takes the whole normalized token object for this reason. Write back everything the refresh returned.</p>
<h3 id="heading-the-agent-files-duplicate-issues">The Agent Files Duplicate Issues</h3>
<p>Two causes. A missing or unwritten state file makes every run triage everything again. Or <code>stopWhen</code> allows enough rounds for a confused model to retry a tool that already succeeded.</p>
<p>Check the state file first. Then check whether the tool's return value clearly signals success, because an ambiguous result invites a retry.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You've built an agent that reads a Slack channel, judges which messages describe real work, files GitHub issues for those, and closes the loop with a threaded reply. Every call ran as one specific user's own OAuth grant, through an OAuth flow and a token store you wrote yourself.</p>
<p>Five ideas carry over to any provider:</p>
<ul>
<li><p><strong>A tool is an API call plus an explanation for a model</strong>, and the explanation is most of the work.</p>
</li>
<li><p><strong>The identifier replaces the token in your agent code.</strong> Everything above one small function handles a reference to a user rather than a credential, so tokens never reach your model inputs or your logs.</p>
</li>
<li><p><strong>Connection time and runtime are separate flows.</strong> Consent happens once per user, per app. Runtime resolves an identifier and fetches a token late.</p>
</li>
<li><p><strong>Authorization stays yours.</strong> A token store answers which tokens belong to an identifier. Whether a caller may act as that identifier is a question only your code can answer.</p>
</li>
<li><p><strong>Multi-provider support is a registry problem, not an architecture problem</strong>, once identity lives in one string.</p>
</li>
</ul>
<p>The detail that carries the most weight is also the smallest: <code>authed_user.access_token</code> rather than <code>access_token</code>. One property access decides whether your agent respects the permissions your workspace already has or quietly routes around them.</p>
<p>From here, keep the shape and swap the providers. Point the read half at Gmail and the write half at Linear, then rewrite the input for the model. The identity plumbing doesn't change.</p>
<p>The full source is at <a href="https://github.com/saif-shines/channel-watcher-agent">github.com/saif-shines/channel-watcher-agent</a>.</p>
<p><em>This write-up reconstructs what we learned building</em> <a href="https://www.scalekit.com/"><em>Scalekit</em></a><em>, a hosted version of the token vault you just built.</em></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Fix the Dual-Write Problem in Node.js with the Outbox Pattern ]]>
                </title>
                <description>
                    <![CDATA[ Imagine you're building an e-commerce platform where placing an order needs to trigger several things at once: the warehouse has to be told to prepare the shipment, the email service has to send a con ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-fix-the-dual-write-problem-in-node-js-with-the-outbox-pattern/</link>
                <guid isPermaLink="false">6a736f87fcec1e65edd2a703</guid>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ design patterns ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AWS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Gabor Koos ]]>
                </dc:creator>
                <pubDate>Wed, 05 Aug 2026 17:14:47 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/bcec9aaf-d418-4e5a-b8aa-f3c75b35f482.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Imagine you're building an e-commerce platform where placing an order needs to trigger several things at once: the warehouse has to be told to prepare the shipment, the email service has to send a confirmation, and the fraud checker has to review the transaction.</p>
<p>The order service handles the checkout, saves the order to its database, and then publishes an <code>order.created</code> event to a message queue so every downstream system can react independently.</p>
<p>This is a common and reasonable design, but it has a reliability problem that's easy to miss until something goes wrong in production.</p>
<p>When a customer places an order and the payment goes through, the application needs to do two things: save the order to the database and publish the event to the queue. These are two separate writes to two separate systems, and there's no way to make them share a single atomic transaction. If the process crashes, the network hiccups, or a deployment rolls out between the two writes, one side commits and the other does not. The order sits confirmed on the customer's screen while the warehouse has no idea it exists.</p>
<p>The <a href="https://microservices.io/patterns/data/transactional-outbox.html">transactional outbox pattern</a> is the standard solution to this problem. In this article, we'll build it from scratch in Node.js, using PostgreSQL for the order service database, SQS for the queue, and DynamoDB as the fulfillment service's database. For local development, we'll use <a href="https://floci.io">floci</a>, a free open-source AWS emulator that runs all three with a single Docker container.</p>
<h2 id="heading-what-well-cover">What We'll Cover</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-problem-with-two-writes">The Problem with Two Writes</a></p>
</li>
<li><p><a href="#heading-the-outbox-pattern">The Outbox Pattern</a></p>
</li>
<li><p><a href="#heading-what-well-build">What We'll Build</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-database-schema">Database Schema</a></p>
</li>
<li><p><a href="#heading-the-request-handler">The Request Handler</a></p>
</li>
<li><p><a href="#heading-the-relay-worker">The Relay Worker</a></p>
</li>
<li><p><a href="#heading-the-consumer">The Consumer</a></p>
</li>
<li><p><a href="#heading-running-the-whole-thing">Running the Whole Thing</a></p>
</li>
<li><p><a href="#heading-going-to-production">Going to Production</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you should be comfortable with:</p>
<ul>
<li><p>Node.js and async/await</p>
</li>
<li><p>Database transactions (BEGIN, COMMIT, ROLLBACK)</p>
</li>
<li><p>The general concept of a message queue</p>
</li>
</ul>
<p>You don't need prior experience with AWS, SQS, or DynamoDB. We'll be running everything locally.</p>
<p>You will need Node.js 20 or later and Docker installed on your machine.</p>
<h2 id="heading-the-problem-with-two-writes">The Problem with Two Writes</h2>
<p>The order service scenario from the intro is one place this problem appears, but the same pattern comes up in many other contexts.</p>
<p>A user registers and the app inserts their account record, then sends a message to trigger the welcome email and the onboarding workflow. A file is uploaded and the API writes the metadata to the database, then publishes a message to kick off a processing worker for virus scanning or thumbnail generation. A payment webhook arrives, the handler records it in the database, then notifies downstream services that the payment is confirmed.</p>
<p>In every case, the application needs two writes to succeed together: one to the database and one to a queue or external system. If the second one is lost, the first one has no way of knowing.</p>
<p>If you want a deeper look at what database transactions actually guarantee and where they stop helping, see <a href="https://blog.gaborkoos.com/posts/2026-08-01-Beyond-Happy-Path-Engineering-Databases/">Beyond Happy Path Engineering: Databases</a>.</p>
<p>The naïve implementation looks straightforward:</p>
<pre><code class="language-js">await db.query('INSERT INTO orders (customer_id, amount_cents) VALUES ($1, $2)', [customerId, amountCents]);
await sqs.send(new SendMessageCommand({ QueueUrl: QUEUE_URL, MessageBody: JSON.stringify({ customerId, amountCents }) }));
</code></pre>
<p>The database write happens first, then the queue write. Under normal conditions this works fine. The problem is what happens when something goes wrong between the two.</p>
<p>If the process crashes, runs out of memory, or gets killed mid-deployment after the database write but before <code>sqs.send</code> is called, the order record exists in the database but no event is ever published. The warehouse, email service, and fraud checker never find out the order happened. From the customer's perspective the order went through. From every downstream system's perspective it doesn't exist.</p>
<p>The failure can also go the other way. If <code>sqs.send</code> succeeds but the database write is later rolled back due to a constraint violation or an error in a subsequent step, you've published an event for an order that doesn't actually exist. A consumer acting on that event may try to fulfill an order with no corresponding record, or charge a customer for something that was never saved.</p>
<p>There's also a timing window even when both writes eventually succeed. Between the database commit and the successful <code>sqs.send</code>, a consumer that queries the database after receiving the event may not find the order yet, depending on transaction isolation and replication lag. These are two separate systems with no shared transaction boundary, and no amount of careful sequencing fully closes the gap.</p>
<p>These aren't edge cases that only happen under extraordinary circumstances. Deploys restart processes mid-request. Out-of-memory kills happen without warning. Networks drop connections at any point. Any of these can interrupt the two-write sequence, and the result is a system that's silently inconsistent with no error logged and no alert fired.</p>
<p>A variation I've seen a few times that looks safer but is actually worse is wrapping both operations in a database transaction:</p>
<pre><code class="language-js">// PLEASE DO NOT EVER DO THIS
const client = await pool.connect();
await client.query('BEGIN');
await client.query('INSERT INTO orders (customer_id, amount_cents) VALUES ($1, $2)', [customerId, amountCents]);
await sqs.send(new SendMessageCommand({ QueueUrl: QUEUE_URL, MessageBody: JSON.stringify({ customerId, amountCents }) }));
await client.query('COMMIT');
</code></pre>
<p>The intent is to make the two writes feel like a unit, but a database transaction has no authority over SQS. The transaction can only roll back database operations. If <code>sqs.send</code> succeeds and then <code>COMMIT</code> fails, the message is already in the queue and can't be taken back. If the process crashes after <code>COMMIT</code> but before the function returns, the transaction committed and the message was sent, but the caller may retry, potentially inserting a duplicate order.</p>
<p>Beyond the correctness problems, this pattern holds an open database connection and any row locks for the entire duration of the SQS network call. SQS is normally fast, but under load, retries, or a degraded queue, that call can take seconds. Every other request trying to read or write the same rows has to wait. In a busy application, this is a reliable way to exhaust the connection pool and bring down unrelated parts of the service.</p>
<h2 id="heading-the-outbox-pattern">The Outbox Pattern</h2>
<p>The core idea is to stop treating the queue publish as a second write that happens after the database write, and instead make it part of the same database transaction.</p>
<p>Rather than calling <code>sqs.send</code> directly, the application inserts a row into an <code>outbox</code> table in the same transaction as the business record. A separate relay process reads the outbox table and publishes the messages to SQS. On the other end, a consumer receives the messages and writes to its own data store. In our case that is a fulfillment service writing to DynamoDB, completely separate from the order service's PostgreSQL database.</p>
<p>If the transaction rolls back for any reason, the outbox row disappears with it. There's no orphaned message in the queue because the message was never sent. If the application crashes after committing but before the relay runs, the outbox row is still there with <code>status='pending'</code>, and the relay will pick it up on its next iteration.</p>
<p>The only guarantee the pattern relies on is the one the database already provides: atomicity within a single transaction.</p>
<p>The relay worker is responsible for the eventual delivery guarantee. It runs on an interval, selects pending rows, publishes them to SQS, and marks them as sent only after SQS confirms receipt. If the relay crashes mid-run, it will reprocess the same rows on the next iteration, which means SQS may receive some messages more than once.</p>
<p>That's why the consumer needs to be <strong>idempotent</strong>: it must handle receiving the same message twice without creating duplicate fulfillment records. We'll cover how to implement that when we build the consumer.</p>
<p>This separation of concerns is what makes the pattern practical. The request handler commits one atomic database transaction and returns. The relay handles the network call to SQS asynchronously, at its own pace, with its own retry logic, without holding database connections open or blocking request handling. The consumer is fully decoupled from the order service and owns its own data store.</p>
<p>The diagram below illustrates the flow:</p>
<img src="https://cdn.hashnode.com/uploads/covers/68b08746916c71e1ed2db58e/ab0620f0-65c6-43f1-a406-00bfd4880cdc.svg" alt="Diagram: outbox pattern flow" style="display: block;" width="960" height="640" loading="lazy">

<h2 id="heading-what-well-build">What We'll Build</h2>
<p>Now let's see the whole thing in practice. We'll implement a simple order placement API. When a customer sends a request to place an order, the order service saves it to PostgreSQL and inserts a row into the outbox table, all in one atomic transaction. A relay worker wakes up periodically, reads the pending outbox rows, and publishes each one as a message to SQS. A separate fulfillment service receives those messages from the queue and creates fulfillment records in DynamoDB.</p>
<p>By the end, you'll have an HTTP endpoint you can call, and you'll be able to verify that placing an order triggers the creation of a fulfillment record in a completely separate database, owned by a completely separate service, without either service ever talking to the other directly.</p>
<p>You can find the complete working code at <a href="https://github.com/gkoos/article-outbox">github.com/gkoos/article-outbox</a>.</p>
<h2 id="heading-project-setup">Project Setup</h2>
<p>Before you can run any code, you need to get floci running so you have local instances of PostgreSQL, SQS, and DynamoDB. You'll also need Node.js 20 or later and Docker installed.</p>
<p>Start by cloning the repository and installing dependencies:</p>
<pre><code class="language-bash">git clone https://github.com/gkoos/article-outbox
cd article-outbox
npm install
</code></pre>
<p>Next, start floci. This command pulls the latest floci image and starts a Docker container that exposes a local AWS API endpoint (make sure Docker is running):</p>
<pre><code class="language-bash">npm run floci:start
</code></pre>
<p>On Linux and macOS, this just works. On Windows with Docker Desktop, <strong>you need to edit the</strong> <code>floci:start</code> <strong>script in your</strong> <code>package.json</code> <strong>to change the Docker socket mount from</strong> <code>/var/run/docker.sock</code> <strong>to</strong> <code>//var/run/docker.sock</code>.</p>
<p>The floci container is now listening on port 4566 and can spin up RDS (PostgreSQL), SQS, and DynamoDB instances on demand.</p>
<p>Now provision the AWS resources with a single setup command:</p>
<pre><code class="language-bash">npm run setup
</code></pre>
<p>This script creates an RDS PostgreSQL database instance, an SQS queue named <code>orders</code>, and a DynamoDB table named <code>fulfillments</code>. It waits for RDS to become available and then writes a <code>.env</code> file with the correct connection details. The environment variables <code>PG_PORT</code>, <code>SQS_QUEUE_URL</code>, and <code>DYNAMODB_TABLE_NAME</code> now point to the local emulated services.</p>
<p>Finally, create the PostgreSQL tables:</p>
<pre><code class="language-bash">npm run migrate
</code></pre>
<p>This creates the <code>orders</code> table and the <code>outbox</code> table in PostgreSQL. You now have a fully functional local environment ready to build against.</p>
<h2 id="heading-database-schema">Database Schema</h2>
<p>The two tables are simple. <code>orders</code> holds the business records: each order has a customer ID, an amount in cents, and a timestamp. The <code>outbox</code> table is the heart of the pattern: it's where the application writes the event that needs to be published.</p>
<pre><code class="language-sql">CREATE TABLE orders (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  customer_id TEXT NOT NULL,
  amount_cents INTEGER NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE outbox (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  event_type TEXT NOT NULL,
  payload JSONB NOT NULL,
  status TEXT NOT NULL DEFAULT 'pending',
  created_at TIMESTAMPTZ DEFAULT now(),
  sent_at TIMESTAMPTZ
);

CREATE INDEX ON outbox (status, created_at) WHERE status = 'pending';
</code></pre>
<p>The <code>orders</code> table needs nothing special. The <code>outbox</code> table stores the event metadata: what type of event it is (<code>event_type</code>), what data it contains (<code>payload</code> as JSON), and whether it has been sent yet (<code>status</code>).</p>
<p>The status starts as <code>pending</code>. When the relay publishes it to SQS, it will mark it as <code>sent</code> and record the timestamp. The index on <code>(status, created_at) WHERE status = 'pending'</code> lets the relay quickly find the next batch of unsent events without scanning the entire table.</p>
<h2 id="heading-the-request-handler">The Request Handler</h2>
<p>This is where the pattern starts. The request handler receives an HTTP POST, inserts an order into the database, inserts a corresponding row into the outbox table, and commits everything in a single atomic transaction. The key insight is that neither write succeeds unless both succeed.</p>
<pre><code class="language-js">const client = await pool.connect();
try {
  await client.query('BEGIN');

  // Insert the order record
  const { rows } = await client.query(
    'INSERT INTO orders (customer_id, amount_cents) VALUES ($1, $2) RETURNING *',
    [customerId, amountCents]
  );
  const order = rows[0];

  // Insert the outbox record in the same transaction
  await client.query(
    `INSERT INTO outbox (event_type, payload)
     VALUES ($1, $2)`,
    ['order.created', JSON.stringify({ orderId: order.id, customerId: order.customer_id, amountCents: order.amount_cents, createdAt: order.created_at })],
  );

  await client.query('COMMIT');
  res.status(201).json(order);
} catch (err) {
  await client.query('ROLLBACK');
  next(err);
} finally {
  client.release();
}
</code></pre>
<p>The handler gets <code>customerId</code> and <code>amountCents</code> from the request body, starts an explicit transaction with <code>BEGIN</code>, and inserts the order. Then it inserts an outbox row with the order data as the payload.</p>
<p>Everything commits atomically. If anything fails, everything rolls back and the client gets an error. If the process crashes between the commit and the response, the client won't get a 201, but the order and the outbox row are still safely committed to the database and the relay will eventually pick it up. The handler doesn't call SQS at all. That is the relay's job.</p>
<h2 id="heading-the-relay-worker">The Relay Worker</h2>
<p>The relay worker is a separate process that polls the outbox table every second and publishes pending rows to SQS. It runs independently of the HTTP server and has no shared state with it.</p>
<pre><code class="language-js">async function relay() {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');

    const { rows } = await client.query(`
      SELECT *
      FROM outbox
      WHERE status = 'pending'
      ORDER BY created_at
      LIMIT 10
      FOR UPDATE SKIP LOCKED -- prevents multiple relays from processing the same rows
    `);

    for (const row of rows) {
      await sqsClient.send(new SendMessageCommand({
        QueueUrl: QUEUE_URL,
        MessageBody: JSON.stringify(row.payload),
        MessageAttributes: {
          EventType: { DataType: 'String', StringValue: row.event_type },
        },
      }));

      await client.query(
        `UPDATE outbox SET status = 'sent', sent_at = now() WHERE id = $1`,
        [row.id],
      );
    }

    await client.query('COMMIT');
  } catch (err) {
    await client.query('ROLLBACK');
    console.error('Relay error:', err.message);
  } finally {
    client.release();
  }
}

setInterval(relay, 1000);
</code></pre>
<p><code>FOR UPDATE SKIP LOCKED</code> is the key to running multiple relay instances safely: when a relay picks up a batch of rows, it locks them. Any other relay instance trying to select the same rows will skip them and move to the next available ones, so you never get two relays publishing the same message from the same run.</p>
<p>The relay marks each row as <code>sent</code> only after <code>sqsClient.send</code> returns. If the relay crashes after sending to SQS but before updating the row, the row stays <code>pending</code> and the relay will resend it on the next iteration.</p>
<p>Note that the <code>UPDATE</code> happens inside the same transaction as the <code>SELECT FOR UPDATE</code>, so if the relay crashes mid-batch, the entire batch rolls back and all rows in it will be retried, including any that were already successfully sent to SQS.</p>
<p>The at-least-once delivery guarantee applies at the batch level, not the individual row level. You can read about this problem in <a href="https://blog.gaborkoos.com/posts/2026-07-01-Beyond-Happy-Path-Engineering-the-Network/">Beyond Happy Path Engineering: the Network</a>: when a response is lost, the caller can't know whether the operation succeeded, so it retries, and the receiver may see the same request twice. This means the consumer may see the same message more than once, which is why idempotency matters on the consumer side.</p>
<h2 id="heading-the-consumer">The Consumer</h2>
<p>The consumer is a completely separate service. It knows nothing about the order service's PostgreSQL database. Its only input is the SQS queue, and its only output is the DynamoDB <code>fulfillments</code> table. This is the point of the pattern: the two services are decoupled by the queue, and each owns its own data store.</p>
<p>As we saw earlier, because SQS delivers at least once (meaning a message might be delivered more than once), the consumer must be idempotent. The <code>PutItem</code> call uses a <code>ConditionExpression</code> that makes the write a no-op if a fulfillment record for that order already exists, so redelivered messages are handled safely.</p>
<pre><code class="language-js">async function consume() {
  const { Messages } = await sqsClient.send(new ReceiveMessageCommand({
    QueueUrl:              QUEUE_URL,
    WaitTimeSeconds:       20,   // long-poll: wait up to 20s for messages
    MaxNumberOfMessages:   10,
    MessageAttributeNames: ['All'],
  }));

  for (const msg of Messages ?? []) {
    const event = JSON.parse(msg.Body);

    try {
      await dynamoClient.send(new PutItemCommand({
        TableName: 'fulfillments',
        Item: {
          orderId:     { S: event.orderId },
          customerId:  { S: event.customerId },
          amountCents: { N: String(event.amountCents) },
          status:      { S: 'received' },
          createdAt:   { S: new Date().toISOString() },
        },
        ConditionExpression: 'attribute_not_exists(orderId)', // idempotency check
      }));
    } catch (err) {
      if (err.name !== 'ConditionalCheckFailedException') throw err;
      // already processed, safe to continue
    }

    // delete the message only after the write succeeds (or was already done)
    await sqsClient.send(new DeleteMessageCommand({
      QueueUrl:      QUEUE_URL,
      ReceiptHandle: msg.ReceiptHandle,
    }));
  }
}
</code></pre>
<p><code>ConditionExpression: 'attribute_not_exists(orderId)'</code> tells DynamoDB to reject the write if a record with that <code>orderId</code> already exists. When that happens, DynamoDB throws a <code>ConditionalCheckFailedException</code>. The consumer catches that specific error and ignores it, then deletes the message from the queue and moves on. Any other error is rethrown and the message stays in the queue to be retried.</p>
<p>The <code>DeleteMessage</code> call happens after the DynamoDB write, not before. If the process crashes between the write and the delete, SQS will redeliver the message and the condition check will handle it. If the process crashes before the write, the message stays in the queue and will be processed normally on the next delivery.</p>
<h2 id="heading-running-the-whole-thing">Running the Whole Thing</h2>
<p>With floci running and the resources provisioned, open three terminal tabs and start each process:</p>
<pre><code class="language-bash">node src/server.js    # the order API on port 3000
node src/relay.js     # the outbox relay
node src/consumer.js  # the fulfillment consumer
</code></pre>
<p>Now place an order:</p>
<pre><code class="language-bash">curl -X POST localhost:3000/orders \
  -H 'Content-Type: application/json' \
  -d '{"customerId":"c1","amountCents":4999}'
</code></pre>
<p>You should get back a 201 with the new order record:</p>
<pre><code class="language-bash">{"id":"1768d35b-083d-45f1-adb5-4063d8d7fcab","customer_id":"c1","amount_cents":4999,"created_at":"2026-07-30T20:27:10.628Z"}
</code></pre>
<p>Within a second the relay will pick up the outbox row and publish it to SQS. The consumer will receive the message and write a fulfillment record to DynamoDB. The repo includes a convenience script to verify this:</p>
<pre><code class="language-bash">npm run check
</code></pre>
<p>You should see a fulfillment record with the <code>orderId</code> from the order you just placed:</p>
<pre><code class="language-bash">{
  orderId: 'c335640e-bc4a-47e4-afed-484c95fbd6d3',
  customerId: 'c1',
  amountCents: '4999',
  status: 'received',
  createdAt: '2026-07-30T19:02:54.929Z'
}
</code></pre>
<h2 id="heading-going-to-production">Going to Production</h2>
<p>Because the local setup uses floci to emulate AWS, switching to real AWS requires no code changes at all. The AWS SDK reads the endpoint from <code>AWS_ENDPOINT_URL</code> in the environment. In production, you simply don't set that variable and the SDK talks to real AWS using the credentials and region from the standard environment variables (<code>AWS_REGION</code>, <code>AWS_ACCESS_KEY_ID</code>, <code>AWS_SECRET_ACCESS_KEY</code>, or an IAM role if you are running on EC2 or ECS).</p>
<p>Running multiple relay instances is safe out of the box because of <code>FOR UPDATE SKIP LOCKED</code>. You can scale the relay horizontally and each instance will pick up a different set of rows without duplicating messages.</p>
<p>One thing worth adding before going to production is handling permanent failures in the relay. Right now the relay only uses <code>pending</code> and <code>sent</code>. You should add a <code>failed</code> status and a retry counter: after a row has failed N times, mark it <code>failed</code> and stop retrying it. Then configure a dead-letter queue on the <code>orders</code> SQS queue as well, so that messages the consumer can't process after the maximum number of retries land somewhere you can inspect rather than disappearing silently.</p>
<p>For high-throughput systems where polling latency matters, <a href="https://en.wikipedia.org/wiki/Change_data_capture">change data capture</a> (CDC) is a common alternative to the polling relay. Tools like <a href="https://debezium.io/">Debezium</a> read directly from the PostgreSQL write-ahead log and publish changes to <a href="https://kafka.apache.org/">Kafka</a> or SQS without any polling delay. The outbox table and the consumer stay exactly the same, only the relay is replaced.</p>
<p>This is a bigger operational commitment than a polling worker, so polling is the right starting point for most systems.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The dual-write problem is easy to overlook because the naïve implementation works correctly most of the time. It only fails in the gaps between two separate system writes, and those gaps only become visible when something goes wrong at exactly the wrong moment. By the time you notice it in production, data is already inconsistent and there is no clean way to recover.</p>
<p>The transactional outbox pattern closes that gap at the database level. The outbox row is part of the same atomic commit as the business record, so the two are always in sync. The relay handles the network call to SQS independently, with its own retry logic, without touching the request lifecycle. The consumer handles at-least-once delivery with a single condition check on the write.</p>
<p>Each piece is simple on its own, and together they give you reliable, decoupled event delivery without distributed transactions.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an AI Agent with Function Calling in Node.js Using Google Gemini ]]>
                </title>
                <description>
                    <![CDATA[ Last year, a client asked me to add a conversational interface to their internal reporting tool. Staff would type a question, and the system would pull a live answer from the database. I had the first ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-ai-agent-function-calling-nodejs-gemini/</link>
                <guid isPermaLink="false">6a63af5c9a1ab0289b0cbb6a</guid>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Tutorial ]]>
                    </category>
                
                    <category>
                        <![CDATA[ gemini ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Zia Ullah ]]>
                </dc:creator>
                <pubDate>Fri, 24 Jul 2026 18:30:52 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/ef2a5058-558c-4951-abff-4d51f1c5cd15.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Last year, a client asked me to add a conversational interface to their internal reporting tool. Staff would type a question, and the system would pull a live answer from the database.</p>
<p>I had the first version running in a day. Single questions worked. But a week in, a tester typed: "What is the weather in Berlin, and how much would 500 EUR convert to in USD right now?"</p>
<p>The model called the weather function, returned that answer, and ignored the second half of the question entirely.</p>
<p>That is the gap between a chatbot and an agent. A chatbot works from training data. That's its limit. An agent doesn't have that limit. It calls a tool, reads what came back, and decides whether to keep going.</p>
<p>Most questions resolve in one or two tool calls. Multi-step ones take a few more. Remove that loop, and they all break.</p>
<p>This tutorial shows you how to build that loop with Google Gemini's function calling API and Node.js. You'll build an agent that can call real external tools: Open-Meteo for live weather, frankfurter.app for live currency rates, and a math evaluator for calculations. All three are completely free. The only API key you need is Gemini, which is also free on Google AI Studio at 1,500 requests per day.</p>
<p>Everything is on GitHub: <a href="https://github.com/ziaongit/nodejs-gemini-agent">github.com/ziaongit/nodejs-gemini-agent</a>.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-how-function-calling-works">How Function Calling Works</a></p>
</li>
<li><p><a href="#heading-what-were-building">What We're Building</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-defining-the-tools">Defining the Tools</a></p>
</li>
<li><p><a href="#heading-implementing-the-tool-functions">Implementing the Tool Functions</a></p>
</li>
<li><p><a href="#heading-building-the-agentic-loop">Building the Agentic Loop</a></p>
</li>
<li><p><a href="#heading-the-cli-entry-point">The CLI Entry Point</a></p>
</li>
<li><p><a href="#heading-adding-an-express-http-server">Adding an Express HTTP Server</a></p>
</li>
<li><p><a href="#heading-testing-the-agent">Testing the Agent</a></p>
</li>
<li><p><a href="#heading-troubleshooting">Troubleshooting</a></p>
</li>
<li><p><a href="#heading-what-to-build-next">What to Build Next</a></p>
</li>
</ul>
<h2 id="heading-how-function-calling-works">How Function Calling Works</h2>
<p>Most LLM tutorials show function calling as: define a function, the model calls it, done. That framing skips the part that actually matters.</p>
<p>The model doesn't call your function. It can't. What happens is more like a negotiation.</p>
<p>You send the model a message along with a list of tool descriptions. Each description is a JSON schema: the function name, what it does, and what arguments it needs. Gemini reads those at request time to decide which tool, if any, fits what the user asked.</p>
<p>Here's the part that surprises people. Gemini doesn't run your code. It sends back a structured object that says: call <code>get_weather</code>, <code>city = Berlin</code>. Your code picks that up, runs the actual function, and sends the result back. Gemini checks whether that's enough to answer. If not, it requests another tool.</p>
<p>That exchange is the loop:</p>
<pre><code class="language-plaintext">User message
      │
      ▼
Model + tool schemas
      │
      ▼
Response: functionCall?
      │
   YES │                          NO
      ▼                            ▼
Run the function(s)         Return text answer
      │
      ▼
Send result(s) back to model
      │
      └──── loop back ────────────┘
</code></pre>
<p>The loop keeps running until the model decides it has enough to answer. That's what allows it to chain calls: check the weather, see the temperature is above 25°C, then decide it should also fetch the exchange rate before answering.</p>
<p>There's one detail that trips people up the first time. When Gemini requests multiple tools in the same response, you run all of them and return all results in a single message. Returning them one at a time in separate messages breaks the model's turn-tracking and produces unreliable output.</p>
<h2 id="heading-what-were-building">What We're Building</h2>
<p>In this tutorial, we'll build an AI agent with three working tools:</p>
<ul>
<li><p><code>get_weather</code> — fetches current weather for any city via Open-Meteo (free, no API key)</p>
</li>
<li><p><code>calculate</code> — evaluates a math expression safely in JavaScript</p>
</li>
<li><p><code>get_exchange_rate</code> — fetches live currency rates via frankfurter.app (free, no API key)</p>
</li>
</ul>
<p>There are two ways to run it: a readline CLI for quick local testing, and an Express HTTP endpoint to wire into a real application.</p>
<p>Full tech stack:</p>
<ul>
<li><p><strong>Node.js 20</strong>: runtime (Node 18 minimum for native fetch)</p>
</li>
<li><p><strong>@google/generative-ai</strong>: official Gemini SDK</p>
</li>
<li><p><strong>dotenv</strong>: environment variable loading</p>
</li>
<li><p><strong>Express</strong>: HTTP server for the API endpoint</p>
</li>
<li><p><strong>Open-Meteo API</strong>: free weather and geocoding, no key required</p>
</li>
<li><p><strong>frankfurter.app</strong>: free currency exchange rates, no key required</p>
</li>
</ul>
<p>Architecture:</p>
<pre><code class="language-plaintext">┌─────────────────────────────────────────────────┐
│                   Client                         │
│         CLI (readline) / HTTP POST               │
└──────────────────────┬──────────────────────────┘
                       │  user message
                       ▼
┌─────────────────────────────────────────────────┐
│               agent.js — Agentic Loop            │
│                                                  │
│  1. Send message + tool schemas to Gemini        │
│  2. Receive response                             │
│  3. functionCalls() present?                     │
│      YES → execute tools in parallel             │
│            send all results back                 │
│            go to step 2                          │
│      NO  → return final text answer              │
└──────────────────────┬──────────────────────────┘
                       │  tool calls
                       ▼
┌─────────────────────────────────────────────────┐
│                  Tool Handlers                   │
│                                                  │
│  get_weather(city)                               │
│    └─► geocoding-api.open-meteo.com              │
│        api.open-meteo.com                        │
│                                                  │
│  calculate(expression)                           │
│    └─► JS safe evaluator (no external call)      │
│                                                  │
│  get_exchange_rate(from, to, amount?)            │
│    └─► api.frankfurter.app                       │
└─────────────────────────────────────────────────┘
</code></pre>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you start, you should have:</p>
<ul>
<li><p>Node.js 18 or higher — run <code>node --version</code> to check</p>
</li>
<li><p>A Gemini API key from <a href="https://aistudio.google.com">aistudio.google.com</a> — free, no card. The free tier gives you 1,500 requests a day.</p>
</li>
<li><p>You should also know how <code>async/await</code> works in Node.js. That's about it.</p>
</li>
</ul>
<h2 id="heading-project-setup">Project Setup</h2>
<pre><code class="language-bash">mkdir nodejs-gemini-agent &amp;&amp; cd nodejs-gemini-agent
npm init -y
npm install @google/generative-ai dotenv express
mkdir src
</code></pre>
<p>Add a <code>.gitignore</code>, as you don't want <code>.env</code> in your repo:</p>
<pre><code class="language-plaintext">node_modules/
.env
</code></pre>
<p>Drop a <code>.env</code> at the root:</p>
<pre><code class="language-plaintext">GEMINI_API_KEY=your_api_key_here
PORT=3000

# Optional: override the default model (gemini-2.0-flash)
# Uncomment if you hit free-tier quota limits
# GEMINI_MODEL=gemini-2.0-flash-lite
</code></pre>
<p>Project structure:</p>
<pre><code class="language-plaintext">nodejs-gemini-agent/
├── src/
│   ├── tools.js        ← tool schemas for Gemini
│   ├── functions.js    ← actual implementations
│   ├── agent.js        ← the agentic loop
│   ├── index.js        ← CLI entry point
│   └── server.js       ← Express HTTP server
├── .env
├── .env.example
├── .gitignore
└── package.json
</code></pre>
<h2 id="heading-defining-the-tools">Defining the Tools</h2>
<p>Gemini can't see your code. It picks tools based entirely on the JSON schemas you pass in. Each schema has a name, a description, and the parameter definitions.</p>
<p>The description is what drives routing. Gemini reads it at request time to decide which tool fits the user's question. Focus on when to call the function, not just what it does.</p>
<pre><code class="language-js">// src/tools.js

const toolDefinitions = [
  {
    name: 'get_weather',
    description:
      'Get the current weather for a city. Returns temperature in Celsius, ' +
      'humidity percentage, and wind speed. Use this when the user asks about ' +
      'weather, temperature, or climate conditions in any location.',
    parameters: {
      type: 'OBJECT',
      properties: {
        city: {
          type: 'STRING',
          description: 'The city name, e.g. Tokyo, London, New York',
        },
      },
      required: ['city'],
    },
  },
  {
    name: 'calculate',
    description:
      'Evaluate a mathematical expression and return the numeric result. ' +
      'Use this for arithmetic, percentage calculations, or any numeric computation ' +
      'the user asks for. Do not guess at math — always call this tool.',
    parameters: {
      type: 'OBJECT',
      properties: {
        expression: {
          type: 'STRING',
          description:
            'A valid mathematical expression, e.g. "47.50 * 0.18" or "1500 / 12"',
        },
      },
      required: ['expression'],
    },
  },
  {
    name: 'get_exchange_rate',
    description:
      'Get the current exchange rate between two currencies. Can also convert ' +
      'a specific amount. Use this when the user asks about currency conversion, ' +
      'exchange rates, or how much a foreign currency amount is worth.',
    parameters: {
      type: 'OBJECT',
      properties: {
        from: {
          type: 'STRING',
          description: 'The source currency code, e.g. USD, EUR, JPY, GBP',
        },
        to: {
          type: 'STRING',
          description: 'The target currency code, e.g. USD, EUR, JPY, GBP',
        },
        amount: {
          type: 'NUMBER',
          description:
            'Amount to convert. Optional — defaults to 1 if not provided.',
        },
      },
      required: ['from', 'to'],
    },
  },
];

module.exports = { toolDefinitions };
</code></pre>
<p>Vague descriptions work most of the time. The problems show up at the edges. "Does currency stuff" routes fine on a simple question. It falls apart on anything ambiguous. "Get the current exchange rate between two currencies. Use this when the user asks about currency conversion." holds up. The extra words cost basically nothing. Debugging bad routing costs a lot more.</p>
<h2 id="heading-implementing-the-tool-functions">Implementing the Tool Functions</h2>
<p>These are the actual functions that run when the model requests them. Each receives the arguments the model decided to pass, does real work, and returns a plain JavaScript object.</p>
<pre><code class="language-js">// src/functions.js

async function get_weather({ city }) {
  // Open-Meteo uses a two-step approach: geocode the city first, then fetch weather.
  // Both APIs are free with no key required.
  const geoUrl = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&amp;count=1`;
  const geoRes  = await fetch(geoUrl);
  const geoData = await geoRes.json();

  if (!geoData.results?.length) {
    return { error: `City not found: ${city}` };
  }

  const { latitude, longitude, name, country } = geoData.results[0];

  const weatherUrl =
    `https://api.open-meteo.com/v1/forecast` +
    `?latitude=${latitude}&amp;longitude=${longitude}` +
    `&amp;current=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code`;

  const weatherRes  = await fetch(weatherUrl);
  const weatherData = await weatherRes.json();
  const current     = weatherData.current;

  return {
    city:        `${name}, ${country}`,
    temperature: `${current.temperature_2m}°C`,
    humidity:    `${current.relative_humidity_2m}%`,
    wind_speed:  `${current.wind_speed_10m} km/h`,
  };
}

function calculate({ expression }) {
  try {
    // Strip anything that is not a number or basic operator before eval.
    // This is not a complete sandbox — use a proper math parser like mathjs
    // in production if expressions come from untrusted users.
    const safe = expression.replace(/[^0-9+\-*/.() %]/g, '');
    if (!safe.trim()) return { error: 'Invalid or empty expression' };

    const result = Function('"use strict"; return (' + safe + ')')();
    return { expression, result };
  } catch {
    return { error: `Could not evaluate: ${expression}` };
  }
}

async function get_exchange_rate({ from, to, amount = 1 }) {
  const url  = `https://api.frankfurter.app/latest?from=${from.toUpperCase()}&amp;to=${to.toUpperCase()}`;
  const res  = await fetch(url);
  const data = await res.json();

  if (data.error) return { error: data.error };

  const rate      = data.rates[to.toUpperCase()];
  if (!rate) return { error: `No rate found for ${from} → ${to}` };

  const converted = parseFloat((amount * rate).toFixed(4));

  return { from: from.toUpperCase(), to: to.toUpperCase(), rate, amount, converted };
}

module.exports = { get_weather, calculate, get_exchange_rate };
</code></pre>
<p>There are a few things worth pointing out here.</p>
<p>Open-Meteo uses geocoding before the weather fetch. Passing latitude and longitude directly to the weather endpoint is more reliable than a city name string, and the geocoding API handles misspellings reasonably well. There are two fetch calls, but that's the trade-off for accuracy.</p>
<p>The <code>calculate</code> function strips everything except digits and operators before evaluation. It narrows the injection risk, but it's not a real sandbox. If you expose this over HTTP with anonymous users, use a proper math parser like <a href="https://mathjs.org">mathjs</a>.</p>
<p>Frankfurter converts currency codes to uppercase before the request. Users will type "usd" or "Usd" and both should work. The API is case-sensitive on its end.</p>
<h2 id="heading-building-the-agentic-loop">Building the Agentic Loop</h2>
<p>This is the file everything else supports. The loop itself is about 20 lines. The rest is logging and error handling.</p>
<pre><code class="language-js">// src/agent.js
const { GoogleGenerativeAI } = require('@google/generative-ai');
const { toolDefinitions }    = require('./tools');
const { get_weather, calculate, get_exchange_rate } = require('./functions');

const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);

// Map tool names to handler functions
const toolHandlers = { get_weather, calculate, get_exchange_rate };

async function runAgent(userMessage) {
  const model = genAI.getGenerativeModel({
    model: process.env.GEMINI_MODEL || 'gemini-2.0-flash',
    tools: [{ functionDeclarations: toolDefinitions }],
  });

  const chat = model.startChat();

  console.log(`\nUser: ${userMessage}`);
  console.log('---');

  let response = await chat.sendMessage(userMessage);
  let iterations = 0;
  const MAX_ITERATIONS = 10; // safety cap against infinite loops

  // Agentic loop
  while (iterations &lt; MAX_ITERATIONS) {
    iterations++;
    const calls = response.response.functionCalls();

    // No tool calls — model is done, return the answer
    if (!calls || calls.length === 0) break;

    // Run all requested tools, collect results
    const toolResults = await Promise.allSettled(
      calls.map(async (call) =&gt; {
        console.log(`Calling tool: ${call.name}(${JSON.stringify(call.args)})`);

        const handler = toolHandlers[call.name];

        if (!handler) {
          return {
            functionResponse: {
              name:     call.name,
              response: { error: `Unknown tool: ${call.name}` },
            },
          };
        }

        try {
          const result = await handler(call.args);
          console.log(`Tool result: ${JSON.stringify(result)}`);
          return {
            functionResponse: {
              name:     call.name,
              response: result,
            },
          };
        } catch (err) {
          return {
            functionResponse: {
              name:     call.name,
              response: { error: err.message },
            },
          };
        }
      })
    );

    // Extract values from allSettled (fulfilled only — errors already caught above)
    const parts = toolResults
      .filter(r =&gt; r.status === 'fulfilled')
      .map(r =&gt; r.value);

    // Send all results back to the model in one message
    response = await chat.sendMessage(parts);
  }

  return response.response.text();
}

module.exports = { runAgent };
</code></pre>
<p>The <code>MAX_ITERATIONS</code> cap isn't paranoia. A model can get into a loop if a tool keeps returning an error and the model keeps retrying. 10 iterations is more than enough for any real query. A complex multi-tool question typically resolves in two or three turns.</p>
<p><code>Promise.allSettled</code> runs all requested tools in parallel rather than sequentially. When the model requests both weather and exchange rate in the same response, they fetch simultaneously. Individual tool failures get caught inside the map rather than letting one failure abort the others.</p>
<p>The logging is intentional. When you're building and testing an agent, watching the tool calls happen in real time tells you whether the routing is working. Production code would route these to a structured logger instead of console output, but the information is the same.</p>
<h2 id="heading-the-cli-entry-point">The CLI Entry Point</h2>
<p><code>require('dotenv').config()</code> runs first so your API key loads before anything else. After that it's a standard readline loop: each line you type goes to <code>runAgent</code>, the response prints, and the loop waits for the next input.</p>
<pre><code class="language-js">// src/index.js
require('dotenv').config();
const readline     = require('readline');
const { runAgent } = require('./agent');

const rl = readline.createInterface({
  input:  process.stdin,
  output: process.stdout,
});

function ask(prompt) {
  return new Promise(resolve =&gt; rl.question(prompt, resolve));
}

async function main() {
  console.log('Gemini Agent — type your question, or "exit" to quit\n');

  while (true) {
    const input = await ask('You: ');
    if (input.toLowerCase() === 'exit') break;
    if (!input.trim()) continue;

    try {
      const answer = await runAgent(input);
      console.log(`\nAgent: ${answer}\n`);
    } catch (err) {
      console.error(`Error: ${err.message}`);
    }
  }

  rl.close();
}

main();
</code></pre>
<h2 id="heading-adding-an-express-http-server">Adding an Express HTTP Server</h2>
<p>The CLI is useful for testing. For integrating into an app, you need an HTTP endpoint.</p>
<pre><code class="language-js">// src/server.js
require('dotenv').config();
const express      = require('express');
const { runAgent } = require('./agent');

const app  = express();
const PORT = process.env.PORT || 3000;

app.use(express.json());

app.post('/agent', async (req, res) =&gt; {
  const { message } = req.body;

  if (!message || typeof message !== 'string') {
    return res.status(400).json({ error: 'message is required and must be a string' });
  }

  try {
    const answer = await runAgent(message);
    res.json({ answer });
  } catch (err) {
    console.error('[agent error]', err.message);
    res.status(500).json({ error: 'Agent failed to process the request' });
  }
});

app.listen(PORT, () =&gt; {
  console.log(`Agent server running on http://localhost:${PORT}`);
});
</code></pre>
<p>Start it:</p>
<pre><code class="language-bash">node src/server.js
</code></pre>
<p>Call it:</p>
<pre><code class="language-bash">curl -X POST http://localhost:3000/agent \
  -H "Content-Type: application/json" \
  -d '{"message": "What is the weather in Paris?"}'
</code></pre>
<p>Response:</p>
<pre><code class="language-json">{
  "answer": "The current weather in Paris, France is 19°C with 65% humidity and wind speeds of 12 km/h."
}
</code></pre>
<p>The POST body is a plain <code>{ message }</code> object. The response is a plain <code>{ answer }</code> string. The agent handles the rest.</p>
<h2 id="heading-testing-the-agent">Testing the Agent</h2>
<p>Start the CLI:</p>
<pre><code class="language-bash">node src/index.js
</code></pre>
<p>The <code>You:</code> prompt comes from the readline in <code>index.js</code>. The <code>User:</code> line and <code>---</code> separator are logged by <code>agent.js</code> at the start of each run. This is the same logging discussed in the agentic loop section.</p>
<p>Single tool — weather:</p>
<pre><code class="language-plaintext">You: What's the weather in Tokyo?

User: What's the weather in Tokyo?
---
Calling tool: get_weather({"city":"Tokyo"})
Tool result: {"city":"Tokyo, JP","temperature":"31°C","humidity":"72%","wind_speed":"8 km/h"}

Agent: The current weather in Tokyo, Japan is 31°C with 72% humidity and wind speeds of 8 km/h.
</code></pre>
<p>Two tools — chained reasoning:</p>
<pre><code class="language-plaintext">You: What is the weather in Tokyo? If it's above 20°C, convert 10000 JPY to EUR.

User: What is the weather in Tokyo? If it's above 20°C, convert 10000 JPY to EUR.
---
Calling tool: get_weather({"city":"Tokyo"})
Tool result: {"city":"Tokyo, JP","temperature":"31°C","humidity":"72%","wind_speed":"8 km/h"}

Calling tool: get_exchange_rate({"from":"JPY","to":"EUR","amount":10000})
Tool result: {"from":"JPY","to":"EUR","rate":0.006,"amount":10000,"converted":60.0}

Agent: The current temperature in Tokyo is 31°C, which is above 20°C.
Converting 10,000 JPY to EUR at the current exchange rate gives approximately 60.00 EUR.
</code></pre>
<p>Notice what happened: the model called <code>get_weather</code>, read the result (31°C), applied the conditional logic from the user's question on its own, and then called <code>get_exchange_rate</code>. You didn't write any of that branching logic. The model handled it from the description alone.</p>
<p>Calculator:</p>
<pre><code class="language-plaintext">You: How much is 18% tip on a $47.50 restaurant bill?

User: How much is 18% tip on a $47.50 restaurant bill?
---
Calling tool: calculate({"expression":"47.50 * 0.18"})
Tool result: {"expression":"47.50 * 0.18","result":8.55}

Agent: An 18% tip on a $47.50 bill is $8.55, making your total $56.05.
</code></pre>
<p>Three tools in one query:</p>
<pre><code class="language-plaintext">You: What's the weather in London and Berlin? And what is 250 GBP in EUR?

User: What's the weather in London and Berlin? And what is 250 GBP in EUR?
---
Calling tool: get_weather({"city":"London"})
Calling tool: get_weather({"city":"Berlin"})
Calling tool: get_exchange_rate({"from":"GBP","to":"EUR","amount":250})
Tool result: {"city":"London, GB","temperature":"16°C","humidity":"78%","wind_speed":"20 km/h"}
Tool result: {"city":"Berlin, DE","temperature":"22°C","humidity":"55%","wind_speed":"14 km/h"}
Tool result: {"from":"GBP","to":"EUR","rate":1.17,"amount":250,"converted":292.5}

Agent: London is currently 16°C with 78% humidity and 20 km/h winds.
Berlin is warmer at 22°C with 55% humidity and lighter winds of 14 km/h.
250 GBP converts to approximately 292.50 EUR at the current exchange rate.
</code></pre>
<p>All three tools ran in parallel. <code>Promise.allSettled</code> is why. A sequential loop would have made three serial network requests. Parallel gives you the same result in roughly the time of the slowest single request.</p>
<h2 id="heading-troubleshooting">Troubleshooting</h2>
<p>Here are a few common issues you might encounter, and how to fix them:</p>
<h3 id="heading-1-404-not-found-modelsgemini-15-flash-is-not-found-for-api-version-v1beta">1. <code>[404 Not Found] models/gemini-1.5-flash is not found for API version v1beta</code></h3>
<p>The model name is outdated. Google deprecates older aliases over time. Swap it out for <code>gemini-2.0-flash</code> in <code>agent.js</code>. To check what models your key can actually access, run:</p>
<pre><code class="language-bash">node -e "
const { GoogleGenerativeAI } = require('@google/generative-ai');
require('dotenv').config();
const g = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
g.listModels().then(r =&gt; r.models.forEach(m =&gt; console.log(m.name)));
"
</code></pre>
<h3 id="heading-2-429-too-many-requests-you-exceeded-your-current-quota">2. <code>[429 Too Many Requests] You exceeded your current quota</code></h3>
<p>The <code>gemini-2.0-flash</code> free tier caps you at 1,500 requests a day. Hit that and every call returns a 429 until midnight Pacific resets the counter.</p>
<p>The error names the quota ID directly. <code>GenerateRequestsPerDayPerProjectPerModel-FreeTier</code> means you hit the daily cap. <code>GenerateRequestsPerMinutePerProjectPerModel-FreeTier</code> means the per-minute rate.</p>
<p>For the per-minute limit, the error includes a <code>retryDelay</code> field. Wait that many seconds and retry. For the daily limit, the quota is per-project. All models under the same project share it.</p>
<p>There are three ways out:</p>
<ul>
<li><p><strong>New project</strong> (fastest): head to <a href="https://aistudio.google.com">aistudio.google.com</a>, spin up a new project, grab a new API key, and swap it into <code>.env</code>. You get a fresh quota immediately.</p>
</li>
<li><p><strong>Enable billing</strong>: billing-enabled projects get much higher limits while keeping the free usage tier. Set up at <a href="https://aistudio.google.com">aistudio.google.com</a>.</p>
</li>
<li><p><strong>Wait</strong>: resets daily at midnight Pacific.</p>
</li>
</ul>
<p>Because <code>agent.js</code> reads the model name from <code>process.env.GEMINI_MODEL</code>, you can also switch models without touching code. Add this to your <code>.env</code> to test with a lighter model:</p>
<pre><code class="language-plaintext">GEMINI_MODEL=gemini-2.0-flash-lite
</code></pre>
<p>Remove the line when your quota resets and the agent goes back to <code>gemini-2.0-flash</code>.</p>
<h3 id="heading-3-error-geminiapikey-is-not-set">3. <code>Error: GEMINI_API_KEY is not set</code></h3>
<p>Nine times out of ten, <code>require('dotenv').config()</code> is either missing or buried below other requires. Drag it to the very top of <code>index.js</code>. Your <code>.env</code> also needs to live at the project root with your actual key in it, not <code>your_api_key_here</code>.</p>
<h3 id="heading-4-googlegenerativeaierror-400-invalidargument">4. <code>GoogleGenerativeAIError: 400 INVALID_ARGUMENT</code></h3>
<p>Almost always a malformed tool schema. Gemini uses uppercase type strings: <code>'OBJECT'</code>, <code>'STRING'</code>, <code>'NUMBER'</code>. JSON Schema uses lowercase. Check your <code>parameters.type</code> values.</p>
<h3 id="heading-5-model-answers-without-calling-any-tools">5. Model Answers Without Calling Any Tools</h3>
<p>The description is too vague or the user's question doesn't match well enough for the model to route it. Add more context to the description about when the tool should be used. The phrase "use this when the user asks about X" directly improves routing accuracy.</p>
<h3 id="heading-6-typeerror-fetch-is-not-a-function">6. <code>TypeError: fetch is not a function</code></h3>
<p>Node 17 and below don't have native <code>fetch</code>. It was added in Node 18. Run <code>node --version</code> to check yours.</p>
<p>If you can't upgrade, install it with <code>npm install node-fetch</code>. Every file that calls <code>fetch</code> then needs <code>const fetch = require('node-fetch')</code> as its first line.</p>
<h3 id="heading-7-tool-works-in-isolation-but-agent-loop-doesnt-call-it">7. Tool Works in Isolation but Agent Loop Doesn't Call it</h3>
<p>The name in <code>toolDefinitions</code> must exactly match the key in <code>toolHandlers</code>. Case matters in JavaScript. <code>get_Weather</code> and <code>get_weather</code> are two different things.</p>
<h3 id="heading-8-exchange-rate-returns-no-rate-found">8. Exchange Rate Returns <code>No rate found</code></h3>
<p>The currency code you passed isn't supported by frankfurter.app. The API covers ~30 major currencies. Check supported codes at <a href="https://www.frankfurter.app/docs/">frankfurter.app</a>.</p>
<h2 id="heading-what-to-build-next">What to Build Next</h2>
<p>The three tools here are a foundation. The loop works the same way regardless of how many tools you add.</p>
<p><strong>Database lookup tool:</strong> A <code>search_products</code> function that queries your PostgreSQL table turns the agent into a product assistant. Point it at your catalog, and it can answer questions about availability, pricing, and specs without you writing any routing logic.</p>
<p><strong>Write tools:</strong> <code>get_*</code> functions make the agent read-only. Add a <code>create_ticket</code> or <code>send_notification</code> function and the agent can take actions: file a support request, trigger a workflow, update a record. Once you add write tools, think carefully about <strong>which queries should require confirmation before executing</strong>.</p>
<p><strong>Memory across sessions:</strong> Right now <code>model.startChat()</code> creates a fresh conversation on every call. Pass a <code>history</code> array when starting the chat and the model remembers prior turns. Store that history in PostgreSQL or Redis keyed to the user ID, and the agent carries context across sessions.</p>
<p><strong>Streaming responses:</strong> For a UI that shows the answer as it types rather than waiting for the full response, replace <code>chat.sendMessage</code> with <code>chat.sendMessageStream</code>. The tool call loop stays the same. Only the final response delivery changes.</p>
<p><strong>Swap the model:</strong> The <code>model</code> string in <code>getGenerativeModel</code> is the only thing that pins you to Gemini 2.0 Flash. <code>gemini-2.0-flash-lite</code> is lighter and faster for simpler queries. For stronger reasoning on complex tasks, run the <code>listModels</code> script from the Troubleshooting section to find the latest available models. The function calling interface is identical across all Gemini models, so swapping takes one line.</p>
<p>The full source code for this article is on GitHub at <a href="https://github.com/ziaongit/nodejs-gemini-agent">github.com/ziaongit/nodejs-gemini-agent</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Multi-Tenant SaaS API with Node.js, RBAC, and Audit Logging ]]>
                </title>
                <description>
                    <![CDATA[ A colleague asked me to help debug what looked like a permissions issue in their SaaS project management tool. Users were seeing resources they hadn't created. I pulled up the query logs expecting som ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-multi-tenant-saas-api-with-nodejs-rbac-and-audit-logging/</link>
                <guid isPermaLink="false">6a5e8914bc397f89a942b88b</guid>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                    <category>
                        <![CDATA[ PostgreSQL ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ backend ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Zia Ullah ]]>
                </dc:creator>
                <pubDate>Mon, 20 Jul 2026 20:46:12 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/69793fe0-fe0e-4c9c-839d-12a134f65287.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>A colleague asked me to help debug what looked like a permissions issue in their SaaS project management tool. Users were seeing resources they hadn't created.</p>
<p>I pulled up the query logs expecting something subtle. It was not. The list endpoint had no <code>tenant_id</code> filter at all. Every tenant in the database could read every other tenant's projects. The application never threw an error. It just returned whatever was there.</p>
<p>Missing tenant filters don't throw errors. They return the wrong data without any complaint, and nothing in your logs will flag it. I've seen this run in production for weeks before a support ticket pointed anyone at the query logs.</p>
<p>When it does surface, who finds it first matters a lot. A customer noticing it is bad. A compliance auditor noticing it during a SOC 2 review is a different kind of problem.</p>
<p>Isolation built in from the start is a day of work. The time I spent helping a team retrofit it after a compliance review was considerably longer than that, and involved more customer emails than anyone wanted to write.</p>
<p>The stack is Node.js with PostgreSQL. CRUD is the easy part. Tenant isolation, RBAC, and audit logging take more care, and where those checks run in the stack matters. I put all three in middleware, before any route handler fires. A handler that never calls the isolation logic directly can't accidentally skip it.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Node.js 18+</p>
</li>
<li><p>PostgreSQL 14+</p>
</li>
<li><p>Basic knowledge of Express.js and JWT</p>
</li>
</ul>
<h2 id="heading-what-we-will-build">What We Will Build</h2>
<p>A multi-tenant Express REST API that enforces:</p>
<ol>
<li><p><strong>Tenant isolation:</strong> every database query scopes to the <code>tenant_id</code> from the verified JWT. The client can't influence which tenant the query runs against.</p>
</li>
<li><p><strong>RBAC:</strong> four roles, each with a numeric level (SuperAdmin is highest, Viewer lowest). Middleware checks the level before the handler runs.</p>
</li>
<li><p><strong>Audit logging:</strong> any write or sensitive read appends a row to the audit table. The app can't modify those rows afterward. The database enforces this directly. If a bug in the app tries to UPDATE an audit row, the database refuses it. Application-level enforcement alone can't give you that guarantee.</p>
</li>
<li><p><strong>Per-tenant rate limiting:</strong> request counts in Redis, keyed to the tenant. I've seen IP-based limiting break an enterprise rollout when fifty users came through a single corporate proxy.</p>
</li>
<li><p><strong>Tenant isolation tests:</strong> a dedicated test file that proves cross-tenant data can't leak. Wire it into CI and it catches broken isolation before it ships.</p>
</li>
</ol>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-how-multi-tenancy-works">How Multi-Tenancy Works</a></p>
</li>
<li><p><a href="#heading-architecture-overview">Architecture Overview</a></p>
</li>
<li><p><a href="#heading-database-schema-design">Database Schema Design</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-jwt-design-for-multi-tenancy">JWT Design for Multi-Tenancy</a></p>
</li>
<li><p><a href="#heading-auth-and-rbac-middleware">Auth and RBAC Middleware</a></p>
</li>
<li><p><a href="#heading-the-tenant-safe-repository-layer">The Tenant-Safe Repository Layer</a></p>
</li>
<li><p><a href="#heading-audit-logging-service">Audit Logging Service</a></p>
</li>
<li><p><a href="#heading-per-tenant-rate-limiting">Per-Tenant Rate Limiting</a></p>
</li>
<li><p><a href="#heading-building-the-routes">Building the Routes</a></p>
</li>
<li><p><a href="#heading-testing-tenant-isolation">Testing Tenant Isolation</a></p>
</li>
<li><p><a href="#heading-troubleshooting">Troubleshooting</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ol>
<h2 id="heading-how-multi-tenancy-works">How Multi-Tenancy Works</h2>
<p>This tutorial uses a <strong>shared database with row-level isolation</strong>: a <code>tenant_id</code> column on every table, a filter on every query. The database holds everyone's data together. The application decides what each tenant can see.</p>
<p>Two other approaches exist: schema-per-tenant and database-per-tenant. I've talked to teams on schema-per-tenant who ended up spending more engineering time on migration tooling than on their actual product. Database-per-tenant gives stronger guarantees but a connection pool that balloons with every new customer signup.</p>
<p>Neither scales cheaply. Row-level isolation scales further than most teams expect. The ones I know who moved off it did so years in, usually under specific regulatory pressure, not because the approach stopped working.</p>
<p>The one thing in this design that can't be optional: <code>tenant_id</code> <strong>must always come from the verified JWT.</strong> Not from the request body, not from the URL. Users control what they put in both of those. They don't control what gets signed into a JWT on your server.</p>
<h2 id="heading-architecture-overview">Architecture Overview</h2>
<pre><code class="language-plaintext">HTTP Request
     │
     ▼
┌─────────────────────────────────────────┐
│           Express Middleware Stack       │
│                                         │
│  1. Rate Limiter (per tenant_id)        │
│  2. Auth Middleware (verify JWT)        │
│     └─► Extracts: userId, tenantId,    │
│          role, permissions              │
│  3. RBAC Middleware (check role)        │
└──────────────┬──────────────────────────┘
               │
               ▼
┌─────────────────────────────────────────┐
│           Route Handler                  │
│                                         │
│  1. Call Repository (tenant-safe query) │
│  2. Call Audit Service (fire &amp; forget)  │
│  3. Return response                     │
└──────────────┬──────────────────────────┘
               │
     ┌─────────┴──────────┐
     ▼                    ▼
┌─────────┐        ┌────────────┐
│ Projects│        │ Audit Logs │
│  Table  │        │   Table    │
│(+tenant)│        │(append only│
└─────────┘        └────────────┘
</code></pre>
<p>Rate limiting, auth, and RBAC all run before any handler sees the request. Writes pass through the audit service. The repository takes <code>tenantId</code> from <code>req.user</code> and the handler never touches tenant scoping directly, so there's no path around it.</p>
<h2 id="heading-database-schema-design">Database Schema Design</h2>
<pre><code class="language-sql">-- Tenants table
CREATE TABLE tenants (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name        VARCHAR(255) NOT NULL,
  plan        VARCHAR(50) NOT NULL DEFAULT 'free', -- 'free', 'pro', 'enterprise'
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Users table
CREATE TABLE users (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  email       VARCHAR(255) NOT NULL,
  role        VARCHAR(50) NOT NULL DEFAULT 'Member', -- 'SuperAdmin','TenantAdmin','Member','Viewer'
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE(tenant_id, email)
);

CREATE INDEX idx_users_tenant ON users(tenant_id);

-- Projects table (example resource — replace with your domain entity)
CREATE TABLE projects (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  name        VARCHAR(255) NOT NULL,
  description TEXT,
  created_by  UUID NOT NULL REFERENCES users(id),
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_projects_tenant ON projects(tenant_id);

-- Audit log table (append-only — never UPDATE or DELETE rows here)
CREATE TABLE audit_logs (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL,
  user_id     UUID NOT NULL,
  user_email  TEXT NOT NULL,
  user_role   TEXT NOT NULL,        -- role at time of action
  action      TEXT NOT NULL,        -- 'CREATE', 'UPDATE', 'DELETE', 'VIEW'
  resource    TEXT NOT NULL,        -- table name
  resource_id TEXT,
  old_values  JSONB,
  new_values  JSONB,
  ip_address  INET,
  user_agent  TEXT,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_audit_tenant ON audit_logs(tenant_id);
CREATE INDEX idx_audit_created ON audit_logs(created_at DESC);

-- Protect audit log at database level
-- Use a DO block so this runs safely in Docker where app_user is the superuser
DO $$
BEGIN
  IF current_user &lt;&gt; 'app_user' THEN
    REVOKE DELETE, UPDATE ON audit_logs FROM app_user;
  END IF;
END $$;
</code></pre>
<p>The <code>REVOKE</code> matters. Application bugs happen. If something in your codebase accidentally tries to UPDATE an audit row, you want the database to refuse it outright, not silently comply.</p>
<h2 id="heading-project-setup">Project Setup</h2>
<pre><code class="language-bash">mkdir nodejs-multitenant-saas-api
cd nodejs-multitenant-saas-api
npm init -y
npm install express pg jsonwebtoken bcryptjs express-rate-limit rate-limit-redis ioredis dotenv
npm install --save-dev jest supertest
</code></pre>
<h3 id="heading-starting-postgresql-and-redis-with-docker">Starting PostgreSQL and Redis with Docker</h3>
<p>Skip the local installs. One <code>docker-compose.yml</code> in the project root brings up both PostgreSQL and Redis:</p>
<pre><code class="language-yaml">services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: saas_api
      POSTGRES_USER: app_user
      POSTGRES_PASSWORD: app_password
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./schema.sql:/docker-entrypoint-initdb.d/01_schema.sql

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  postgres_data:
</code></pre>
<p>That <code>schema.sql</code> mount runs your SQL automatically when the container first starts. No psql required.</p>
<pre><code class="language-bash">docker compose up -d
</code></pre>
<p><code>.env</code> in the project root:</p>
<pre><code class="language-plaintext">DATABASE_URL=postgresql://app_user:app_password@localhost:5432/saas_api
REDIS_URL=redis://localhost:6379
JWT_SECRET=your_random_secret_here
PORT=3000
NODE_ENV=development
</code></pre>
<p>Don't type a <code>JWT_SECRET</code> by hand. Run this to generate one:</p>
<pre><code class="language-bash">node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
</code></pre>
<p>File structure:</p>
<pre><code class="language-plaintext">nodejs-multitenant-saas-api/
├── src/
│   ├── middleware/
│   │   ├── auth.js          # JWT verification + tenant extraction
│   │   ├── rbac.js          # Role enforcement
│   │   └── rateLimiter.js   # Per-tenant rate limiting
│   ├── services/
│   │   └── auditService.js  # Append-only audit logger
│   ├── repositories/
│   │   └── projectRepo.js   # Tenant-safe DB queries
│   ├── routes/
│   │   └── projects.js      # Route handlers
│   └── utils/
│       └── token.js         # JWT token generation
├── db/
│   ├── index.js             # PostgreSQL pool
│   └── redis.js             # Redis client
├── docker-compose.yml
├── app.js
├── server.js
└── tests/
    └── tenantIsolation.test.js
</code></pre>
<h3 id="heading-boilerplate-files">Boilerplate Files</h3>
<p>There are four files the tutorial doesn't cover in detail, but the test file needs all of them to run:</p>
<pre><code class="language-javascript">// db/index.js
const { Pool } = require('pg');

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

pool.on('error', (err) =&gt; console.error('PostgreSQL error:', err.message));

module.exports = { pool };
</code></pre>
<pre><code class="language-javascript">// db/redis.js
const Redis = require('ioredis');

const redisClient = new Redis(process.env.REDIS_URL);

redisClient.on('error', (err) =&gt; console.error('Redis error:', err.message));

module.exports = { redisClient };
</code></pre>
<pre><code class="language-javascript">// app.js
require('dotenv').config();
const express = require('express');
const projectsRouter = require('./src/routes/projects');

const app = express();
app.use(express.json());

app.use('/api/projects', projectsRouter);

// Global error handler — must have 4 parameters to be recognised by Express
app.use((err, req, res, next) =&gt; {
  console.error(err.stack);
  res.status(500).json({ error: 'Internal server error' });
});

module.exports = app;
</code></pre>
<pre><code class="language-javascript">// server.js
const app = require('./app');

const PORT = process.env.PORT || 3000;
app.listen(PORT, () =&gt; console.log(`Server running on port ${PORT}`));
</code></pre>
<p><code>bcryptjs</code> is included for a login endpoint with proper password hashing. That part isn't covered here, but the GitHub repo has a working <code>/api/auth/login</code> example.</p>
<h2 id="heading-jwt-design-for-multi-tenancy">JWT Design for Multi-Tenancy</h2>
<p>Both <code>tenantId</code> and <code>role</code> go into the JWT payload. Everything downstream reads from these two fields. Get them wrong, and nothing behaves correctly.</p>
<pre><code class="language-javascript">// Example JWT payload
{
  "userId": "usr_abc123",
  "tenantId": "ten_xyz789",
  "email": "alice@acme.com",
  "role": "TenantAdmin",
  "iat": 1720000000,
  "exp": 1720086400
}
</code></pre>
<p>The roles in order of privilege:</p>
<ul>
<li><p><strong>SuperAdmin:</strong> cross-tenant access for your internal team only</p>
</li>
<li><p><strong>TenantAdmin:</strong> full access within their tenant</p>
</li>
<li><p><strong>Member:</strong> read and write within their tenant</p>
</li>
<li><p><strong>Viewer:</strong> read-only within their tenant</p>
</li>
</ul>
<p>Generate a token (used for testing and your auth endpoint):</p>
<pre><code class="language-javascript">// src/utils/token.js
const jwt = require('jsonwebtoken');

function generateToken({ userId, tenantId, email, role }) {
  return jwt.sign(
    { userId, tenantId, email, role },
    process.env.JWT_SECRET,
    { expiresIn: '24h' }
  );
}

module.exports = { generateToken };
</code></pre>
<h2 id="heading-auth-and-rbac-middleware">Auth and RBAC Middleware</h2>
<p>The auth middleware does two things: verifies the JWT signature and extracts the tenant context into <code>req.user</code>.</p>
<p>That second part is what the entire system depends on. Every query downstream reads <code>req.user.tenantId</code>. The client has no say in what that value is. They send a token the server signed, and the server reads back what it put in.</p>
<pre><code class="language-javascript">// src/middleware/auth.js
const jwt = require('jsonwebtoken');

function authMiddleware(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing or malformed Authorization header' });
  }

  const token = authHeader.split(' ')[1];

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);

    // tenantId always comes from the verified token — never req.body or req.params
    req.user = {
      userId:   decoded.userId,
      tenantId: decoded.tenantId,
      email:    decoded.email,
      role:     decoded.role,
    };

    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

module.exports = { authMiddleware };
</code></pre>
<p>The RBAC middleware is separate from auth by design. Auth runs on every route. Role enforcement only applies where a minimum role is required. You pass the allowed roles to <code>requireRole()</code> and it compares the user's level against the hierarchy. A Viewer trying to delete something hits the 403 before the handler ever runs.</p>
<pre><code class="language-javascript">// src/middleware/rbac.js
const ROLE_HIERARCHY = {
  SuperAdmin:   4,
  TenantAdmin:  3,
  Member:       2,
  Viewer:       1,
};

// requireRole('TenantAdmin') — user must be TenantAdmin or higher
function requireRole(...roles) {
  return (req, res, next) =&gt; {
    const userLevel = ROLE_HIERARCHY[req.user?.role] ?? 0;
    const requiredLevel = Math.min(...roles.map(r =&gt; ROLE_HIERARCHY[r] ?? 999));

    if (userLevel &lt; requiredLevel) {
      return res.status(403).json({
        error: 'Insufficient permissions',
        required: roles,
        current: req.user?.role,
      });
    }

    next();
  };
}

module.exports = { requireRole };
</code></pre>
<h2 id="heading-the-tenant-safe-repository-layer">The Tenant-Safe Repository Layer</h2>
<p>Isolation lives here. Every function takes <code>tenantId</code> as a required argument, pulled from <code>req.user</code> by the handler. There's no way to call these without providing a tenant scope. I've watched teams try to handle this with a URL parameter instead (<code>GET /api/projects?tenantId=xyz</code>) and call it isolated. It is not. Any client sends whatever it wants in a query string.</p>
<pre><code class="language-javascript">// src/repositories/projectRepo.js
const { pool } = require('../../db');

// List all projects for a tenant — tenantId is ALWAYS from the JWT
async function listProjects(tenantId) {
  const result = await pool.query(
    `SELECT id, name, description, created_by, created_at
     FROM projects
     WHERE tenant_id = $1
     ORDER BY created_at DESC`,
    [tenantId]
  );
  return result.rows;
}

// Get a single project — returns null if it belongs to a different tenant
// NOTE: Returns 404 (not 403) intentionally — don't reveal the resource exists
async function getProject(id, tenantId) {
  const result = await pool.query(
    `SELECT id, name, description, created_by, created_at
     FROM projects
     WHERE id = $1 AND tenant_id = $2`,
    [id, tenantId]
  );
  return result.rows[0] || null;
}

async function createProject({ tenantId, name, description, createdBy }) {
  const result = await pool.query(
    `INSERT INTO projects (tenant_id, name, description, created_by)
     VALUES ($1, $2, $3, $4)
     RETURNING *`,
    [tenantId, name, description, createdBy]
  );
  return result.rows[0];
}

async function updateProject(id, tenantId, updates) {
  const result = await pool.query(
    `UPDATE projects
     SET name = COALESCE($3, name),
         description = COALESCE($4, description),
         updated_at = NOW()
     WHERE id = $1 AND tenant_id = $2
     RETURNING *`,
    [id, tenantId, updates.name, updates.description]
  );
  return result.rows[0] || null;
}

async function deleteProject(id, tenantId) {
  const result = await pool.query(
    `DELETE FROM projects WHERE id = $1 AND tenant_id = $2 RETURNING id`,
    [id, tenantId]
  );
  return result.rows[0] || null;
}

module.exports = { listProjects, getProject, createProject, updateProject, deleteProject };
</code></pre>
<p>Notice what <code>getProject</code> does when Tenant A tries to fetch a Tenant B resource. The query runs with Tenant A's <code>tenantId</code>. The condition <code>id = $1 AND tenant_id = $2</code> matches nothing, <code>null</code> comes back, and the handler sends a <code>404</code>. Not a <code>403</code>. A 403 tells the caller the resource exists, but they can't access it, which is information they shouldn't have.</p>
<h2 id="heading-audit-logging-service">Audit Logging Service</h2>
<pre><code class="language-javascript">// src/services/auditService.js
const { pool } = require('../../db');

async function log({
  tenantId,
  userId,
  userEmail,
  userRole,          // role at time of action — roles change, log should not
  action,            // 'CREATE' | 'UPDATE' | 'DELETE' | 'VIEW'
  resource,          // table name
  resourceId = null,
  oldValues = null,
  newValues = null,
  ipAddress = null,
  userAgent = null,
}) {
  const query = `
    INSERT INTO audit_logs
      (tenant_id, user_id, user_email, user_role, action, resource,
       resource_id, old_values, new_values, ip_address, user_agent)
    VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
  `;

  const values = [
    tenantId, userId, userEmail, userRole, action, resource,
    resourceId,
    oldValues  ? JSON.stringify(oldValues)  : null,
    newValues  ? JSON.stringify(newValues)  : null,
    ipAddress,
    userAgent,
  ];

  // Fire-and-forget — audit logging must never block or fail a user request
  pool.query(query, values).catch((err) =&gt; {
    console.error('[AuditService] Failed to write log:', err.message);
  });
}

module.exports = { log };
</code></pre>
<p>Capturing <code>userRole</code> at write time matters more than it looks. User roles change after the fact: someone gets demoted, a permission is revoked. If the log only records the user ID, you lose the context of what privilege they held when the action happened. Store the role at the time of the action, and you always know.</p>
<h2 id="heading-per-tenant-rate-limiting">Per-Tenant Rate Limiting</h2>
<p>IP-based rate limiting breaks down in SaaS. A corporate customer might route hundreds of users through a single NAT gateway, sharing one IP address. One heavy tenant throttles everyone else on that address.</p>
<p>I've watched teams discover this the hard way when an enterprise customer suddenly floods the API, and their other tenants start getting 429s with no explanation. Scope limits to <code>tenant_id</code> instead.</p>
<pre><code class="language-javascript">// src/middleware/rateLimiter.js
const rateLimit = require('express-rate-limit');
const { RedisStore } = require('rate-limit-redis');
const { redisClient } = require('../../db/redis');

// Rate limits by plan — extend as needed
const PLAN_LIMITS = {
  free:       { max: 100,  windowMs: 15 * 60 * 1000 }, // 100 req / 15 min
  pro:        { max: 500,  windowMs: 15 * 60 * 1000 }, // 500 req / 15 min
  enterprise: { max: 2000, windowMs: 15 * 60 * 1000 }, // 2000 req / 15 min
};

function createTenantRateLimiter(plan = 'free') {
  const limits = PLAN_LIMITS[plan] || PLAN_LIMITS.free;

  return rateLimit({
    windowMs: limits.windowMs,
    max: limits.max,
    // Key = tenant_id from verified JWT — NOT the IP address
    keyGenerator: (req) =&gt; `tenant:${req.user?.tenantId || req.ip}`,
    store: new RedisStore({
      sendCommand: (...args) =&gt; redisClient.call(...args),
    }),
    handler: (req, res) =&gt; {
      res.status(429).json({
        error: 'Too many requests',
        retryAfter: Math.ceil(limits.windowMs / 1000),
      });
    },
  });
}

// Default limiter for all API routes
const defaultLimiter = createTenantRateLimiter('free');

module.exports = { defaultLimiter, createTenantRateLimiter };
</code></pre>
<h2 id="heading-building-the-routes">Building the Routes</h2>
<p>This is where everything connects. Auth and rate limiting apply to the whole router. Role checks go on individual routes. The audit log fires after every write. <code>tenantId</code> never comes from the request body or URL. <code>req.user.tenantId</code> is the only source, set by the auth middleware from the verified token, so there's no path around it.</p>
<p>One practical detail for Express 4: it doesn't catch async errors automatically. Every handler wraps its logic in try/catch and passes failures to <code>next(err)</code>. Skip that and an unhandled promise rejection returns a blank 500 with no log entry and no audit trail. The comment at the top of the router is a reminder that the pattern is intentional.</p>
<pre><code class="language-javascript">// src/routes/projects.js
const express = require('express');
const { authMiddleware }  = require('../middleware/auth');
const { requireRole }     = require('../middleware/rbac');
const { defaultLimiter }  = require('../middleware/rateLimiter');
const audit               = require('../services/auditService');
const repo                = require('../repositories/projectRepo');

const router = express.Router();

// All routes require authentication
router.use(authMiddleware);
router.use(defaultLimiter);

// Express 4 does not catch async errors automatically.
// Every handler must wrap await calls in try/catch and pass errors to next().
// Without this, an unhandled promise rejection silently returns 500
// with no useful message and no audit log entry.

// GET /api/projects — list all (Viewer and above)
router.get('/', async (req, res, next) =&gt; {
  try {
    const projects = await repo.listProjects(req.user.tenantId);

    audit.log({
      tenantId:   req.user.tenantId,
      userId:     req.user.userId,
      userEmail:  req.user.email,
      userRole:   req.user.role,
      action:     'VIEW',
      resource:   'projects',
      ipAddress:  req.ip,
      userAgent:  req.headers['user-agent'],
    });

    res.json(projects);
  } catch (err) {
    next(err);
  }
});

// GET /api/projects/:id — single project (Viewer and above)
router.get('/:id', async (req, res, next) =&gt; {
  try {
    const project = await repo.getProject(req.params.id, req.user.tenantId);
    if (!project) return res.status(404).json({ error: 'Not found' });
    res.json(project);
  } catch (err) {
    next(err);
  }
});

// POST /api/projects — create (Member and above)
router.post('/', requireRole('Member', 'TenantAdmin', 'SuperAdmin'), async (req, res, next) =&gt; {
  try {
    const { name, description } = req.body;
    if (!name) return res.status(400).json({ error: 'name is required' });

    const project = await repo.createProject({
      tenantId:    req.user.tenantId,
      name,
      description,
      createdBy:   req.user.userId,
    });

    audit.log({
      tenantId:    req.user.tenantId,
      userId:      req.user.userId,
      userEmail:   req.user.email,
      userRole:    req.user.role,
      action:      'CREATE',
      resource:    'projects',
      resourceId:  project.id,
      newValues:   project,
      ipAddress:   req.ip,
      userAgent:   req.headers['user-agent'],
    });

    res.status(201).json(project);
  } catch (err) {
    next(err);
  }
});

// PUT /api/projects/:id — update (Member and above)
router.put('/:id', requireRole('Member', 'TenantAdmin', 'SuperAdmin'), async (req, res, next) =&gt; {
  try {
    const oldProject = await repo.getProject(req.params.id, req.user.tenantId);
    if (!oldProject) return res.status(404).json({ error: 'Not found' });

    const updated = await repo.updateProject(req.params.id, req.user.tenantId, req.body);

    audit.log({
      tenantId:    req.user.tenantId,
      userId:      req.user.userId,
      userEmail:   req.user.email,
      userRole:    req.user.role,
      action:      'UPDATE',
      resource:    'projects',
      resourceId:  req.params.id,
      oldValues:   oldProject,
      newValues:   updated,
      ipAddress:   req.ip,
      userAgent:   req.headers['user-agent'],
    });

    res.json(updated);
  } catch (err) {
    next(err);
  }
});

// DELETE /api/projects/:id — TenantAdmin and above only
router.delete('/:id', requireRole('TenantAdmin', 'SuperAdmin'), async (req, res, next) =&gt; {
  try {
    const project = await repo.getProject(req.params.id, req.user.tenantId);
    if (!project) return res.status(404).json({ error: 'Not found' });

    await repo.deleteProject(req.params.id, req.user.tenantId);

    audit.log({
      tenantId:    req.user.tenantId,
      userId:      req.user.userId,
      userEmail:   req.user.email,
      userRole:    req.user.role,
      action:      'DELETE',
      resource:    'projects',
      resourceId:  req.params.id,
      oldValues:   project,
      ipAddress:   req.ip,
      userAgent:   req.headers['user-agent'],
    });

    res.json({ deleted: true });
  } catch (err) {
    next(err);
  }
});

module.exports = router;
</code></pre>
<h2 id="heading-testing-tenant-isolation">Testing Tenant Isolation</h2>
<p>Skip the isolation tests and you're flying blind. The application keeps running, nothing throws an error, but two customers are reading each other's data.</p>
<p>I've watched this sit undetected in production for months because nothing actually broke. The wrong data just showed up quietly. Automated tests on every pull request are the only reliable way to catch it early.</p>
<pre><code class="language-javascript">// tests/tenantIsolation.test.js
require('dotenv').config();  // must be first — loads DATABASE_URL and REDIS_URL
const request = require('supertest');
const app     = require('../app');
const { generateToken } = require('../src/utils/token');
const { pool }        = require('../db');
const { redisClient } = require('../db/redis');

// Test fixture: two isolated tenants, one project in Tenant B
async function seedTestData() {
  // Clean up from any previous run to avoid unique-constraint failures
  await pool.query(`DELETE FROM projects WHERE name LIKE 'TEST-%'`);
  await pool.query(`DELETE FROM tenants WHERE name IN ('Tenant A', 'Tenant B')`);

  const tenantA = (await pool.query(
    `INSERT INTO tenants (name, plan) VALUES ('Tenant A', 'pro') RETURNING id`
  )).rows[0].id;

  const tenantB = (await pool.query(
    `INSERT INTO tenants (name, plan) VALUES ('Tenant B', 'pro') RETURNING id`
  )).rows[0].id;

  const userA = (await pool.query(
    `INSERT INTO users (tenant_id, email, role) VALUES ($1, 'usera@a.com', 'Member') RETURNING id`,
    [tenantA]
  )).rows[0].id;

  // userB owns the project in Tenant B — satisfies the created_by FK constraint
  const userB = (await pool.query(
    `INSERT INTO users (tenant_id, email, role) VALUES ($1, 'userb@b.com', 'Member') RETURNING id`,
    [tenantB]
  )).rows[0].id;

  const projectB = (await pool.query(
    `INSERT INTO projects (tenant_id, name, created_by)
     VALUES ($1, 'TEST-Secret Project', $2) RETURNING id`,
    [tenantB, userB]
  )).rows[0].id;

  return { tenantA, tenantB, userA, projectB };
}

describe('Tenant Isolation', () =&gt; {
  let data;

  beforeAll(async () =&gt; {
    data = await seedTestData();
  });

  afterAll(async () =&gt; {
    await pool.query(`DELETE FROM tenants WHERE name IN ('Tenant A', 'Tenant B')`);
    await pool.end();
    await redisClient.quit();  // close Redis connection so Jest exits cleanly
  });

  test('Tenant A user cannot read Tenant B project', async () =&gt; {
    const token = generateToken({
      userId:   data.userA,
      tenantId: data.tenantA,   // ← Tenant A token
      email:    'usera@a.com',
      role:     'Member',
    });

    const res = await request(app)
      .get(`/api/projects/${data.projectB}`)  // ← Tenant B's project ID
      .set('Authorization', `Bearer ${token}`);

    // Must be 404, not 200 or 403
    expect(res.status).toBe(404);
  });

  test('Tenant A user cannot list Tenant B projects', async () =&gt; {
    const token = generateToken({
      userId:   data.userA,
      tenantId: data.tenantA,
      email:    'usera@a.com',
      role:     'TenantAdmin',
    });

    const res = await request(app)
      .get('/api/projects')
      .set('Authorization', `Bearer ${token}`);

    expect(res.status).toBe(200);
    // Response must contain zero Tenant B projects
    const names = res.body.map(p =&gt; p.name);
    expect(names).not.toContain('TEST-Secret Project');
  });

  test('Viewer cannot delete a project', async () =&gt; {
    const token = generateToken({
      userId:   data.userA,
      tenantId: data.tenantA,
      email:    'usera@a.com',
      role:     'Viewer',         // ← Viewer role
    });

    const res = await request(app)
      .delete(`/api/projects/${data.projectB}`)
      .set('Authorization', `Bearer ${token}`);

    expect(res.status).toBe(403);
  });
});
</code></pre>
<p>Run the tests:</p>
<pre><code class="language-bash">npm test
</code></pre>
<p>Three tests, three boundaries confirmed. Wire these into CI so they run on every pull request. A future refactor that quietly drops the <code>tenant_id</code> filter will get caught before it ships.</p>
<h2 id="heading-troubleshooting">Troubleshooting</h2>
<h3 id="heading-tenant-a-can-see-tenant-bs-data">Tenant A can see Tenant B's data</h3>
<p>One query is missing the <code>AND tenant_id = $N</code> clause. Search every repository file for <code>SELECT</code> statements and check each one. It's almost always this.</p>
<h3 id="heading-403-forbidden-on-a-route-that-should-be-accessible"><code>403 Forbidden</code> on a route that should be accessible</h3>
<p>The role string in the JWT doesn't match what <code>requireRole()</code> is checking. Check the exact string in the token payload. <code>'member'</code> and <code>'Member'</code> aren't the same thing. Paste your token into jwt.io and look at the role field directly.</p>
<h3 id="heading-rate-limiter-isnt-working">Rate limiter isn't working</h3>
<p>Redis is probably not connected. Log <code>redisClient.status</code> before the server starts. If it's not <code>ready</code>, the limiter has fallen back to in-memory, which means restarts reset all counters and tenant-scoped limiting stops working.</p>
<h3 id="heading-audit-log-table-growing-very-large">Audit log table growing very large</h3>
<p>Expected behaviour. Audit tables grow, that's the point. Once it gets large, ship rows older than a year to S3 or Azure Blob and keep querying against a smaller hot table. Most compliance requirements want at least 12 months of accessible logs anyway. Just don't DELETE from the table itself.</p>
<h3 id="heading-jwtverify-throws-jsonwebtokenerror-invalid-signature"><code>jwt.verify</code> throws <code>JsonWebTokenError: invalid signature</code></h3>
<p>The secret that signed the token doesn't match <code>JWT_SECRET</code> in the environment where you're verifying it. This comes up most when switching between environments or when a second service has a different value in its <code>.env</code>. Every service that calls <code>jwt.verify</code> needs the exact same secret. Copy it across, don't retype it.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>The system you've built: row-level isolation in the repository, role checks before the handler runs, an audit table the app can't touch, and rate limits per tenant. That's the whole thing.</p>
<p>The tests are what I see dropped most often. Teams build the isolation, ship it, and never write something that actually proves cross-tenant data can't leak. Then a query gets refactored six months later and the <code>tenant_id</code> filter quietly disappears. CI catches it. Manual code review rarely does.</p>
<p>Schema-per-tenant comes up eventually if your product grows large enough. But not at the start. Row-level isolation handles more scale than most teams will ever hit, and it costs a fraction of the operational overhead.</p>
<p>The full working code is available on GitHub: <a href="https://github.com/ziaongit/nodejs-multitenant-saas-api">nodejs-multitenant-saas-api</a></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Manage Secrets Securely with Azure Key Vault in Node.js ]]>
                </title>
                <description>
                    <![CDATA[ Last year a client called me about exactly this. Someone ran git log -p on a hunch and found a .env committed two years earlier, never caught. Database password, Stripe secret, JWT signing key — all s ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-manage-secrets-securely-with-azure-key-vault-in-node-js/</link>
                <guid isPermaLink="false">6a5e27b295e748bed9510853</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Azure ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Zia Ullah ]]>
                </dc:creator>
                <pubDate>Mon, 20 Jul 2026 13:50:42 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/5491b408-9c6b-4d4d-a53e-215119fb2d97.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Last year a client called me about exactly this. Someone ran <code>git log -p</code> on a hunch and found a <code>.env</code> committed two years earlier, never caught. Database password, Stripe secret, JWT signing key — all still active. All still in production.</p>
<p>IBM's 2024 breach cost report put the average data breach at <strong>$4.88 million</strong> — and that's the average, not the worst cases.</p>
<p>Exposed credentials are consistently near the top of root causes. GitHub found over a million secrets leaked in public repos in 2023 alone, before you even count the private ones nobody ever discovered.</p>
<p>It's not a people problem. The developers I've worked with aren't careless — the architecture is just set up to fail them. A <code>.env</code> file gets committed once by accident. Credentials get copied and pasted into a Slack message to unblock a teammate. A Docker image gets published with secrets baked into a layer. A server gets shut down, and nobody rotates the credentials it was holding.</p>
<p>Azure Key Vault solves this differently. Your application fetches credentials at runtime from a centralized, encrypted service — the <code>.env</code> file stops being a liability because it stops holding anything worth stealing.</p>
<p>What you'll build is a Node.js Express API that fetches every secret from Azure Key Vault at startup. No passwords in the code. When someone quits, there's nothing in the repo to rotate. The <code>.env</code> ends up with one line — the vault name.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Node.js 18+</p>
</li>
<li><p>An Azure account (free tier works)</p>
</li>
<li><p>Azure CLI installed and logged in (<code>az login</code>)</p>
</li>
<li><p>Basic knowledge of Express.js</p>
</li>
<li><p>Docker (optional — only needed for the local database test section)</p>
</li>
</ul>
<h2 id="heading-what-we-will-build">What We Will Build</h2>
<p>A Node.js Express API that:</p>
<ol>
<li><p>Connects to PostgreSQL using credentials fetched from Key Vault at startup</p>
</li>
<li><p>Uses Managed Identity for authentication — no client secrets or passwords anywhere</p>
</li>
<li><p>Caches secrets in memory, so Key Vault isn't called on every request</p>
</li>
<li><p>Works locally via Azure CLI auth and in production via Managed Identity — same code, zero changes</p>
</li>
</ol>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-how-the-architecture-works">How the Architecture Works</a></p>
</li>
<li><p><a href="#heading-what-is-azure-key-vault">What Is Azure Key Vault?</a></p>
</li>
<li><p><a href="#heading-set-up-the-key-vault">Set Up the Key Vault</a></p>
</li>
<li><p><a href="#heading-create-the-nodejs-project">Create the Node.js Project</a></p>
</li>
<li><p><a href="#heading-connect-to-key-vault-with-managed-identity">Connect to Key Vault with Managed Identity</a></p>
</li>
<li><p><a href="#heading-cache-secrets-at-startup">Cache Secrets at Startup</a></p>
</li>
<li><p><a href="#heading-use-secrets-in-your-express-api">Use Secrets in Your Express API</a></p>
</li>
<li><p><a href="#heading-test-locally">Test Locally</a></p>
</li>
<li><p><a href="#heading-deploy-to-azure-app-service">Deploy to Azure App Service</a></p>
</li>
<li><p><a href="#heading-grant-key-vault-access-to-the-app">Grant Key Vault Access to the App</a></p>
</li>
<li><p><a href="#heading-rotate-secrets-without-redeploying">Rotate Secrets Without Redeploying</a></p>
</li>
<li><p><a href="#heading-troubleshooting">Troubleshooting</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ol>
<h2 id="heading-how-the-architecture-works">How the Architecture Works</h2>
<p>Before writing any code, it helps to see the full picture:</p>
<pre><code class="language-plaintext"> LOCAL DEVELOPMENT
.-------------------------------------------------------.
|                                                        |
|   [Node.js App]                                        |
|        |                                               |
|        v                                               |
|   [DefaultAzureCredential] ---&gt; az login session       |
|        |                                               |
|        v                                               |
|   [Azure Key Vault]  ---&gt; Returns secrets              |
|        |                                               |
|        v                                               |
|   [In-memory cache]  ---&gt; App uses secrets at runtime  |
'-------------------------------------------------------'

 PRODUCTION (Azure)
.-------------------------------------------------------.
|                                                        |
|   [Azure App Service]                                  |
|        |                                               |
|        v                                               |
|   [DefaultAzureCredential] ---&gt; Managed Identity       |
|        |                                               |
|        v                                               |
|   [Azure Key Vault]  ---&gt; Returns secrets              |
|        |                                               |
|        v                                               |
|   [In-memory cache]  ---&gt; App uses secrets at runtime  |
'-------------------------------------------------------'
</code></pre>
<p>Both environments run the exact same code. <code>DefaultAzureCredential</code> figures out where it is — locally it picks up your <code>az login</code> session, on Azure it uses Managed Identity. You don't switch config files and you don't manage credentials. It just works.</p>
<h2 id="heading-what-is-azure-key-vault">What Is Azure Key Vault?</h2>
<p>Azure Key Vault is Microsoft's managed secret store — it handles secrets, keys, and certificates. For this tutorial, we're only using the secrets part: database passwords, API keys, JWT signing keys, anything your app needs to run but has no business being in your Git history.</p>
<p>Compared to <code>.env</code> files, the practical differences are worth understanding before you write any code.</p>
<p>Rotation is the one I notice most on real projects. Update a secret in Key Vault and every app picks it up on the next restart — no hunting down five different environment configs across staging and production.</p>
<p>Access control is the other big one. Each application only gets permission to read the secrets it actually needs. If one service gets compromised, it can't read credentials belonging to other services.</p>
<p>And every read gets logged. When something goes wrong — and eventually something will — you can see exactly which app accessed which secret, and when. That log is what auditors actually want to see.</p>
<p>I've sat in enough security reviews to know that "we use <code>.env</code> files and tell people not to commit them" doesn't satisfy an auditor. SOC 2, HIPAA, GDPR — they all want demonstrable controls. A vault with an access log is demonstrable.</p>
<h2 id="heading-set-up-the-key-vault">Set Up the Key Vault</h2>
<p>Run these commands. The vault name has to be globally unique across all of Azure — not just your own subscription — so pick something specific. Letters, numbers, and hyphens, 3 to 24 characters.</p>
<pre><code class="language-bash"># Create a resource group (skip if you already have one)
az group create \
  --name keyvault-demo-rg \
  --location eastus

# Create the Key Vault (RBAC enabled by default — required for the role assignment later)
az keyvault create \
  --name your-vault-name \
  --resource-group keyvault-demo-rg \
  --location eastus

# Grant yourself permission to manage secrets (required with RBAC — creators are not auto-assigned)
az role assignment create \
  --role "Key Vault Secrets Officer" \
  --assignee-object-id $(az ad signed-in-user show --query id -o tsv) \
  --scope $(az keyvault show \
    --name your-vault-name \
    --resource-group keyvault-demo-rg \
    --query id -o tsv)

# Add your secrets
az keyvault secret set \
  --vault-name your-vault-name \
  --name "DB-HOST" \
  --value "your-db-host.postgres.database.azure.com"

az keyvault secret set \
  --vault-name your-vault-name \
  --name "DB-PASSWORD" \
  --value "your-super-secret-password"

az keyvault secret set \
  --vault-name your-vault-name \
  --name "JWT-SECRET" \
  --value "your-jwt-signing-secret"
</code></pre>
<p>Verify the secrets were stored:</p>
<pre><code class="language-bash">az keyvault secret list --vault-name your-vault-name --query "[].name" -o tsv
</code></pre>
<p>You should see:</p>
<pre><code class="language-plaintext">DB-HOST
DB-PASSWORD
JWT-SECRET
</code></pre>
<h2 id="heading-create-the-nodejs-project">Create the Node.js Project</h2>
<p>Set up the project structure:</p>
<pre><code class="language-bash">mkdir nodejs-azure-keyvault
cd nodejs-azure-keyvault
npm init -y
npm install express pg jsonwebtoken @azure/keyvault-secrets @azure/identity dotenv
</code></pre>
<p>The two Azure packages do all the work:</p>
<ul>
<li><p><code>@azure/keyvault-secrets</code> — connects to your vault and pulls secrets out</p>
</li>
<li><p><code>@azure/identity</code> — handles auth. Locally, it uses your <code>az login</code> session, in production, it switches to Managed Identity automatically</p>
</li>
</ul>
<p>Add a start script to <code>package.json</code>:</p>
<pre><code class="language-bash">npm pkg set scripts.start="node server.js"
</code></pre>
<p>Create the following file structure:</p>
<pre><code class="language-plaintext">nodejs-azure-keyvault/
|-- src/
|   |-- config/
|   |   `-- secrets.js   # Key Vault client and secret loader
|   |-- db/
|   |   `-- index.js     # PostgreSQL pool using secrets
|   `-- routes/
|       `-- users.js     # Example route
|-- app.js               # Express app
`-- server.js            # Entry point -- loads secrets first
</code></pre>
<h2 id="heading-connect-to-key-vault-with-managed-identity">Connect to Key Vault with Managed Identity</h2>
<p>Create the secrets config file:</p>
<pre><code class="language-javascript">// src/config/secrets.js
const { SecretClient } = require('@azure/keyvault-secrets');
const { DefaultAzureCredential } = require('@azure/identity');

const VAULT_URL = `https://${process.env.KEY_VAULT_NAME}.vault.azure.net`;

const credential = new DefaultAzureCredential();
const client = new SecretClient(VAULT_URL, credential);

async function getSecret(name) {
  const secret = await client.getSecret(name);
  return secret.value;
}

module.exports = { getSecret };
</code></pre>
<p><code>DefaultAzureCredential</code> is the most important part of this setup. It tries a chain of authentication methods in order:</p>
<ol>
<li><p>Environment variables (for CI/CD pipelines)</p>
</li>
<li><p>Azure CLI credentials (for local development — <code>az login</code>)</p>
</li>
<li><p>Managed Identity (for deployed apps on Azure)</p>
</li>
</ol>
<p>This means the exact same code works locally and in production with zero changes. Locally, it uses your <code>az login</code> session. In production, it uses the app's Managed Identity. You never touch credentials.</p>
<h2 id="heading-cache-secrets-at-startup">Cache Secrets at Startup</h2>
<p>Calling Key Vault on every request adds latency and costs money. Load all secrets once at startup and cache them in memory. Replace <code>src/config/secrets.js</code> with this complete version:</p>
<pre><code class="language-javascript">// src/config/secrets.js
const { SecretClient } = require('@azure/keyvault-secrets');
const { DefaultAzureCredential } = require('@azure/identity');

const VAULT_URL = `https://${process.env.KEY_VAULT_NAME}.vault.azure.net`;

const credential = new DefaultAzureCredential();
const client = new SecretClient(VAULT_URL, credential);

// In-memory cache
const cache = {};

async function getSecret(name) {
  if (cache[name]) return cache[name];
  const secret = await client.getSecret(name);
  cache[name] = secret.value;
  return secret.value;
}

async function loadAllSecrets() {
  console.log('Loading secrets from Azure Key Vault...');
  const secretNames = ['DB-HOST', 'DB-PASSWORD', 'JWT-SECRET'];

  await Promise.all(
    secretNames.map(async (name) =&gt; {
      cache[name] = await getSecret(name);
      console.log(`  ✓ ${name} loaded`);
    })
  );

  console.log('All secrets loaded successfully.');
}

function getFromCache(name) {
  if (!cache[name]) throw new Error(`Secret "${name}" not loaded. Did loadAllSecrets() run?`);
  return cache[name];
}

module.exports = { loadAllSecrets, getFromCache };
</code></pre>
<p>The <code>loadAllSecrets</code> function runs once when the application starts. After that, all secrets are served from the in-memory cache with zero latency and zero Key Vault calls.</p>
<h2 id="heading-use-secrets-in-your-express-api">Use Secrets in Your Express API</h2>
<p>Set up the database connection using the cached secrets:</p>
<pre><code class="language-javascript">// src/db/index.js
const { Pool } = require('pg');
const { getFromCache } = require('../config/secrets');

let pool;

function getPool() {
  if (!pool) {
    pool = new Pool({
      host:     getFromCache('DB-HOST'),
      database: process.env.DB_NAME || 'myapp',
      user:     process.env.DB_USER || 'dbadmin',
      password: getFromCache('DB-PASSWORD'),
      port:     parseInt(process.env.DB_PORT || '5432'),
      ssl:      process.env.NODE_ENV === 'production'
                  ? { rejectUnauthorized: false }
                  : false,
    });

    pool.on('error', (err) =&gt; {
      console.error('Unexpected database pool error:', err.message);
    });
  }

  return pool;
}

module.exports = { getPool };
</code></pre>
<p>Notice the distinction: <code>DB-HOST</code> and <code>DB-PASSWORD</code> come from Key Vault because they're sensitive. The database name, username, and port are not — they don't need to be protected, so they use environment variables with sensible defaults. Key Vault is for credentials, not all configuration.</p>
<p>The SSL flag is environment-aware: forced on in production, off locally so Docker connections work without a certificate. The <code>rejectUnauthorized: false</code> setting accepts Azure Database for PostgreSQL's certificate without verifying the CA chain — this is standard for Azure-managed databases. For stricter environments, you can download the Azure root CA and pass it via the <code>ca</code> option in the pool config instead.</p>
<p>Create a sample route that uses JWT verification with the secret from Key Vault:</p>
<pre><code class="language-javascript">// src/routes/users.js
const express = require('express');
const jwt     = require('jsonwebtoken');
const { getFromCache } = require('../config/secrets');
const { getPool }      = require('../db');

const router = express.Router();

// Auth middleware — JWT secret comes from Key Vault, not process.env
function authMiddleware(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing or malformed Authorization header' });
  }

  const token = authHeader.split(' ')[1];

  try {
    req.user = jwt.verify(token, getFromCache('JWT-SECRET'));
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

// GET /api/users — list users (authenticated)
router.get('/', authMiddleware, async (req, res) =&gt; {
  try {
    const result = await getPool().query(
      'SELECT id, email, created_at FROM users ORDER BY created_at DESC LIMIT 20'
    );
    res.json(result.rows);
  } catch (err) {
    console.error('Database error:', err.message);
    res.status(500).json({ error: 'Internal server error' });
  }
});

// GET /api/users/:id — single user (authenticated)
router.get('/:id', authMiddleware, async (req, res) =&gt; {
  try {
    const result = await getPool().query(
      'SELECT id, email, created_at FROM users WHERE id = $1',
      [req.params.id]
    );
    if (!result.rows[0]) return res.status(404).json({ error: 'User not found' });
    res.json(result.rows[0]);
  } catch (err) {
    console.error('Database error:', err.message);
    res.status(500).json({ error: 'Internal server error' });
  }
});

module.exports = router;
</code></pre>
<p>Notice the error handler returns <code>'Internal server error'</code> instead of <code>err.message</code>. Database errors are surprisingly chatty — they'll hand an attacker your table names, column names, and query structure if you let them through.</p>
<p>Set up the Express application. Both files define <code>authMiddleware</code> locally — yes, it's duplicated. In production, I'd pull this into a shared middleware file. For this tutorial, keeping it local means you can read either file without bouncing between three others:</p>
<pre><code class="language-javascript">// app.js
const express = require('express');
const jwt = require('jsonwebtoken');
const { getFromCache } = require('./src/config/secrets');
const usersRouter = require('./src/routes/users');

const app = express();
app.use(express.json());

// Auth middleware — JWT secret comes from Key Vault, not process.env
function authMiddleware(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing or malformed Authorization header' });
  }
  const token = authHeader.split(' ')[1];
  try {
    req.user = jwt.verify(token, getFromCache('JWT-SECRET'));
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

// Health check — no auth required
app.get('/health', (req, res) =&gt; {
  res.json({ status: 'healthy', timestamp: new Date().toISOString() });
});

// Status endpoint — proves Key Vault integration without needing a database
app.get('/api/status', authMiddleware, (req, res) =&gt; {
  res.json({
    message: 'All secrets loaded from Azure Key Vault',
    vault: process.env.KEY_VAULT_NAME,
    secrets_loaded: ['DB-HOST', 'DB-PASSWORD', 'JWT-SECRET'],
    authenticated_as: req.user.email,
    timestamp: new Date().toISOString()
  });
});

app.use('/api/users', usersRouter);

app.use((req, res) =&gt; res.status(404).json({ error: 'Route not found' }));
app.use((err, req, res, next) =&gt; {
  console.error('Unhandled error:', err.message);
  res.status(500).json({ error: 'Internal server error' });
});

module.exports = app;
</code></pre>
<p>The entry point loads secrets before starting the server. The server doesn't start unless all secrets load successfully:</p>
<pre><code class="language-javascript">// server.js
require('dotenv').config();
const app = require('./app');
const { loadAllSecrets } = require('./src/config/secrets');

const PORT = process.env.PORT || 3000;

async function start() {
  try {
    await loadAllSecrets();
    app.listen(PORT, () =&gt; {
      console.log(`Server running on port ${PORT}`);
    });
  } catch (err) {
    console.error('Failed to start server:', err.message);
    console.error('Hint: Run "az login" for local development, or check Managed Identity for Azure deployments.');
    process.exit(1);
  }
}

start();
</code></pre>
<p>That <code>process.exit(1)</code> is deliberate. I'd rather the app crash loudly at startup than limp along with missing credentials and fail on the first real request two hours later.</p>
<h2 id="heading-test-locally">Test Locally</h2>
<p>Create a <code>.env</code> file for local development. This only contains the Key Vault name, nothing sensitive:</p>
<pre><code class="language-bash"># .env
KEY_VAULT_NAME=your-vault-name
PORT=3000
</code></pre>
<p>Add <code>.env</code> and the deployment zip to <code>.gitignore</code>:</p>
<pre><code class="language-bash">echo ".env" &gt;&gt; .gitignore
echo "app.zip" &gt;&gt; .gitignore
</code></pre>
<p>Make sure you're logged into Azure CLI:</p>
<pre><code class="language-bash">az login
</code></pre>
<p>Start the application:</p>
<pre><code class="language-bash">npm start
</code></pre>
<p>You should see:</p>
<pre><code class="language-plaintext">Loading secrets from Azure Key Vault...
  ✓ JWT-SECRET loaded
  ✓ DB-PASSWORD loaded
  ✓ DB-HOST loaded
All secrets loaded successfully.
Server running on port 3000
</code></pre>
<p>The order secrets load may vary — <code>Promise.all</code> fetches them in parallel and resolves as each one completes. What matters is that all three are confirmed before the server starts.</p>
<p>Test the health endpoint:</p>
<pre><code class="language-bash">curl http://localhost:3000/health
# {"status":"healthy","timestamp":"2026-07-14T19:38:11.659Z"}
</code></pre>
<p>Now prove the integration end-to-end. Grab the value you stored as <code>JWT-SECRET</code> and use it to sign a test token — paste it in for <code>YOUR-JWT-SECRET-VALUE</code>. Then hit <code>/api/status</code> with it:</p>
<pre><code class="language-bash">node -e "const jwt = require('jsonwebtoken'); console.log(jwt.sign({id:1, email:'test@test.com'}, 'YOUR-JWT-SECRET-VALUE', {expiresIn:'1h'}));"
</code></pre>
<p>On Linux/macOS:</p>
<pre><code class="language-bash">curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:3000/api/status
</code></pre>
<p>On Windows PowerShell:</p>
<pre><code class="language-powershell">Invoke-RestMethod -Uri "http://localhost:3000/api/status" -Headers @{Authorization = "Bearer YOUR_TOKEN"}
</code></pre>
<p>You should see:</p>
<pre><code class="language-json">{
  "message": "All secrets loaded from Azure Key Vault",
  "vault": "your-vault-name",
  "secrets_loaded": ["DB-HOST", "DB-PASSWORD", "JWT-SECRET"],
  "authenticated_as": "test@test.com",
  "timestamp": "2026-07-14T19:50:08.687Z"
}
</code></pre>
<p>If you got that response, the whole chain worked. The JWT was signed and verified using a secret that lived only in Key Vault — not in your code, not in your<code>.env</code>, not anywhere in the repo. Your <code>az login</code> session handled the auth locally. In production, Managed Identity takes over. Same code, nothing changes.</p>
<h3 id="heading-test-the-full-database-flow-with-docker">Test the Full Database Flow with Docker</h3>
<p>The app reads <code>DB-HOST</code> and <code>DB-PASSWORD</code> from Key Vault, so those secrets need to match your local Docker container. Update them now:</p>
<pre><code class="language-bash">az keyvault secret set --vault-name your-vault-name --name "DB-HOST" --value "localhost"
az keyvault secret set --vault-name your-vault-name --name "DB-PASSWORD" --value "demopassword123"
</code></pre>
<p>Docker up a Postgres container. The password has to match <code>demopassword123</code> — that's what you just put in Key Vault:</p>
<pre><code class="language-bash">docker run --name pg-demo \
  -e POSTGRES_USER=dbadmin \
  -e POSTGRES_PASSWORD=demopassword123 \
  -e POSTGRES_DB=myapp \
  -p 5432:5432 \
  -d postgres:15
</code></pre>
<p>Get the table created and throw in some test rows:</p>
<pre><code class="language-bash">docker exec -it pg-demo psql -U dbadmin -d myapp -c \
  "CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, email VARCHAR(255) UNIQUE NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW());"

docker exec -it pg-demo psql -U dbadmin -d myapp -c \
  "INSERT INTO users (email) VALUES ('alice@example.com'), ('bob@example.com'), ('carol@example.com');"
</code></pre>
<p>Kill the server and bring it back up — secrets load at startup, so it needs a fresh run to pick up what you just changed in Key Vault:</p>
<pre><code class="language-bash">npm start
</code></pre>
<p>Call the users endpoint with a valid JWT:</p>
<pre><code class="language-bash"># Generate a token (use the same value you stored as JWT-SECRET in Key Vault)
node -e "const jwt = require('jsonwebtoken'); console.log(jwt.sign({id:1, email:'test@test.com'}, 'YOUR-JWT-SECRET-VALUE', {expiresIn:'1h'}));"
</code></pre>
<p>On Linux/macOS:</p>
<pre><code class="language-bash">curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:3000/api/users
</code></pre>
<p>On Windows PowerShell:</p>
<pre><code class="language-powershell">Invoke-RestMethod -Uri "http://localhost:3000/api/users" -Headers @{Authorization = "Bearer YOUR_TOKEN"}
</code></pre>
<p>You should see:</p>
<pre><code class="language-json">[
  { "id": 1, "email": "alice@example.com", "created_at": "2026-07-14T19:59:21.064Z" },
  { "id": 2, "email": "bob@example.com",   "created_at": "2026-07-14T19:59:21.064Z" },
  { "id": 3, "email": "carol@example.com", "created_at": "2026-07-14T19:59:21.064Z" }
]
</code></pre>
<p>That query ran using a password that came straight from Key Vault. It's not in your <code>.env</code>, not hardcoded anywhere, and not in a local variable. The repo has nothing worth stealing.</p>
<p>Before you deploy, put the real production values back in Key Vault:</p>
<pre><code class="language-bash">az keyvault secret set --vault-name your-vault-name --name "DB-HOST" --value "your-db-host.postgres.database.azure.com"
az keyvault secret set --vault-name your-vault-name --name "DB-PASSWORD" --value "your-super-secret-password"
</code></pre>
<p>If you skip this, the deployed app will try to connect to <code>localhost</code> and fail immediately — <code>localhost</code> doesn't exist on App Service.</p>
<h2 id="heading-deploy-to-azure-app-service">Deploy to Azure App Service</h2>
<p><strong>Note:</strong> This section creates the App Service infrastructure. The actual code deployment (zip upload) happens at the end of the next section — the app must have Key Vault access configured before its first startup, or it will fail immediately and exit.</p>
<p>Create the App Service:</p>
<pre><code class="language-bash"># Create an App Service Plan (B1 is the cheapest paid tier)
az appservice plan create \
  --name keyvault-demo-plan \
  --resource-group keyvault-demo-rg \
  --sku B1 \
  --is-linux

# Create the Web App
az webapp create \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg \
  --plan keyvault-demo-plan \
  --runtime "NODE:18-lts"

# Set app settings — KEY_VAULT_NAME tells the app which vault to use
# NODE_ENV=production enables SSL for the database connection
az webapp config appsettings set \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg \
  --settings KEY_VAULT_NAME=your-vault-name NODE_ENV=production
</code></pre>
<h2 id="heading-grant-key-vault-access-to-the-app">Grant Key Vault Access to the App</h2>
<p>Enable Managed Identity on the app. This gives it an identity in Microsoft Entra ID that Key Vault can trust:</p>
<pre><code class="language-bash"># Enable system-assigned managed identity
az webapp identity assign \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg
</code></pre>
<p>The following commands capture the <code>principalId</code> automatically and use it to grant the role:</p>
<pre><code class="language-bash"># Get the principal ID
PRINCIPAL_ID=$(az webapp identity show \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg \
  --query principalId \
  --output tsv)

# Get the Key Vault resource ID
KV_ID=$(az keyvault show \
  --name your-vault-name \
  --resource-group keyvault-demo-rg \
  --query id \
  --output tsv)

# Grant the app the "Key Vault Secrets User" role
az role assignment create \
  --role "Key Vault Secrets User" \
  --assignee-object-id $PRINCIPAL_ID \
  --scope $KV_ID
</code></pre>
<p>The <code>Key Vault Secrets User</code> role allows the app to read secrets. It can't create, update, or delete them. This is the principle of least privilege — the application can only do what it needs to do.</p>
<p>Time to ship it. Linux/macOS can run this directly — Windows users, open Git Bash (it ships with Git for Windows):</p>
<pre><code class="language-bash">zip -r app.zip . -x "node_modules/*" ".git/*" ".env" "app.zip"
</code></pre>
<p>Then deploy:</p>
<pre><code class="language-bash">az webapp deployment source config-zip \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg \
  --src app.zip
</code></pre>
<p>The deployed application authenticates to Key Vault using its Managed Identity automatically. No passwords, no client secrets, no credentials of any kind in the deployment.</p>
<p>Check the health endpoint to confirm it's running:</p>
<pre><code class="language-bash">curl https://my-keyvault-node-app.azurewebsites.net/health
# {"status":"healthy","timestamp":"..."}
</code></pre>
<p>If it won't start, pull the logs:</p>
<pre><code class="language-bash">az webapp log tail --name my-keyvault-node-app --resource-group keyvault-demo-rg
</code></pre>
<p>Nine times out of ten, it's that the Key Vault role assignment has not been propagated yet. Give it 2–3 minutes, then restart:</p>
<pre><code class="language-bash">az webapp restart --name my-keyvault-node-app --resource-group keyvault-demo-rg
</code></pre>
<h2 id="heading-rotate-secrets-without-redeploying">Rotate Secrets Without Redeploying</h2>
<p>One of the biggest practical benefits of Key Vault is secret rotation. When a database password needs to change, you update it in Key Vault — not in your app:</p>
<pre><code class="language-bash">az keyvault secret set \
  --vault-name your-vault-name \
  --name "DB-PASSWORD" \
  --value "new-rotated-password"
</code></pre>
<p>The cache builds at startup, so you don't need a redeploy — a restart is enough:</p>
<pre><code class="language-bash">az webapp restart \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg
</code></pre>
<p>No code change. No new deployment. The secret is rotated, and the app is using the new value in seconds.</p>
<p>If you need zero-downtime rotation, add a <code>/refresh-secrets</code> endpoint behind admin auth that clears the cache and then calls <code>loadAllSecrets()</code>. The order matters — <code>loadAllSecrets()</code> uses <code>getSecret()</code> which returns cached values if they exist, so you must clear the cache first, or it will reload nothing. This is optional but useful for long-running processes that can't afford a restart.</p>
<h2 id="heading-troubleshooting">Troubleshooting</h2>
<p><code>CredentialUnavailableError: DefaultAzureCredential failed to retrieve a token</code></p>
<p>You're not logged into Azure CLI. Run <code>az login</code> and try again. On Azure App Service, check that Managed Identity is enabled and the role assignment was created correctly.</p>
<p><code>RestError: Forbidden — The user does not have secrets get permission</code></p>
<p>The Managed Identity isn't wired up to Key Vault yet. Go back and run the <code>az role assignment create</code> command. If you already did, it might just need time. Azure can take 2–3 minutes to propagate role assignments, so give it a moment before you dig further.</p>
<p><code>Error: Secret "DB-PASSWORD" not loaded. Did loadAllSecrets() run?</code></p>
<p><code>getFromCache()</code> ran before <code>loadAllSecrets()</code> finished, meaning the startup sequence is out of order. Open <code>server.js</code> and confirm <code>await loadAllSecrets()</code> comes before <code>app.listen()</code>. If the order's fine, the secret might just not be in the vault yet. Run <code>az keyvault secret list --vault-name YOUR_VAULT</code> to double-check. (A name mismatch — wrong case, typo — throws <code>SecretNotFound</code> instead, which is the entry below.)</p>
<p><strong>App starts locally but fails on Azure App Service</strong></p>
<p>Almost always, the app setting. Either <code>KEY_VAULT_NAME</code> isn't in App Service configuration at all, or the vault name has a typo. Run <code>az webapp log tail</code> to see the actual startup error — that'll tell you which one.</p>
<p><code>AuthorizationFailed</code> <strong>when running</strong> <code>az role assignment create</code></p>
<p>You are a guest user in your Azure tenant and lack the Owner role needed to assign roles. Switch the existing vault to the access policy model — no need to recreate it or lose your secrets:</p>
<pre><code class="language-bash">az keyvault update \
  --name your-vault-name \
  --resource-group keyvault-demo-rg \
  --enable-rbac-authorization false
</code></pre>
<p>If this happened during <strong>Set Up the Key Vault</strong> (granting yourself access), run:</p>
<pre><code class="language-bash">az keyvault set-policy \
  --name your-vault-name \
  --object-id $(az ad signed-in-user show --query id -o tsv) \
  --secret-permissions get set list delete
</code></pre>
<p>If this happened during <strong>Grant Key Vault Access to the App</strong> (granting the Managed Identity access), run:</p>
<pre><code class="language-bash">az keyvault set-policy \
  --name your-vault-name \
  --object-id $PRINCIPAL_ID \
  --secret-permissions get list
</code></pre>
<p><strong>Key Vault returns</strong> <code>SecretNotFound</code></p>
<p>The secret was never added, was deleted, or its name doesn't match exactly what your code requests — Key Vault secret names are case-sensitive. A secret named <code>db-password</code> and a request for <code>DB-PASSWORD</code> are different names. Run <code>az keyvault secret list --vault-name YOUR_VAULT</code> and compare what's actually in the vault against what <code>loadAllSecrets()</code> is asking for in <code>src/config/secrets.js</code>. Usually, it's a casing issue or a stray hyphen.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>The <code>.env</code> file in this project contains exactly one value: the Key Vault name. That's not sensitive. Every actual secret — database passwords, API keys, signing secrets — lives in Key Vault and never touches your codebase or your deployment pipeline.</p>
<p>This is the pattern I use on Azure projects now. The startup check is the part I find most useful in practice: if Key Vault is unreachable or a secret is missing, the server exits immediately with a clear error instead of starting up broken and failing on the first real request. You find out right away, rather than getting an obscure database connection error two hours later.</p>
<p>To add another secret, put it in Key Vault and drop its name into the <code>secretNames</code> array — that's it. Everything else scales with it.</p>
<p>The full working code is on GitHub: <a href="https://github.com/ziaongit/nodejs-azure-keyvault">nodejs-azure-keyvault</a></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Fix Common Web Application Security Vulnerabilities in Node.js ]]>
                </title>
                <description>
                    <![CDATA[ Here's something that tends to surprise developers who are new to security: most web vulnerabilities aren't the result of sophisticated attacks. They come from code patterns that look completely reaso ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-fix-common-web-application-security-vulnerabilities-in-node-js/</link>
                <guid isPermaLink="false">6a581c65dba34eeb664be8e2</guid>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ owasp ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Hackita ]]>
                </dc:creator>
                <pubDate>Wed, 15 Jul 2026 23:48:53 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/b2ec81b6-d6eb-41f0-9fa5-7570914ef97d.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Here's something that tends to surprise developers who are new to security: most web vulnerabilities aren't the result of sophisticated attacks. They come from code patterns that look completely reasonable: trusting a value from the URL, applying a request body to a database update, or running two queries where one should've been enough.</p>
<p>This guide covers six of those patterns. For each one, you'll see a real code example that creates the vulnerability, an explanation of what makes it dangerous, and a corrected version with notes on exactly what changed and why.</p>
<p>No security background is required to follow along here. It'll just help to have some familiarity with Node.js and SQL.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>The examples assume you're comfortable with:</p>
<ul>
<li><p>Node.js and Express.js basics</p>
</li>
<li><p>SQL queries</p>
</li>
<li><p>How HTTP requests and responses work</p>
</li>
<li><p>Basic authentication concepts (sessions, tokens)</p>
</li>
</ul>
<p><strong>Note on code examples</strong>: Throughout this tutorial, <code>db.query()</code> is a fictional database helper that returns a single row object for <code>SELECT</code> queries (or <code>null</code> if not found), and a result object for <code>INSERT</code>/<code>UPDATE</code> queries. The <code>connection.query()</code> in the race conditions section uses the <a href="https://github.com/sidorares/node-mysql2">mysql2</a> promise API directly, where <code>query()</code> returns <code>[rows, fields]</code>. Adapt the syntax to the database driver you use.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-1-broken-access-control-and-idor">1. Broken Access Control and IDOR</a></p>
</li>
<li><p><a href="#heading-2-mass-assignment">2. Mass Assignment</a></p>
</li>
<li><p><a href="#heading-3-prototype-pollution">3. Prototype Pollution</a></p>
</li>
<li><p><a href="#heading-4-race-conditions">4. Race Conditions</a></p>
</li>
<li><p><a href="#heading-5-business-logic-flaws">5. Business Logic Flaws</a></p>
</li>
<li><p><a href="#heading-6-jwt-misconfiguration">6. JWT Misconfiguration</a></p>
</li>
<li><p><a href="#heading-summary">Summary</a></p>
</li>
</ul>
<h2 id="heading-1-broken-access-control-and-idor">1. Broken Access Control and IDOR</h2>
<p><a href="https://owasp.org/Top10/A01_2021-Broken_Access_Control/">Broken Access Control</a> has topped the OWASP Top 10 since 2021, and it's not hard to see why. The most common form is <strong>Insecure Direct Object Reference (IDOR)</strong>: the application exposes a database ID in a URL, a user changes the number, and suddenly they're looking at someone else's data.</p>
<p>The fix seems obvious in hindsight. But it keeps appearing in production code because authentication and authorization get conflated. Confirming that a user is logged in is not the same as confirming they're allowed to access a specific resource.</p>
<h3 id="heading-how-to-identify-this-vulnerability-in-your-code">How to Identify This Vulnerability in Your Code</h3>
<p>Here's a typical user profile endpoint:</p>
<pre><code class="language-javascript">// Express.js - Vulnerable
app.get('/api/users/:id/profile', authenticate, async (req, res) =&gt; {
  const userId = req.params.id;

  const user = await db.query(
    'SELECT id, name, email, address FROM users WHERE id = ?',
    [userId]
  );

  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }

  res.json(user);
});
</code></pre>
<p>The <code>authenticate</code> middleware confirms that the request includes a valid token. But it doesn't confirm whether the authenticated user is allowed to access the requested resource.</p>
<p>Any authenticated user can request <code>/api/users/1/profile</code>, <code>/api/users/2/profile</code>, and so on and retrieve other users' data.</p>
<h3 id="heading-why-this-matters">Why This Matters</h3>
<p>Authentication confirms <em>who</em> you are. Authorization confirms <em>what you're allowed to do</em>. The code above does the first and skips the second entirely.</p>
<p>With a sequential numeric ID, a curious user doesn't need any special tools. They can just change <code>1</code> to <code>2</code> in the URL. But the same problem exists with UUIDs or slugs if the ownership check is missing.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Verify server-side that the authenticated user owns — or is explicitly authorized to access — the requested resource:</p>
<pre><code class="language-javascript">// Express.js - Secure
app.get('/api/users/:id/profile', authenticate, async (req, res) =&gt; {
  // Reject anything that isn't a string of digits — parseInt("12abc") would return 12
  if (!/^\d+$/.test(req.params.id)) {
    return res.status(400).json({ error: 'Invalid user ID' });
  }

  const requestedId = Number(req.params.id);

  if (requestedId &lt; 1) {
    return res.status(400).json({ error: 'Invalid user ID' });
  }

  // The authenticated user's ID is set by the authenticate middleware
  const authenticatedId = req.user.id;

  // Enforce ownership: users can only access their own profile
  if (requestedId !== authenticatedId) {
    return res.status(403).json({ error: 'Forbidden' });
  }

  const user = await db.query(
    'SELECT id, name, email, address FROM users WHERE id = ?',
    [requestedId]
  );

  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }

  res.json(user);
});
</code></pre>
<p>For admin endpoints that legitimately need to access any user, enforce role-based authorization explicitly:</p>
<pre><code class="language-javascript">// Admin endpoint with explicit role check
app.get('/api/admin/users/:id', authenticate, requireRole('admin'), async (req, res) =&gt; {
  if (!/^\d+$/.test(req.params.id)) {
    return res.status(400).json({ error: 'Invalid user ID' });
  }

  const userId = Number(req.params.id);

  const user = await db.query(
    'SELECT id, name, email, role FROM users WHERE id = ?',
    [userId]
  );

  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }

  res.json(user);
});
</code></pre>
<p>Here's what changed and why:</p>
<ul>
<li><p><code>/^\d+$/.test(req.params.id)</code> rejects anything that isn't a pure string of digits. <code>parseInt("12abc", 10)</code> would silently return <code>12</code> and pass further checks. The regex prevents this.</p>
</li>
<li><p><code>Number(req.params.id)</code> converts the already-validated string to a number safely.</p>
</li>
<li><p>The comparison <code>requestedId !== authenticatedId</code> enforces ownership.</p>
</li>
<li><p>Admin functionality is a separate endpoint with its own authorization check.</p>
</li>
</ul>
<p><strong>Never infer authorization from a URL parameter. Derive it from the authenticated session.</strong></p>
<h2 id="heading-2-mass-assignment">2. Mass Assignment</h2>
<p>Mass assignment is one of those vulnerabilities that's almost invisible when you write it. You're just being efficient, right? Why iterate over fields manually when you can pass the whole object?</p>
<p>The problem is that your database table knows about fields your users were never supposed to touch.</p>
<h3 id="heading-how-to-identify-this-vulnerability-in-your-code">How to Identify This Vulnerability in Your Code</h3>
<p>Here's a user profile update endpoint:</p>
<pre><code class="language-javascript">// Express.js - Vulnerable
app.put('/api/users/me', authenticate, async (req, res) =&gt; {
  const userId = req.user.id;

  // req.body contains everything the client sends
  const updates = req.body;

  await db.query(
    'UPDATE users SET ? WHERE id = ?',
    [updates, userId]
  );

  res.json({ success: true });
});
</code></pre>
<p>The <code>users</code> table has these columns:</p>
<pre><code class="language-sql">CREATE TABLE users (
  id           INT PRIMARY KEY,
  name         VARCHAR(100),
  email        VARCHAR(100),
  bio          TEXT,
  role         ENUM('user', 'moderator', 'admin') DEFAULT 'user',
  credits      INT DEFAULT 0,
  is_banned    BOOLEAN DEFAULT false
);
</code></pre>
<p>The developer intended users to update <code>name</code>, <code>email</code>, and <code>bio</code>. But <code>role</code>, <code>credits</code>, and <code>is_banned</code> are also in the table — and the query updates whatever fields the client sends.</p>
<h3 id="heading-why-this-matters">Why This Matters</h3>
<p>That request body goes straight into the SQL query. The <code>users</code> table also has <code>role</code>, <code>credits</code>, and <code>is_banned</code> — and the query doesn't know or care which fields the developer "intended" to expose.</p>
<p>A user who sends this:</p>
<pre><code class="language-json">{
  "name": "Alice",
  "role": "admin",
  "credits": 100000,
  "is_banned": false
}
</code></pre>
<p>Has just promoted themselves to admin, cleared their ban, and given themselves a hundred thousand credits.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Build the update object yourself, field by field, using an explicit allowlist:</p>
<pre><code class="language-javascript">// Express.js - Secure
app.put('/api/users/me', authenticate, async (req, res) =&gt; {
  const userId = req.user.id;

  // Only these fields may be updated by the user
  const ALLOWED_FIELDS = ['name', 'email', 'bio'];
  const updates = {};

  for (const field of ALLOWED_FIELDS) {
    if (req.body[field] !== undefined) {
      updates[field] = req.body[field];
    }
  }

  if (Object.keys(updates).length === 0) {
    return res.status(400).json({ error: 'No valid fields provided' });
  }

  // Validate individual fields
  // isValidEmail is a simple helper: /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
  if (updates.email &amp;&amp; !isValidEmail(updates.email)) {
    return res.status(400).json({ error: 'Invalid email format' });
  }

  if (updates.name &amp;&amp; (typeof updates.name !== 'string' || updates.name.length &gt; 100)) {
    return res.status(400).json({ error: 'Name must be a string of 100 characters or fewer' });
  }

  await db.query(
    'UPDATE users SET ? WHERE id = ?',
    [updates, userId]
  );

  res.json({ success: true });
});
</code></pre>
<p>For admin operations that legitimately update sensitive fields, use a separate endpoint with its own authorization:</p>
<pre><code class="language-javascript">// Admin-only endpoint for changing user roles
app.put('/api/admin/users/:id/role', authenticate, requireRole('admin'), async (req, res) =&gt; {
  if (!/^\d+$/.test(req.params.id)) {
    return res.status(400).json({ error: 'Invalid user ID' });
  }

  const userId = Number(req.params.id);
  const { role } = req.body;

  const VALID_ROLES = ['user', 'moderator', 'admin'];

  if (!VALID_ROLES.includes(role)) {
    return res.status(400).json({ error: 'Invalid role' });
  }

  await db.query(
    'UPDATE users SET role = ? WHERE id = ?',
    [role, userId]
  );

  res.json({ success: true });
});
</code></pre>
<p><strong>Don't spread</strong> <code>req.body</code> <strong>into a database query. Build the update object field by field.</strong></p>
<h2 id="heading-3-prototype-pollution">3. Prototype Pollution</h2>
<p>Prototype pollution occurs when untrusted data is merged into an object recursively, allowing an attacker to inject properties into <code>Object.prototype</code> (the base object that every plain JavaScript object inherits from).</p>
<p>The OWASP Top 10 covers this under <a href="https://owasp.org/Top10/A08_2021-Software_and_Data_Integrity_Failures/">Software and Data Integrity Failures (A08:2021)</a>.</p>
<h3 id="heading-how-to-identify-this-vulnerability-in-your-code">How to Identify This Vulnerability in Your Code</h3>
<p>A configuration merge utility that processes user-supplied settings:</p>
<pre><code class="language-javascript">// Vulnerable recursive merge function
function mergeConfig(target, source) {
  for (const key of Object.keys(source)) {
    if (typeof source[key] === 'object' &amp;&amp; source[key] !== null) {
      if (!target[key]) target[key] = {};
      mergeConfig(target[key], source[key]); // recursive call
    } else {
      target[key] = source[key]; // triggers __proto__ setter via bracket notation
    }
  }
}

app.post('/api/settings', authenticate, (req, res) =&gt; {
  const userSettings = {};
  mergeConfig(userSettings, req.body); // merge untrusted input
  applySettings(userSettings);
  res.json({ success: true });
});
</code></pre>
<h3 id="heading-why-this-matters">Why This Matters</h3>
<p>An attacker sends this request body:</p>
<pre><code class="language-json">{
  "__proto__": {
    "isAdmin": true
  }
}
</code></pre>
<p>The recursive <code>mergeConfig</code> function reaches the <code>__proto__</code> key and executes <code>target['__proto__']['isAdmin'] = true</code>. Because <code>__proto__</code> is JavaScript's prototype accessor, this writes directly to <code>Object.prototype</code>. After the merge:</p>
<pre><code class="language-javascript">const anyObject = {};
console.log(anyObject.isAdmin); // true — inherited from Object.prototype
</code></pre>
<p>Every plain object in the running application now inherits <code>isAdmin: true</code>. If any authorization check looks like this:</p>
<pre><code class="language-javascript">if (user.isAdmin) { /* grant admin access */ }
</code></pre>
<p>That check now passes for every user, regardless of their actual role.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Store user state server-side and validate each field individually. Never recursively merge untrusted input:</p>
<pre><code class="language-javascript">// Express.js - Secure
app.post('/api/settings', authenticate, async (req, res) =&gt; {
  // Load current settings from the database — never from the client
  const current = await db.query(
    'SELECT theme, language, notifications FROM user_settings WHERE user_id = ?',
    [req.user.id]
  );

  // Validate each field against an explicit allowlist
  const safeSettings = {
    theme: validateEnum(req.body.theme, ['light', 'dark'], current.theme),
    language: validateEnum(req.body.language, ['en', 'es', 'fr', 'de'], current.language),
    notifications: typeof req.body.notifications === 'boolean'
      ? req.body.notifications
      : current.notifications
  };

  await db.query(
    'UPDATE user_settings SET ? WHERE user_id = ?',
    [safeSettings, req.user.id]
  );

  res.json({ success: true });
});

function validateEnum(value, allowed, defaultValue) {
  return allowed.includes(value) ? value : defaultValue;
}
</code></pre>
<p>If you need a merge utility, use <code>Object.create(null)</code> as the base — it has no prototype, so <code>__proto__</code> can't be polluted — and allowlist keys explicitly:</p>
<pre><code class="language-javascript">// Safe merge: base object with no prototype
function safeMerge(allowedKeys, source) {
  const result = Object.create(null); // no prototype = no pollution possible

  for (const key of allowedKeys) {
    if (key in source &amp;&amp; typeof source[key] !== 'object') {
      result[key] = source[key];
    }
  }

  return result;
}
</code></pre>
<p><strong>Rules</strong>:</p>
<ul>
<li><p>Never recursively merge untrusted input into a plain object.</p>
</li>
<li><p>Store application state server-side. Don't trust clients to carry it.</p>
</li>
<li><p>Use <code>Object.create(null)</code> for data containers that will hold untrusted keys.</p>
</li>
<li><p>Validate each field by type and allowed values before using it.</p>
</li>
</ul>
<h2 id="heading-4-race-conditions">4. Race Conditions</h2>
<p>Race conditions are tricky because the code is perfectly correct. It's the timing that breaks it. Two requests arrive at almost the same moment, both check the same condition, both see a valid result, and both proceed. The result is something that should only happen once, happening twice.</p>
<p>This is called a <strong>Time-of-Check to Time-of-Use (TOCTOU)</strong> problem: the state you checked is no longer the state you're acting on.</p>
<h3 id="heading-how-to-identify-this-pattern-in-your-code">How to Identify This Pattern in Your Code</h3>
<p>A single-use coupon redemption endpoint:</p>
<pre><code class="language-javascript">// Express.js - Vulnerable
app.post('/api/redeem-coupon', authenticate, async (req, res) =&gt; {
  const { couponCode } = req.body;
  const userId = req.user.id;

  // Step 1: Check if the coupon is still valid
  const coupon = await db.query(
    'SELECT id, discount_amount, used FROM coupons WHERE code = ? AND used = false',
    [couponCode]
  );

  if (!coupon) {
    return res.status(400).json({ error: 'Invalid or already used coupon' });
  }

  // Time gap: another request can pass Step 1 here before Step 3 runs

  // Step 2: Apply the discount
  await applyDiscountToOrder(userId, coupon.discount_amount);

  // Step 3: Mark coupon as used
  await db.query(
    'UPDATE coupons SET used = true, used_by = ? WHERE code = ?',
    [userId, couponCode]
  );

  res.json({ success: true });
});
</code></pre>
<h3 id="heading-why-this-matters">Why This Matters</h3>
<p>Two requests arrive with the same coupon code at nearly the same time. Both hit Step 1 before either reaches Step 3. Both read <code>used = false</code>. Both apply the discount. One coupon, used twice.</p>
<p>The same window exists anywhere you read-then-write: balance checks before deductions, inventory checks before reservations, vote checks before incrementing. Any of them can be exploited the same way.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Replace the check-then-act pattern with an atomic operation. An atomic database update guarantees that the condition check and the write happen as a single indivisible unit:</p>
<pre><code class="language-javascript">// Express.js - Secure
app.post('/api/redeem-coupon', authenticate, async (req, res) =&gt; {
  const { couponCode } = req.body;
  const userId = req.user.id;

  const connection = await db.getConnection();

  try {
    await connection.beginTransaction();

    // Atomic: only one request can update a row where used = false.
    // The database row lock ensures only one request succeeds.
    const [result] = await connection.query(
      `UPDATE coupons
       SET used = true, used_by = ?, used_at = NOW()
       WHERE code = ? AND used = false`,
      [userId, couponCode]
    );

    if (result.affectedRows === 0) {
      await connection.rollback();
      return res.status(400).json({ error: 'Invalid or already used coupon' });
    }

    const [couponRows] = await connection.query(
      'SELECT discount_amount FROM coupons WHERE code = ?',
      [couponCode]
    );
    const coupon = couponRows[0];

    await applyDiscountToOrder(userId, coupon.discount_amount, connection);

    await connection.commit();

    res.json({ success: true, discount: coupon.discount_amount });

  } catch (error) {
    await connection.rollback();
    console.error('Coupon redemption failed:', error);
    res.status(500).json({ error: 'Could not process the coupon' });
  } finally {
    connection.release();
  }
});
</code></pre>
<p>The key change is <code>UPDATE ... WHERE code = ? AND used = false</code>. The database acquires a row lock during the update. Only one concurrent request can succeed. The second request finds <code>affectedRows = 0</code> and returns an error — correctly.</p>
<p><strong>Any time you read a value to make a decision before writing — that's a potential race condition. Make the check and the write atomic.</strong></p>
<h2 id="heading-5-business-logic-flaws">5. Business Logic Flaws</h2>
<p>Business logic flaws are the hardest category to catch. Automated scanners won't find them, and code review might miss them too, because the code works exactly as written. The problem is in what the code was designed to do, not how it does it.</p>
<p>The most common form: trusting the client to send sensible numeric values.</p>
<h3 id="heading-how-to-identify-this-vulnerability-in-your-code">How to Identify This Vulnerability in Your Code</h3>
<p>An e-commerce checkout endpoint:</p>
<pre><code class="language-javascript">// Express.js - Vulnerable
app.post('/api/checkout', authenticate, async (req, res) =&gt; {
  const { items } = req.body;

  let total = 0;
  const processedItems = [];

  for (const item of items) {
    const product = await db.query(
      'SELECT id, price FROM products WHERE id = ?',
      [item.productId]
    );

    if (!product) {
      return res.status(400).json({ error: `Product not found: ${item.productId}` });
    }

    // Trust the quantity value from the client
    const itemTotal = product.price * item.quantity;
    total += itemTotal;

    processedItems.push({ productId: product.id, quantity: item.quantity, price: product.price });
  }

  if (total &gt; 100) {
    total = total * 0.9; // 10% discount
  }

  await createOrder(req.user.id, processedItems, total);
  res.json({ success: true, total });
});
</code></pre>
<h3 id="heading-why-this-matters">Why This Matters</h3>
<p><strong>Problem 1 — Negative quantity</strong>: The code multiplies the server's price by the client's quantity without checking that the quantity is positive. A user sends <code>"quantity": -5</code> on an expensive item. Its contribution to the total becomes negative, reducing the overall total.</p>
<p><strong>Problem 2 — Floating point arithmetic</strong>: <code>0.1 + 0.2</code> in JavaScript is <code>0.30000000000000004</code>. Without rounding, financial calculations accumulate errors over time.</p>
<p><strong>Problem 3 — Discount manipulation</strong>: If a separate endpoint allows modifying an order after checkout without recalculating the total, a user could add items to earn the discount, then remove items while keeping the discounted price.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Validate every numeric input and recalculate all totals server-side. Use integer arithmetic (cents) for money to avoid floating point errors:</p>
<pre><code class="language-javascript">// Express.js - Secure
app.post('/api/checkout', authenticate, async (req, res) =&gt; {
  const { items } = req.body;

  if (!Array.isArray(items) || items.length === 0) {
    return res.status(400).json({ error: 'Cart must contain at least one item' });
  }

  if (items.length &gt; 50) {
    return res.status(400).json({ error: 'Cart cannot contain more than 50 items' });
  }

  let totalCents = 0; // Integer arithmetic avoids floating point errors
  const processedItems = [];

  for (const item of items) {
    if (!/^\d+$/.test(String(item.productId))) {
      return res.status(400).json({ error: `Invalid product ID: ${item.productId}` });
    }

    const productId = Number(item.productId);

    // Validate quantity: must be a string of digits only, between 1 and 10
    // parseInt("3abc", 10) returns 3 — we use regex to prevent this
    if (!/^\d+$/.test(String(item.quantity))) {
      return res.status(400).json({
        error: `Invalid quantity for product ${productId}`
      });
    }

    const quantity = Number(item.quantity);

    if (quantity &lt; 1 || quantity &gt; 10) {
      return res.status(400).json({
        error: `Quantity for product ${productId} must be between 1 and 10`
      });
    }

    const product = await db.query(
      'SELECT id, name, price_cents, stock FROM products WHERE id = ? AND active = true',
      [productId]
    );

    if (!product) {
      return res.status(400).json({ error: `Product not found: ${productId}` });
    }

    if (product.stock &lt; quantity) {
      return res.status(400).json({
        error: `Insufficient stock for "${product.name}"`
      });
    }

    // Use the server's price — never trust a price from the client
    totalCents += product.price_cents * quantity;

    processedItems.push({
      productId: product.id,
      name: product.name,
      quantity,
      unitPriceCents: product.price_cents
    });
  }

  // Integer arithmetic for the discount calculation
  const discountMultiplier = totalCents &gt; 10000 ? 90 : 100; // 10000 cents = $100
  const finalTotalCents = Math.round(totalCents * discountMultiplier / 100);

  await createOrder(req.user.id, processedItems, finalTotalCents);

  res.json({
    success: true,
    total: (finalTotalCents / 100).toFixed(2),
    currency: 'USD'
  });
});
</code></pre>
<p>What changed and why:</p>
<ul>
<li><p>Integer (cents) arithmetic eliminates floating point errors. <code>10000 + 3000</code> in integer cents is always exact.</p>
</li>
<li><p><code>quantity &lt; 1 || quantity &gt; 10</code> prevents negative quantities and unreasonably large orders.</p>
</li>
<li><p><code>items.length &gt; 50</code> prevents oversized requests.</p>
</li>
<li><p><code>product.stock &lt; quantity</code> ensures the order doesn't exceed available inventory.</p>
</li>
<li><p>All totals, discounts, and final prices are calculated server-side from server-side prices.</p>
</li>
</ul>
<p><strong>Define the valid range for every numeric input. Recalculate every total server-side. The client isn't a trusted source for prices or quantities.</strong></p>
<h2 id="heading-6-jwt-misconfiguration">6. JWT Misconfiguration</h2>
<p>JWTs are everywhere in Node.js APIs, and the <code>jsonwebtoken</code> library makes them easy to use. That's both good and bad: they're easy to use correctly, but they're also easy to use in ways that look fine until they're not.</p>
<p>The OWASP Top 10 classifies authentication failures under <a href="https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/">Identification and Authentication Failures (A07:2021)</a>. Three misconfigurations show up repeatedly.</p>
<h3 id="heading-how-to-identify-this-vulnerability-in-your-code">How to Identify This Vulnerability in Your Code</h3>
<p><strong>Mistake 1 — Algorithm not specified in</strong> <code>verify()</code>:</p>
<pre><code class="language-javascript">// Vulnerable
const decoded = jwt.verify(token, process.env.JWT_SECRET);
</code></pre>
<p>Without an <code>algorithms</code> option, some JWT implementations can be tricked into accepting tokens that declare <code>"alg": "none"</code> in their header — meaning no signature is required at all.</p>
<p><strong>Mistake 2 — Weak or hardcoded secrets</strong>:</p>
<pre><code class="language-javascript">// Vulnerable
const token = jwt.sign({ userId: user.id }, 'secret');
</code></pre>
<p>A short or predictable HS256 secret can be brute-forced offline if an attacker obtains a valid token.</p>
<p><strong>Mistake 3 — Sensitive data in the payload</strong>:</p>
<pre><code class="language-javascript">// Vulnerable
const token = jwt.sign({
  userId: user.id,
  passwordHash: user.passwordHash, // never do this
  role: user.role
}, secret);
</code></pre>
<p>JWT payloads are base64-encoded, not encrypted. Anyone who holds the token can decode the payload and read its contents.</p>
<h3 id="heading-how-to-fix-these-issues">How to Fix These Issues</h3>
<pre><code class="language-javascript">// Secure JWT implementation
const jwt = require('jsonwebtoken');
const crypto = require('crypto');

// Generate a strong secret once and store it as an environment variable:
// node -e "console.log(crypto.randomBytes(64).toString('hex'))"
const JWT_SECRET = process.env.JWT_SECRET;

if (!JWT_SECRET || Buffer.from(JWT_SECRET, 'hex').length &lt; 32) {
  throw new Error('JWT_SECRET must be at least 32 random bytes');
}

function signToken(userId) {
  return jwt.sign(
    { sub: userId },         // 'sub' is the standard claim for the user identifier
    JWT_SECRET,
    {
      algorithm: 'HS256',    // always declare the algorithm explicitly
      expiresIn: '15m',      // short-lived tokens reduce the window if a token is stolen
      issuer: 'your-app-name',
      audience: 'your-app-name'
    }
  );
}

function verifyToken(token) {
  return jwt.verify(token, JWT_SECRET, {
    algorithms: ['HS256'],   // whitelist only the expected algorithm
    issuer: 'your-app-name',
    audience: 'your-app-name'
  });
}

function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'No token provided' });
  }

  const token = authHeader.split(' ')[1];

  try {
    const decoded = verifyToken(token);
    req.user = { id: decoded.sub };
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}
</code></pre>
<p><strong>A note on token revocation</strong>: JWTs are stateless, meaning your server keeps no record of them. This means you can't immediately invalidate a token on logout or account compromise without extra infrastructure.</p>
<p>Common solutions include short-lived access tokens (15 minutes) paired with refresh tokens stored server-side, or a token denylist in a fast store like Redis. Choose the approach that matches your application's requirements.</p>
<p><strong>JWT security checklist</strong>:</p>
<ul>
<li><p>Always pass <code>algorithms: ['HS256']</code> to <code>verify()</code>.</p>
</li>
<li><p>Use a secret of at least 32 random bytes generated with <code>crypto.randomBytes</code>.</p>
</li>
<li><p>Set short expiration times.</p>
</li>
<li><p>Store only non-sensitive identifiers (user ID) in the payload — not emails, passwords, or roles.</p>
</li>
<li><p>Use standard claims: <code>sub</code>, <code>iss</code>, <code>aud</code>, <code>exp</code>.</p>
</li>
<li><p>Plan your revocation strategy before going to production.</p>
</li>
</ul>
<h2 id="heading-summary">Summary</h2>
<p>Here's a quick reference for the six vulnerability categories covered in this tutorial:</p>
<table>
<thead>
<tr>
<th>Vulnerability</th>
<th>Root Cause</th>
<th>Core Fix</th>
</tr>
</thead>
<tbody><tr>
<td>IDOR</td>
<td>Authorization check missing</td>
<td>Verify ownership from the authenticated session</td>
</tr>
<tr>
<td>Mass Assignment</td>
<td>All request body fields applied to database</td>
<td>Allowlist allowed fields explicitly</td>
</tr>
<tr>
<td>Prototype Pollution</td>
<td>Recursive merge of untrusted input</td>
<td>Store state server-side. Use <code>Object.create(null)</code> for merge targets.</td>
</tr>
<tr>
<td>Race Condition</td>
<td>Check-then-act without atomicity</td>
<td>Atomic <code>UPDATE ... WHERE condition</code> or transactions</td>
</tr>
<tr>
<td>Business Logic Flaw</td>
<td>Trusting client-supplied numeric values</td>
<td>Regex-validate inputs. Use integer arithmetic for money.</td>
</tr>
<tr>
<td>JWT Misconfiguration</td>
<td>No algorithm allowlist, weak secrets</td>
<td>Explicit algorithm array, strong random secret, short expiration</td>
</tr>
</tbody></table>
<p>None of these vulnerabilities requires a clever attacker. They just need code that trusts the wrong thing at the wrong time.</p>
<p>The patterns worth internalizing: always derive authorization from the session, never from the request. Validate every numeric input with a range. Make financial writes atomic. Build your JWT configuration explicitly.</p>
<p>Understanding how to fix these vulnerabilities is one side of the picture. Understanding how they're actually identified and exploited during a real security assessment is the other.</p>
<p>If you want to go deeper on the offensive side — how penetration testers approach web application targets, what they look for, and how they chain multiple flaws together — this <a href="https://hackita.it/articoli/attacchi-applicazioni-web/">guide to web application attack techniques</a> covers that perspective in detail.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a RAG Chatbot for Your Docs with Node.js, Google Gemini, and pgvector ]]>
                </title>
                <description>
                    <![CDATA[ I was helping a team that had a 200-page API documentation PDF. Every new engineer spent their first two weeks Ctrl+F-ing through it, asking the same questions in Slack, getting redirected to the same ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-rag-chatbot-nodejs-gemini-pgvector/</link>
                <guid isPermaLink="false">6a57a6aa328507d0d4d46169</guid>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ PostgreSQL ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Tutorial ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ RAG  ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Zia Ullah ]]>
                </dc:creator>
                <pubDate>Wed, 15 Jul 2026 15:26:34 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/9aa3d8d3-9c51-42a7-8e78-907802394ea1.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>I was helping a team that had a 200-page API documentation PDF. Every new engineer spent their first two weeks Ctrl+F-ing through it, asking the same questions in Slack, getting redirected to the same paragraphs on page 47.</p>
<p>The doc was accurate. It was even well-written. But nobody could find anything in it fast enough for it to be useful.</p>
<p>That's the problem RAG, or Retrieval-Augmented Generation, solves.</p>
<p>The naïve approach is to stuff your entire PDF into a prompt and let the model figure it out. That breaks down fast: context windows overflow, costs spike on every request, and the model loses the thread somewhere in the wall of text.</p>
<p>RAG takes a different approach. Your documents get broken into small chunks upfront. Ask it a question and it digs out the 3 or 4 chunks that best match it — those are what the model actually sees. The model gets a tight, focused context. The answer comes from what your document actually says — not from whatever the LLM memorized during training.</p>
<p>In this tutorial, you'll build that from scratch. Upload any PDF — an API reference, an internal spec, a research paper — and ask questions about it in plain English. The system finds the relevant sections and answers from the document itself, not from general training data.</p>
<p>The stack: Node.js with Express, Google Gemini for embeddings, Groq for text generation, and pgvector running in Docker. Every piece of it is free — no credit card, no trial period.</p>
<p>The complete code is on GitHub at <a href="https://github.com/ziaongit/nodejs-rag-chatbot">nodejs-rag-chatbot</a>.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-how-rag-works">How RAG Works</a></p>
</li>
<li><p><a href="#heading-what-were-building">What We're Building</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-set-up-postgres-with-pgvector-using-docker">Set Up Postgres with pgvector Using Docker</a></p>
</li>
<li><p><a href="#heading-connect-to-the-database">Connect to the Database</a></p>
</li>
<li><p><a href="#heading-build-the-ingestion-pipeline">Build the Ingestion Pipeline</a></p>
</li>
<li><p><a href="#heading-build-the-query-pipeline">Build the Query Pipeline</a></p>
</li>
<li><p><a href="#heading-build-the-chat-api-with-express">Build the Chat API with Express</a></p>
</li>
<li><p><a href="#heading-test-the-chatbot">Test the Chatbot</a></p>
</li>
<li><p><a href="#heading-troubleshooting">Troubleshooting</a></p>
</li>
<li><p><a href="#heading-how-to-swap-in-openai">How to Swap in OpenAI</a></p>
</li>
<li><p><a href="#heading-what-to-build-next">What to Build Next</a></p>
</li>
</ul>
<h2 id="heading-how-rag-works">How RAG Works</h2>
<p>RAG has two phases, and the code maps directly to both.</p>
<p><strong>Ingestion phase</strong> — runs once when you upload a document:</p>
<ol>
<li><p>Pull the raw text out of the PDF</p>
</li>
<li><p>Break it into chunks of 400 to 600 characters each, with a bit of overlap so nothing important gets cut at a boundary</p>
</li>
<li><p>Run each chunk through an embedding model, which turns it into a vector (a long list of numbers that captures what the text means)</p>
</li>
<li><p>Store each chunk and its vector in Postgres</p>
</li>
</ol>
<p><strong>Query phase</strong> — runs every time someone asks a question:</p>
<ol>
<li><p>Embed the user's question using the same model</p>
</li>
<li><p>Search the database for chunks whose vectors are closest to the question vector</p>
</li>
<li><p>Take the top 5 matching chunks and assemble them into a context block</p>
</li>
<li><p>Send <code>context + question</code> to the LLM and return its answer</p>
</li>
</ol>
<p>The reason this works better than keyword search: the embedding model captures <em>meaning</em>, not just exact words. If your doc says "terminate the process" and the user asks "how do I stop it?", vector similarity finds that match. Regular string matching doesn't.</p>
<p>One thing that trips people up: you must use the same embedding model at query time as you did at ingestion. The model defines the geometric space those vectors live in. Switch models halfway through and the coordinates stop meaning the same thing — you'd be comparing apples to completely different apples.</p>
<h2 id="heading-what-were-building">What We're Building</h2>
<p>The architecture is intentionally minimal: two endpoints, with nothing you don't need:</p>
<ul>
<li><p><code>POST /ingest</code>: accepts a PDF upload, chunks it, embeds each chunk, stores vectors in pgvector</p>
</li>
<li><p><code>POST /chat</code>: accepts a question, retrieves the most relevant chunks, returns an LLM-generated answer</p>
</li>
</ul>
<p>The full tech stack:</p>
<ul>
<li><p><strong>Node.js + Express</strong> — API layer</p>
</li>
<li><p><strong>Google Gemini free API</strong> — <code>gemini-embedding-001</code> for embeddings (3,072 dimensions per chunk)</p>
</li>
<li><p><strong>Groq free API</strong> — <code>llama-3.1-8b-instant</code> for text generation</p>
</li>
<li><p><strong>PostgreSQL + pgvector</strong> — vector storage and cosine similarity search, running in Docker</p>
</li>
<li><p><strong>pdf-parse</strong> — extracts raw text from PDF buffers</p>
</li>
</ul>
<p>Gemini handles embeddings and Groq handles generation. Splitting them across two providers isn't arbitrary. Gemini's generation API has a quota limit of zero in certain regions (including Pakistan), while Groq works everywhere with no restrictions. Using Groq for generation means this tutorial runs the same way regardless of where you are.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you start:</p>
<ul>
<li><p>Node.js 20+ installed on your machine</p>
</li>
<li><p>Docker Desktop running (this is how we'll run Postgres locally)</p>
</li>
<li><p>A free Google Gemini API key (for embeddings)</p>
</li>
<li><p>A free Groq API key (for text generation)</p>
</li>
</ul>
<h3 id="heading-how-to-get-your-free-gemini-api-key">How to Get Your Free Gemini API Key</h3>
<ol>
<li><p>Go to <a href="https://aistudio.google.com/app/apikey">aistudio.google.com/app/apikey</a> and sign in with a Google account</p>
</li>
<li><p>Click "Create API key"</p>
</li>
<li><p>Select "Create API key in new project"</p>
</li>
<li><p>Copy the key — it starts with <code>AIzaSy...</code></p>
</li>
</ol>
<p>No credit card or billing required.</p>
<h3 id="heading-how-to-get-your-free-groq-api-key">How to Get Your Free Groq API Key</h3>
<ol>
<li><p>Go to <a href="https://console.groq.com">console.groq.com</a> and sign up with Google</p>
</li>
<li><p>Click "API Keys" in the left sidebar</p>
</li>
<li><p>Click "Create API Key", give it a name, copy the key — it starts with <code>gsk_...</code></p>
</li>
</ol>
<p>Groq is free with generous rate limits and works in all regions.</p>
<h2 id="heading-project-setup">Project Setup</h2>
<p>Create the project directory and initialize it:</p>
<pre><code class="language-bash">mkdir nodejs-rag-chatbot
cd nodejs-rag-chatbot
npm init -y
</code></pre>
<p>Install dependencies:</p>
<pre><code class="language-bash">npm install express pg pdf-parse uuid dotenv multer
npm install --save-dev nodemon
</code></pre>
<p>A quick note on the packages: <code>multer</code> is what makes file uploads work on the <code>/ingest</code> endpoint. Without it, Express can't parse multipart form data.</p>
<p><code>pdf-parse</code> does the heavy lifting on PDFs, though watch out for scanned PDFs. Those are just images with no text layer underneath, so you'll get back an empty string.</p>
<p><code>pg</code> talks to Postgres, <code>uuid</code> gives each row a unique ID, and <code>dotenv</code> loads your keys before the app does anything.</p>
<p>Create a <code>.env</code> in the project root. It needs seven values:</p>
<pre><code class="language-plaintext">GEMINI_API_KEY=AIzaSy...         ← your Gemini key from Google AI Studio
GROQ_API_KEY=gsk_...             ← your Groq key from console.groq.com
POSTGRES_USER=rag_user
POSTGRES_PASSWORD=rag_pass       ← choose any password, this is local only
POSTGRES_DB=rag_db
DATABASE_URL=postgresql://rag_user:rag_pass@localhost:5432/rag_db
PORT=3000
</code></pre>
<p>One thing: the password in <code>POSTGRES_PASSWORD</code> and the one in <code>DATABASE_URL</code> must match exactly. I changed just one of them once and spent way too long debugging a "password authentication failed" error before realising the two values were out of sync.</p>
<p>Update <code>package.json</code> scripts:</p>
<pre><code class="language-json">"scripts": {
  "start": "node src/index.js",
  "dev": "nodemon src/index.js"
}
</code></pre>
<p>Create the <code>src</code> directory:</p>
<pre><code class="language-bash">mkdir src
</code></pre>
<p>Your final folder structure will look like this:</p>
<pre><code class="language-plaintext">nodejs-rag-chatbot/
├── src/
│   ├── index.js        ← Express app entry point
│   ├── db.js           ← Postgres connection and schema setup
│   ├── embeddings.js   ← Gemini embedding + Groq generation
│   ├── ingest.js       ← Document ingestion pipeline
│   └── query.js        ← RAG query pipeline
├── docker-compose.yml
├── .env
└── package.json
</code></pre>
<h2 id="heading-set-up-postgres-with-pgvector-using-docker">Set Up Postgres with pgvector Using Docker</h2>
<p>pgvector adds a <code>vector</code> column type to Postgres and the operators needed to search it by similarity. Normally you'd have to install it yourself, but the <code>pgvector/pgvector</code> Docker image ships with it already baked in. Just pull the image and you're good.</p>
<p>Now add <code>docker-compose.yml</code> to the project root:</p>
<pre><code class="language-yaml">services:
  postgres:
    image: pgvector/pgvector:pg16
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:
</code></pre>
<p>Those <code>${VARIABLE}</code> references get swapped out from <code>.env</code> when Compose starts — so <code>docker-compose.yml</code> itself stays clean. This is worth doing from day one. I've seen people skip this and regret it after a repo goes public.</p>
<p>Start it:</p>
<pre><code class="language-bash">docker compose up -d
</code></pre>
<h2 id="heading-connect-to-the-database">Connect to the Database</h2>
<p>Create <code>src/db.js</code>. This sets up the connection pool and creates the <code>documents</code> table on first run:</p>
<pre><code class="language-javascript">const { Pool } = require('pg');

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

async function initDb() {
  await pool.query(`CREATE EXTENSION IF NOT EXISTS vector`);

  await pool.query(`
    CREATE TABLE IF NOT EXISTS documents (
      id UUID PRIMARY KEY,
      content TEXT NOT NULL,
      source TEXT NOT NULL,
      embedding VECTOR(3072)
    )
  `);

  console.log('Database ready');
}

module.exports = { pool, initDb };
</code></pre>
<p>The <code>VECTOR(3072)</code> dimension matches the output of Gemini's <code>gemini-embedding-001</code> model exactly. If you use a different embedding model in the future, check its output dimensions and update this number to match.</p>
<h2 id="heading-build-the-ingestion-pipeline">Build the Ingestion Pipeline</h2>
<p>Start with <code>embeddings.js</code>. This file is the bridge to both external APIs — Gemini for turning text into vectors, Groq for generating the final answer. Keeping both in one place means a single file to touch if you ever swap providers.</p>
<p><strong>src/embeddings.js:</strong></p>
<pre><code class="language-javascript">const GEMINI_KEY = process.env.GEMINI_API_KEY;
const GEMINI_BASE = 'https://generativelanguage.googleapis.com/v1/models';

async function embedText(text) {
  const res = await fetch(
    `${GEMINI_BASE}/gemini-embedding-001:embedContent?key=${GEMINI_KEY}`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ content: { parts: [{ text }] } }),
    }
  );
  const data = await res.json();
  if (!res.ok) throw new Error(JSON.stringify(data));
  return data.embedding.values;
}

async function generateAnswer(context, question) {
  const res = await fetch(
    'https://api.groq.com/openai/v1/chat/completions',
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${process.env.GROQ_API_KEY}`,
      },
      body: JSON.stringify({
        model: 'llama-3.1-8b-instant',
        messages: [
          {
            role: 'system',
            content: 'You are a helpful assistant. Answer the question using only the context provided. If the context does not contain enough information, say so clearly.',
          },
          {
            role: 'user',
            content: `Context:\n${context}\n\nQuestion: ${question}`,
          },
        ],
      }),
    }
  );
  const data = await res.json();
  if (!res.ok) throw new Error(JSON.stringify(data));
  return data.choices[0].message.content;
}

module.exports = { embedText, generateAnswer };
</code></pre>
<p>We're calling both APIs directly with Node.js's built-in <code>fetch</code> rather than the official SDKs. The reason is practical: Google's Node.js SDK routes requests through the <code>v1beta</code> endpoint by default, and <code>gemini-embedding-001</code> isn't available there — only on <code>v1</code>. Direct fetch sidesteps that entirely and keeps the dependency count low.</p>
<p><strong>src/ingest.js:</strong></p>
<pre><code class="language-javascript">const pdfParse = require('pdf-parse');
const { v4: uuidv4 } = require('uuid');
const { pool } = require('./db');
const { embedText } = require('./embeddings');

function chunkText(text, chunkSize = 500, overlap = 50) {
  const chunks = [];
  let start = 0;

  while (start &lt; text.length) {
    const end = Math.min(start + chunkSize, text.length);
    chunks.push(text.slice(start, end).trim());
    start += chunkSize - overlap;
  }

  return chunks.filter(chunk =&gt; chunk.length &gt; 50);
}

async function ingestDocument(buffer, filename) {
  const { text } = await pdfParse(buffer);
  const chunks = chunkText(text);

  console.log(`Processing ${chunks.length} chunks from "${filename}"`);

  for (const chunk of chunks) {
    const embedding = await embedText(chunk);

    await pool.query(
      `INSERT INTO documents (id, content, source, embedding)
       VALUES ($1, $2, $3, $4::vector)`,
      [uuidv4(), chunk, filename, JSON.stringify(embedding)]
    );
  }

  return chunks.length;
}

module.exports = { ingestDocument };
</code></pre>
<p>500 characters per chunk, with 50 characters of overlap between neighbours.</p>
<p>Why the overlap? Without it, a sentence that straddles a boundary gets split, half in one chunk, half in the next — and neither piece makes sense on its own when retrieved. The overlap keeps those boundary sentences intact.</p>
<p>For most technical docs, 500 is a good starting point. Dense legal or financial text tends to need something closer to 300.</p>
<h2 id="heading-build-the-query-pipeline">Build the Query Pipeline</h2>
<p><strong>src/query.js:</strong></p>
<pre><code class="language-javascript">const { pool } = require('./db');
const { embedText, generateAnswer } = require('./embeddings');

async function queryDocuments(question) {
  const questionEmbedding = await embedText(question);

  const { rows } = await pool.query(
    `SELECT content, source,
            1 - (embedding &lt;=&gt; $1::vector) AS similarity
     FROM documents
     ORDER BY embedding &lt;=&gt; $1::vector
     LIMIT 5`,
    [JSON.stringify(questionEmbedding)]
  );

  if (rows.length === 0) {
    return { answer: 'No relevant documents found.', sources: [] };
  }

  const context = rows.map(r =&gt; r.content).join('\n\n---\n\n');
  const answer = await generateAnswer(context, question);

  return {
    answer,
    sources: [...new Set(rows.map(r =&gt; r.source))],
    topSimilarity: parseFloat(rows[0].similarity).toFixed(3),
  };
}

module.exports = { queryDocuments };
</code></pre>
<p>The <code>&lt;=&gt;</code> operator is pgvector's cosine distance. Semantically similar text produces vectors that point in the same direction — so the distance between them is small. Flip that with <code>1 - distance</code> and you get a similarity score, where anything close to 1 means a strong match.</p>
<p>I found 0.7 to be a reliable threshold in my testing — chunks above that were almost always relevant. Anything below 0.5 and the retrieval was really stretching, pulling chunks that shared a keyword or two but weren't actually answering the question.</p>
<p>When that happens, the system prompt instruction ("if the context does not contain enough information, say so clearly") becomes important. A well-behaved model will tell the user it doesn't know rather than guess.</p>
<p>We also surface the source filename. Once you've ingested more than one document, users need to know whether that answer came from the architecture spec or the incident report.</p>
<h2 id="heading-build-the-chat-api-with-express">Build the Chat API with Express</h2>
<p><strong>src/index.js:</strong></p>
<pre><code class="language-javascript">require('dotenv').config();
const express = require('express');
const multer = require('multer');
const { initDb } = require('./db');
const { ingestDocument } = require('./ingest');
const { queryDocuments } = require('./query');

const app = express();
const upload = multer({ storage: multer.memoryStorage() });

app.use(express.json());

app.post('/ingest', upload.single('file'), async (req, res) =&gt; {
  if (!req.file) {
    return res.status(400).json({ error: 'No file uploaded' });
  }

  if (!req.file.mimetype.includes('pdf')) {
    return res.status(400).json({ error: 'Only PDF files are supported' });
  }

  try {
    const count = await ingestDocument(req.file.buffer, req.file.originalname);
    res.json({ message: `Ingested ${count} chunks from "${req.file.originalname}"` });
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'Ingestion failed', detail: err.message });
  }
});

app.post('/chat', async (req, res) =&gt; {
  const { question } = req.body;

  if (!question || typeof question !== 'string') {
    return res.status(400).json({ error: 'question is required' });
  }

  try {
    const result = await queryDocuments(question);
    res.json(result);
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'Query failed', detail: err.message });
  }
});

const PORT = process.env.PORT || 3000;

initDb().then(() =&gt; {
  app.listen(PORT, () =&gt; {
    console.log(`RAG chatbot running on port ${PORT}`);
  });
});
</code></pre>
<p><code>memoryStorage()</code> keeps the uploaded file in a buffer instead of writing it to disk. We parse it and store the chunks immediately, so there's nothing to save.</p>
<h2 id="heading-test-the-chatbot">Test the Chatbot</h2>
<p>Start the server:</p>
<pre><code class="language-bash">npm run dev
</code></pre>
<p>You should see:</p>
<pre><code class="language-plaintext">Database ready
RAG chatbot running on port 3000
</code></pre>
<p>Upload a PDF. Any PDF works. I tested with a copy of a Node.js best practices guide:</p>
<pre><code class="language-bash"># Linux / macOS
curl -X POST http://localhost:3000/ingest -F "file=@your-document.pdf"

# Windows PowerShell
curl.exe -X POST http://localhost:3000/ingest -F "file=@your-document.pdf"
</code></pre>
<p>Response:</p>
<pre><code class="language-json">{ "message": "Ingested 142 chunks from \"your-document.pdf\"" }
</code></pre>
<p>Now ask a question:</p>
<pre><code class="language-bash"># Linux / macOS
curl -X POST http://localhost:3000/chat \
  -H "Content-Type: application/json" \
  -d '{ "question": "How should I handle errors in async functions?" }'

# Windows PowerShell
curl.exe -X POST http://localhost:3000/chat -H "Content-Type: application/json" -d "{\"question\": \"How should I handle errors in async functions?\"}"
</code></pre>
<p>Response:</p>
<pre><code class="language-json">{
  "answer": "For async functions in Node.js, wrap your logic in a try/catch block to handle rejected promises. In Express, pass the caught error to next(err) to trigger your error-handling middleware. Alternatively, you can create a wrapper function that wraps any async route handler in a promise and calls next on rejection, keeping your route handlers clean...",
  "sources": ["your-document.pdf"],
  "topSimilarity": "0.841"
}
</code></pre>
<p>The <code>topSimilarity</code> score tells you how well the retrieval went. Above 0.7 and the chunks pulled were genuinely relevant. Below 0.5, and the search was struggling: it found something, but not something that actually answers the question.</p>
<p>Try asking about something your PDF doesn't mention. If the system prompt is doing its job, the model should say it doesn't have enough information rather than making something up. That's the behaviour you want in production.</p>
<p>The repo includes two diagnostic scripts that are useful if anything isn't working:</p>
<ul>
<li><p><code>node test-keys.js</code> — tests both API keys live and reports whether each one succeeds</p>
</li>
<li><p><code>node list-models.js</code> — fetches the full list of Gemini models available to your API key</p>
</li>
</ul>
<p>Run these before diving into the troubleshooting section below.</p>
<h2 id="heading-troubleshooting">Troubleshooting</h2>
<p>Everything in this section is a real error I hit while building this. Nothing hypothetical.</p>
<h3 id="heading-port-5432-is-already-in-use">Port 5432 is already in use</h3>
<pre><code class="language-plaintext">Error: bind: address already in use
</code></pre>
<p>Something else — probably a local Postgres install — is already on that port. Two fixes are needed. First, in <code>docker-compose.yml</code>:</p>
<pre><code class="language-yaml">ports:
  - "5433:5432"
</code></pre>
<p>Second, update <code>DATABASE_URL</code> in <code>.env</code>:</p>
<pre><code class="language-plaintext">DATABASE_URL=postgresql://rag_user:rag_pass@localhost:5433/rag_db
</code></pre>
<p>The container itself still listens on 5432 internally. You're just changing which port your machine uses to reach it.</p>
<h3 id="heading-password-authentication-failed-for-user-raguser">Password authentication failed for user "rag_user"</h3>
<pre><code class="language-plaintext">Error: password authentication failed for user "rag_user"
</code></pre>
<p>The password Postgres was initialized with doesn't match what your app is sending. Open <code>.env</code> and compare <code>POSTGRES_PASSWORD</code> with the password embedded in <code>DATABASE_URL</code>. They need to be character-for-character identical.</p>
<p>After fixing the mismatch, the old volume still has the wrong password baked into it. You must destroy it and start fresh:</p>
<pre><code class="language-bash">docker compose down -v
docker compose up -d
</code></pre>
<p>The <code>-v</code> flag deletes the data volume. Postgres reinitializes on the next start with the credentials from your current <code>.env</code>.</p>
<h3 id="heading-gemini-model-not-found-404">Gemini model not found (404)</h3>
<pre><code class="language-json">{ "error": { "code": 404, "message": "models/text-embedding-004 is not found" } }
</code></pre>
<p>The Google AI model naming has changed. Older tutorials and blog posts reference model names that no longer exist on the v1 endpoint. The correct model for this stack is <code>gemini-embedding-001</code>. That's what this repo uses.</p>
<p>If you want to see every model available to your API key, run:</p>
<pre><code class="language-bash">node list-models.js
</code></pre>
<p>That script fetches the live list directly from the API so you're not guessing.</p>
<h3 id="heading-vector-dimension-mismatch">Vector dimension mismatch</h3>
<pre><code class="language-plaintext">ERROR: expected 768 dimensions, not 3072
</code></pre>
<p>This error appears when your database table was created with a different dimension count than what your embedding model produces. <code>gemini-embedding-001</code> outputs 3,072-dimensional vectors. The <code>documents</code> table in this tutorial uses <code>VECTOR(3072)</code> to match.</p>
<p>If you get this error, it means either an old table exists with the wrong dimension, or you changed embedding models without recreating the table. Drop the data volume and restart:</p>
<pre><code class="language-bash">docker compose down -v
docker compose up -d
</code></pre>
<h3 id="heading-vector-index-dimension-limit">Vector index dimension limit</h3>
<pre><code class="language-plaintext">ERROR: ivfflat index type only supports up to 2000 dimensions
</code></pre>
<p>pgvector's <code>ivfflat</code> and <code>hnsw</code> index types have a maximum dimension of 2000. Since <code>gemini-embedding-001</code> produces 3,072-dimensional vectors, neither index type works.</p>
<p>This tutorial drops the index and lets pgvector do a full scan — fine for development and any reasonably sized corpus. Scaling to thousands of documents in production? Pick a model under 2000 dimensions. OpenAI's <code>text-embedding-3-small</code> outputs 1536 and plays nicely with both index types.</p>
<h3 id="heading-port-3000-is-already-in-use">Port 3000 is already in use</h3>
<pre><code class="language-plaintext">Error: EADDRINUSE: address already in use :::3000
</code></pre>
<p>Some other process got there first. Swap the port number in <code>.env</code>:</p>
<pre><code class="language-plaintext">PORT=3002
</code></pre>
<p>Save it and restart the server.</p>
<h3 id="heading-gemini-generation-returns-quota-exceeded-limit-0">Gemini generation returns quota exceeded (limit: 0)</h3>
<pre><code class="language-json">{ "error": { "status": "RESOURCE_EXHAUSTED", "message": "Quota exceeded for quota metric ... with limit 0" } }
</code></pre>
<p>That <code>limit 0</code> means Google has switched off free generation in your country entirely — not that you've used it up. I hit this myself while testing from Pakistan.</p>
<p>That's exactly why this tutorial uses Groq instead. Make sure <code>GROQ_API_KEY</code> is in your <code>.env</code> and that <code>generateAnswer</code> in <code>src/embeddings.js</code> is pointing at <code>api.groq.com</code>.</p>
<p>To verify both keys work, run:</p>
<pre><code class="language-bash">node test-keys.js
</code></pre>
<p>It tests the Gemini embedding endpoint and the Groq generation endpoint independently and reports whether each succeeds.</p>
<h3 id="heading-nodemon-doesnt-pick-up-changes-to-env">nodemon doesn't pick up changes to <code>.env</code></h3>
<p>nodemon only watches <code>.js</code> files — <code>.env</code> changes don't trigger a restart. Switch to the terminal running the server and type <code>rs</code>, then hit Enter. That forces a restart and picks up whatever you changed.</p>
<h3 id="heading-curl-doesnt-work-in-windows-powershell"><code>curl</code> doesn't work in Windows PowerShell</h3>
<pre><code class="language-plaintext">curl : The term 'curl' is not recognized
</code></pre>
<p>or</p>
<pre><code class="language-plaintext">curl : Cannot bind parameter because parameter 'Method' is specified more than once
</code></pre>
<p>PowerShell has a built-in <code>curl</code> alias that points to <code>Invoke-WebRequest</code> — completely different flags, completely different behaviour. Add <code>.exe</code> and you bypass the alias and hit the real binary.</p>
<p>So instead of <code>curl</code>, type <code>curl.exe</code>:</p>
<pre><code class="language-powershell"># Ingest
curl.exe -X POST http://localhost:3000/ingest -F "file=@your-document.pdf"

# Chat
curl.exe -X POST http://localhost:3000/chat -H "Content-Type: application/json" -d "{\"question\": \"How do I handle async errors?\"}"
</code></pre>
<p>That <code>.exe</code> is the whole fix.</p>
<h3 id="heading-docker-desktop-stopped-running">Docker Desktop stopped running</h3>
<p>Docker Desktop doesn't start automatically after a reboot on most setups. If your Docker commands suddenly fail with connection errors, that's probably why. Open Docker Desktop, wait until it says "Engine running", then try again.</p>
<h2 id="heading-how-to-swap-in-openai">How to Swap in OpenAI</h2>
<p>If you want to use the OpenAI API instead of Gemini, it's three changes.</p>
<p>1. Install the OpenAI SDK:</p>
<pre><code class="language-bash">npm install openai
</code></pre>
<p>2. Replace <code>src/embeddings.js</code> entirely:</p>
<pre><code class="language-javascript">const OpenAI = require('openai');

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

async function embedText(text) {
  const result = await client.embeddings.create({
    model: 'text-embedding-3-small',
    input: text,
  });
  return result.data[0].embedding;
}

async function generateAnswer(context, question) {
  const result = await client.chat.completions.create({
    model: 'gpt-4o-mini',
    messages: [
      { role: 'system', content: 'Answer only from the context provided. If the context is insufficient, say so.' },
      { role: 'user', content: `Context:\n${context}\n\nQuestion: ${question}` },
    ],
  });
  return result.choices[0].message.content;
}

module.exports = { embedText, generateAnswer };
</code></pre>
<p>3. Update the vector dimension in <code>src/db.js</code>:</p>
<p>Open <code>db.js</code> and swap <code>VECTOR(3072)</code> for <code>VECTOR(1536)</code> — that's the output size of <code>text-embedding-3-small</code>. Then kill the volume so the table gets recreated with the right dimensions:</p>
<pre><code class="language-bash">docker compose down -v
docker compose up -d
</code></pre>
<p>Nothing else needs touching. The ingestion and query logic works the same regardless of which model you plugged in.</p>
<h2 id="heading-what-to-build-next">What to Build Next</h2>
<p>What you've built works. But there are some gaps that come up quickly once you put it in front of real users.</p>
<p>The most noticeable one is <strong>streaming</strong>. Right now <code>/chat</code> holds the connection open until Groq finishes generating the full answer, then returns everything at once. On a short question that's fine. On a longer one, the user stares at nothing for a few seconds and wonders if the request hung.</p>
<p>The Groq API supports streaming — add <code>stream: true</code> to the request body and tokens start coming back as they're generated. Piping those through Express with <code>res.write()</code> is maybe 15 minutes of work and the difference in feel is immediate.</p>
<p><strong>Metadata filtering</strong> is the second thing you'll want. Once you've loaded more than a few documents, queries bleed across everything: ask about the API spec and you'll get chunks from the onboarding guide too.</p>
<p>The fix is a <code>metadata JSONB</code> column where you store the document ID on ingest, then add <code>WHERE metadata-&gt;&gt;'doc_id' = $1</code> to the similarity query. Expose it as an optional body field on <code>/chat</code>: <code>{ "question": "...", "docId": "api-spec-v2" }</code>. Users get scoped results, and you get much cleaner answers.</p>
<p>When your corpus grows into the hundreds of documents, look at <strong>re-ranking</strong>. Vector similarity retrieval is fast but approximate — it finds chunks that are semantically close to the question, not necessarily the ones that most directly answer it.</p>
<p>The pattern is: retrieve the top 20 by cosine distance, then run a cross-encoder over them to re-score by actual relevance, then take the best 5 from that second pass. LangChain.js has a cross-encoder wrapper if you don't want to implement it yourself.</p>
<p>The last thing most people forget until they actually need it is <strong>document management</strong> — the ability to list what's ingested, delete a specific file, and re-ingest an updated version.</p>
<p>A <code>DELETE FROM documents WHERE source = $1</code> handles the delete case. Add a <code>GET /documents</code> endpoint that queries <code>SELECT DISTINCT source FROM documents</code> and you have a complete enough API for real use.</p>
<p>RAG isn't magic. It's a well-scoped retrieval problem combined with a language model that's been told to stay within its lane.</p>
<p>The quality of your answers depends on three things: how cleanly your PDFs parse, how well your chunk size fits the content type, and how clearly your system prompt instructs the model to say "I don't know" rather than guess. Get those right and you've built something genuinely useful: the kind of thing that saves a new engineer's first two weeks.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Containerize a Node.js Application with Docker and Deploy with GitHub Actions ]]>
                </title>
                <description>
                    <![CDATA[ If you've been building Node.js projects, you've probably had an experience like this. The project runs fine on your machine, but when you push it to a server, something breaks. Maybe it's a different ]]>
                </description>
                <link>https://www.freecodecamp.org/news/containerize-a-node-js-app-with-docker-and-deploy-with-github-actions/</link>
                <guid isPermaLink="false">6a569b9cbd138d774dee2042</guid>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ GitHub Actions ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ci-cd ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker compose ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker-compose.yml ]]>
                    </category>
                
                    <category>
                        <![CDATA[ containerization ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Backend Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Tutorial ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Zia Ullah ]]>
                </dc:creator>
                <pubDate>Tue, 14 Jul 2026 20:27:08 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/343864e6-5319-4378-a2b1-4955e38ad6d8.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you've been building <a href="https://www.freecodecamp.org/news/role-based-access-control-nodejs-rest-api-jwt/">Node.js projects</a>, you've probably had an experience like this. The project runs fine on your machine, but when you push it to a server, something breaks.</p>
<p>Maybe it's a different Node version, maybe an environment variable is missing, or maybe a system dependency doesn't match. You spend an hour debugging something that was never actually a code problem.</p>
<p>Docker fixes this at the root. With Docker, you stop shipping just code. The Node version, dependencies, and config all travel inside the container. Your laptop, a CI server, a production VM — it behaves the same on all of them. No more environment surprises.</p>
<p>In this tutorial, we'll go through all this step by step: a multi-stage Dockerfile, using Docker Compose with PostgreSQL for local development, and a GitHub Actions workflow that pushes a fresh image to Docker Hub on every merge to <code>main</code>.</p>
<p>The complete code for this tutorial is available on <a href="https://github.com/ziaongit/nodejs-docker-cicd">GitHub</a>.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-sample-application">The Sample Application</a></p>
</li>
<li><p><a href="#heading-writing-the-dockerfile">Writing the Dockerfile</a></p>
</li>
<li><p><a href="#heading-the-dockerignore-file">The .dockerignore File</a></p>
</li>
<li><p><a href="#heading-the-gitignore-file">The .gitignore File</a></p>
</li>
<li><p><a href="#heading-build-and-test-the-image-locally">Build and Test the Image Locally</a></p>
</li>
<li><p><a href="#heading-docker-compose-for-local-development">Docker Compose for Local Development</a></p>
</li>
<li><p><a href="#heading-automate-the-build-with-github-actions">Automate the Build with GitHub Actions</a></p>
</li>
<li><p><a href="#heading-deploying-the-image">Deploying the Image</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Node.js 18+</p>
</li>
<li><p>Docker Desktop, which you can download at <a href="https://docs.docker.com/get-docker/">docs.docker.com/get-docker</a>. Windows users need WSL 2 before Docker starts. Open PowerShell as Administrator and run <code>wsl --install</code>. After the restart, Docker Desktop will install without issues.</p>
</li>
<li><p>A GitHub account</p>
</li>
<li><p>A Docker Hub account (free at <a href="https://hub.docker.com">hub.docker.com</a>)</p>
</li>
<li><p>Some Express.js experience helps, but isn't required</p>
</li>
</ul>
<h2 id="heading-the-sample-application">The Sample Application</h2>
<p>We're building a task management API with Express and PostgreSQL. Keep in mind the app is just a vehicle to teach you how this works. The Dockerfile and pipeline we set up here work the same way for any Node.js project.</p>
<p>Create the project:</p>
<pre><code class="language-bash">mkdir nodejs-docker-cicd &amp;&amp; cd nodejs-docker-cicd
npm init -y
npm install express pg dotenv
npm install --save-dev nodemon
</code></pre>
<p>Create <code>src/index.js</code>:</p>
<pre><code class="language-javascript">const express = require('express');
const { Pool } = require('pg');
require('dotenv').config();

const app = express();
app.use(express.json());

const pool = new Pool({
  host: process.env.DB_HOST,
  port: process.env.DB_PORT,
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
});

// Create table on startup
pool.query(`
  CREATE TABLE IF NOT EXISTS tasks (
    id SERIAL PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    completed BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP DEFAULT NOW()
  )
`).catch(console.error);

// Health check — required for Docker HEALTHCHECK and load balancers
app.get('/health', (req, res) =&gt; {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

app.get('/tasks', async (req, res) =&gt; {
  try {
    const result = await pool.query('SELECT * FROM tasks ORDER BY created_at DESC');
    res.json(result.rows);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

app.post('/tasks', async (req, res) =&gt; {
  const { title } = req.body;
  if (!title) return res.status(400).json({ error: 'Title is required' });
  try {
    const result = await pool.query(
      'INSERT INTO tasks (title) VALUES ($1) RETURNING *',
      [title]
    );
    res.status(201).json(result.rows[0]);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

app.patch('/tasks/:id', async (req, res) =&gt; {
  const { id } = req.params;
  const { completed } = req.body;
  try {
    const result = await pool.query(
      'UPDATE tasks SET completed = $1 WHERE id = $2 RETURNING *',
      [completed, id]
    );
    if (result.rows.length === 0) return res.status(404).json({ error: 'Task not found' });
    res.json(result.rows[0]);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () =&gt; console.log(`Server running on port ${PORT}`));
</code></pre>
<p>Open <code>package.json</code> and update the <code>"scripts"</code> section:</p>
<pre><code class="language-json">"scripts": {
  "start": "node src/index.js",
  "dev": "nodemon src/index.js"
}
</code></pre>
<p><code>npm start</code> runs the app directly with Node. <code>npm run dev</code> uses nodemon so the server restarts automatically when you edit a file.</p>
<p>For running without Docker, create a <code>.env</code> file:</p>
<pre><code class="language-plaintext">DB_HOST=localhost
DB_PORT=5432
DB_NAME=tasksdb
DB_USER=postgres
DB_PASSWORD=yourpassword
PORT=3000
</code></pre>
<p>Notice that all database credentials come from environment variables rather than being hardcoded. Swap the variables, and the same image runs against your local database or a production one — no code changes needed. The <code>/health</code> endpoint is what Docker pings to know the app is actually handling requests.</p>
<h2 id="heading-writing-the-dockerfile">Writing the Dockerfile</h2>
<p>Before touching the Dockerfile, there are two terms you'll keep seeing. An <strong>image</strong> is a packaged, immutable version of your app — Node runtime, code, dependencies, everything together in one artifact. A <strong>container</strong> is a running instance of that image. One image, many containers, any machine.</p>
<p>Here's the Dockerfile we'll use:</p>
<pre><code class="language-dockerfile"># ── Stage 1: Install dependencies ──────────────────────────────────────────
FROM node:18-alpine AS builder

WORKDIR /app

# Copy package files first — Docker caches this layer separately.
# If you only change src code (not package.json), Docker skips npm ci on rebuild.
COPY package*.json ./
RUN npm ci

COPY . .


# ── Stage 2: Production image ───────────────────────────────────────────────
FROM node:18-alpine AS production

# Create a non-root user — running as root inside a container is a security risk
RUN addgroup -g 1001 -S nodejs &amp;&amp; \
    adduser -S nodeuser -u 1001

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

# Copy only the source code from the builder stage (not node_modules or dev files)
COPY --from=builder /app/src ./src

RUN chown -R nodeuser:nodejs /app
USER nodeuser

EXPOSE 3000

# Docker will ping /health every 30s. If it fails 3 times, the container is marked unhealthy.
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

CMD ["node", "src/index.js"]
</code></pre>
<p>This is a multi-stage build. The first stage (<code>builder</code>) installs everything, including dev dependencies. The second stage (<code>production</code>) starts fresh and only copies what the app needs to run. Nodemon, test frameworks, and anything else dev-only never make it into the final image.</p>
<p>The size difference is real. A <code>node:18</code> Debian image is over 950MB. Switch to <code>node:18-alpine</code> and cut out the dev dependencies, and the final image lands around 150–200MB instead. A smaller image means faster pushes and faster deploys.</p>
<p><code>npm ci</code> instead of <code>npm install</code> is a deliberate choice for CI/CD. It reads exact versions from <code>package-lock.json</code> and fails hard if the lockfile doesn't match <code>package.json</code>. Every build on every machine installs the exact same versions — no surprises from a dependency that quietly updated overnight.</p>
<p>The <code>nodeuser</code> account exists because containers run as root by default. That's fine until something goes wrong. A non-root user means that an attacker who gets inside the container can't just do whatever they want.</p>
<h2 id="heading-the-dockerignore-file">The <code>.dockerignore</code> File</h2>
<p>Create <code>.dockerignore</code> before building:</p>
<pre><code class="language-plaintext">node_modules
npm-debug.log
.env
.git
.gitignore
README.md
Dockerfile
.dockerignore
</code></pre>
<p>The <code>node_modules</code> exclusion is the critical one. Your local modules were compiled for your operating system — macOS or Windows binaries won't work inside a Linux container. Excluding them means Docker installs fresh modules during the build, compiled for the correct platform. Without this exclusion, you'd either copy broken binaries into the image or waste time uploading hundreds of megabytes to the build context.</p>
<p>Never put <code>.env</code> in an image. Passwords, API keys, anything sensitive — those go in at runtime as environment variables, never inside the image itself.</p>
<h2 id="heading-the-gitignore-file">The <code>.gitignore</code> File</h2>
<p>One more thing before the first commit: a <code>.gitignore</code>. You don't want <code>node_modules</code> or <code>.env</code> tracked:</p>
<pre><code class="language-plaintext">node_modules/
.env
.env.local
npm-debug.log*
logs/
.DS_Store
Thumbs.db
.vscode/
.idea/
dist/
build/
</code></pre>
<h2 id="heading-build-and-test-the-image-locally">Build and Test the Image Locally</h2>
<p>Open Docker Desktop first and give it a moment. On Windows, you'll see a whale icon in the taskbar that animates while the engine is starting up. Once it goes still, you're good to run Docker commands. If you try to run Docker before the engine is up, you'll hit this:</p>
<pre><code class="language-plaintext">ERROR: Error response from daemon: Docker Desktop is unable to start
</code></pre>
<p>If that happens, quit Docker Desktop. Open PowerShell as Administrator, run <code>wsl --update</code>, and restart. Then go to Control Panel → Programs → Turn Windows features on or off. Both Hyper-V and Virtual Machine Platform need to be checked. After the restart, Docker Desktop should come up fine.</p>
<p>It's worth knowing about this error too:</p>
<pre><code class="language-plaintext">docker : The term 'docker' is not recognized as the name of a cmdlet, function,
script file, or operable program.
</code></pre>
<p>This means that Docker Desktop isn't running or isn't installed. Open it from the Start menu and wait.</p>
<p>Run the build:</p>
<pre><code class="language-bash">docker build -t nodejs-docker-cicd:latest .
</code></pre>
<p>The first time takes roughly 30 seconds since Docker has to pull <code>node:18-alpine</code> from the internet. Once that's cached, subsequent builds are much quicker. Both stages will scroll by:</p>
<pre><code class="language-plaintext">[+] Building 33.1s (17/17) FINISHED
 =&gt; [builder 1/5] FROM docker.io/library/node:18-alpine       20.9s
 =&gt; [builder 4/5] RUN npm ci                                   3.5s
 =&gt; [production 5/7] RUN npm ci --only=production              3.2s
 =&gt; [production 7/7] RUN chown -R nodeuser:nodejs /app         3.2s
 =&gt; exporting to image                                         1.5s
 =&gt; =&gt; naming to docker.io/library/nodejs-docker-cicd:latest     0.0s
</code></pre>
<p>When you see <code>(17/17) FINISHED</code> the image is built. Check the size:</p>
<pre><code class="language-bash">docker images nodejs-docker-cicd
</code></pre>
<pre><code class="language-plaintext">IMAGE                     ID             DISK USAGE   CONTENT SIZE
nodejs-docker-cicd:latest   c9eed311d999        198MB         47.5MB
</code></pre>
<p><strong>CONTENT SIZE</strong> (47.5MB) is the compressed size that gets pushed to Docker Hub. <strong>DISK USAGE</strong> (198MB) is what it takes up on disk locally. Compare that to a <code>node:18</code> Debian image at 950MB+, and you can see why the Alpine base and multi-stage approach matter.</p>
<p>On subsequent builds, Docker reuses cached layers. Edit only your source files without touching <code>package.json</code> and the <code>npm ci</code> step gets skipped completely. That 33-second first build becomes 3 seconds.</p>
<h2 id="heading-docker-compose-for-local-development">Docker Compose for Local Development</h2>
<p>The app needs a database. Setting up PostgreSQL locally means every developer who clones the repo has to do it, too. Docker Compose handles this: one file defines both services, and one command starts them.</p>
<p>Create <code>docker-compose.yml</code>:</p>
<pre><code class="language-yaml">services:
  app:
    build:
      context: .
      target: production
    ports:
      - '3000:3000'
    environment:
      DB_HOST: postgres
      DB_PORT: 5432
      DB_NAME: tasksdb
      DB_USER: postgres
      DB_PASSWORD: postgres
      PORT: 3000
    depends_on:
      postgres:
        condition: service_healthy
    restart: unless-stopped

  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: tasksdb
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    ports:
      - '5432:5432'
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U postgres']
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:
</code></pre>
<p>A few things worth pointing out. <code>DB_HOST</code> is set to <code>postgres</code>. That's the service name, not <code>localhost</code>. Containers on the same Docker network reach each other by service name. Put <code>localhost</code> there and the app tries to connect to itself.</p>
<p><code>depends_on</code> with <code>condition: service_healthy</code> holds the app back until Postgres actually passes its health check. Skip this and the app starts, tries to connect to a database that isn't ready yet, and crashes. The health check pings <code>pg_isready</code> every 5 seconds. Once it gets a green response, the app container starts.</p>
<p>The named volume <code>postgres_data</code> keeps your data alive between restarts. Run <code>docker compose down</code> and the data is still there next time. Add <code>--volumes</code> to wipe it clean.</p>
<p>Start both services:</p>
<pre><code class="language-bash">docker compose up --build
</code></pre>
<p>You'll see PostgreSQL initialize and then the app start. Once you see <code>Server running on port 3000</code> in the logs, the stack is up.</p>
<p>Open a second terminal to test — leave the compose logs running in the first one.</p>
<p><strong>Linux/macOS:</strong></p>
<pre><code class="language-bash">curl -X POST http://localhost:3000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn Docker"}'

curl http://localhost:3000/tasks

curl http://localhost:3000/health
</code></pre>
<p><strong>Windows PowerShell:</strong> Typing <code>curl</code> in PowerShell runs <code>Invoke-WebRequest</code>, not actual curl. Run <code>curl.exe</code> instead. For JSON bodies, write to a file first:</p>
<pre><code class="language-powershell">'{"title": "Learn Docker"}' | Set-Content body.json
curl.exe -X POST http://localhost:3000/tasks -H "Content-Type: application/json" --data `@body.json

curl.exe http://localhost:3000/tasks

curl.exe http://localhost:3000/health
</code></pre>
<p>The backtick before <code>@body.json</code> is necessary. PowerShell would otherwise try to interpret <code>@</code> as a splatting operator rather than passing it to curl as a filename prefix.</p>
<p>You should see responses like these:</p>
<pre><code class="language-json"># POST /tasks
{"id":1,"title":"Learn Docker","completed":false,"created_at":"2026-07-09T22:21:17.073Z"}

# GET /tasks
[{"id":1,"title":"Learn Docker","completed":false,"created_at":"2026-07-09T22:21:17.073Z"}]

# GET /health
{"status":"ok","timestamp":"2026-07-09T22:11:44.700Z"}
</code></pre>
<p>The task hit PostgreSQL in one container and came back through the app. <code>Ctrl+C</code> in the compose terminal stops both.</p>
<h2 id="heading-automate-the-build-with-github-actions">Automate the Build with GitHub Actions</h2>
<p>The image works locally, so it's time to stop doing this by hand.</p>
<h3 id="heading-step-1-create-a-docker-hub-access-token">Step 1: Create a Docker Hub Access Token</h3>
<p>Go to <a href="https://hub.docker.com">hub.docker.com</a> and then Account Settings → Security → New Access Token. Set permission to Read &amp; Write, as read-only breaks the push. The token appears once, so copy it before closing the page.</p>
<p><strong>Security warning:</strong> Don't paste this token into a chat, email, or commit. If you expose it by accident, delete it immediately, then make a new one.</p>
<h3 id="heading-step-2-add-secrets-to-your-github-repository">Step 2: Add Secrets to Your GitHub Repository</h3>
<p>Head to Settings → Secrets and variables → Actions in your repo and add:</p>
<ul>
<li><p><code>DOCKERHUB_USERNAME</code> — your Docker Hub username</p>
</li>
<li><p><code>DOCKERHUB_TOKEN</code> — paste the token here, nowhere else</p>
</li>
</ul>
<p>If you ran into <code>Error: Username and password required</code>, the secrets either aren't saved yet or the names are typed wrong. Both are case-sensitive.</p>
<p>A Node 20 deprecation warning in the logs is normal. It comes from the Docker actions internally, not your code.</p>
<h3 id="heading-step-3-create-the-workflow-file">Step 3: Create the Workflow File</h3>
<p>Create <code>.github/workflows/docker-publish.yml</code>:</p>
<pre><code class="language-yaml">name: Build and Push Docker Image

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/nodejs-docker-cicd

jobs:
  build-and-push:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to Docker Hub
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=sha-
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          target: production
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
</code></pre>
<p>The login step has <code>if: github.event_name != 'pull_request'</code>. This skips authentication on pull requests. PRs from forks don't have access to your secrets, so trying to log in would just fail. The build still runs on PRs to validate your Dockerfile, but the image isn't pushed.</p>
<p>The metadata action generates two tags on every merge to <code>main</code>: <code>latest</code> and a short commit SHA like <code>sha-a1b2c3d</code>. The SHA tag is what makes rollbacks practical. If <code>latest</code> breaks in production, you can pull any previous <code>sha-</code> tag and you're back to a known-good state in seconds.</p>
<p>The <code>cache-from/cache-to: type=gha</code> lines store Docker's layer cache in GitHub Actions' built-in cache. The first run builds everything from scratch. After that, unchanged layers are pulled from cache rather than rebuilt. On a typical Node.js app this brings build time from 2–3 minutes down to under 30 seconds.</p>
<h3 id="heading-push-and-watch-it-run">Push and Watch it Run</h3>
<pre><code class="language-bash">git add .
git commit -m "Add Docker configuration and GitHub Actions workflow"
git push origin main
</code></pre>
<p>Go to your repo's <strong>Actions</strong> tab. You'll see the workflow running in real time. Each step turns green as it completes:</p>
<pre><code class="language-plaintext">✅ Checkout code
✅ Set up Docker Buildx
✅ Log in to Docker Hub
✅ Extract metadata
✅ Build and push
</code></pre>
<p>Green across the board means your image is live on Docker Hub — two tags, <code>latest</code> and a commit SHA like <code>sha-a1b2c3d</code>. Every push to <code>main</code> from here builds and ships automatically.</p>
<h2 id="heading-deploying-the-image">Deploying the Image</h2>
<p>With your image on Docker Hub, you can deploy it to any infrastructure:</p>
<p><strong>Any VPS or server:</strong></p>
<pre><code class="language-bash">docker pull yourusername/nodejs-docker-cicd:latest
docker run -d -p 3000:3000 \
  -e DB_HOST=your-db-host \
  -e DB_NAME=tasksdb \
  -e DB_USER=postgres \
  -e DB_PASSWORD=yourpassword \
  yourusername/nodejs-docker-cicd:latest
</code></pre>
<p><strong>Railway</strong> — Connect your Docker Hub image in the Railway dashboard and it deploys on the next push.</p>
<p><strong>Fly.io</strong> — Run <code>fly launch</code> pointing at your Dockerfile and Fly handles the rest.</p>
<p><strong>Render</strong> — Paste your Docker Hub image URL into the Render service settings.</p>
<p>Each push to <code>main</code> runs the workflow. New image goes to Docker Hub, platform picks it up — that's your deployment handled.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>What started as a local Node.js app now runs in a container. You get the same behavior on any machine, real PostgreSQL in development, and a pipeline that builds and ships to Docker Hub without you doing anything after the push.</p>
<p>The multi-stage build keeps the image lean — dev tools stay out, non-root user, health check baked in. Compose gets the full stack up with one command for anyone who clones the repo. The SHA tag on every GitHub Actions build means rolling back is just a matter of pulling an older tag.</p>
<p>These same patterns (multi-stage builds, Compose for local development, automated image publishing) are used across the industry for production Node.js deployments. Pick up these patterns once and they follow you to every project.</p>
<p>From here, you can extend the pipeline: drop a test step in before the build, or add multi-platform support if you're targeting ARM. Once Docker Compose starts feeling limiting in production, that's usually when Kubernetes enters the picture.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Implement Role-Based Access Control in a Node.js REST API with JWT ]]>
                </title>
                <description>
                    <![CDATA[ The first time I built an API without thinking about roles, I gave every logged-in user the same access. It worked fine until a regular user accidentally hit a delete endpoint and wiped test data. Tha ]]>
                </description>
                <link>https://www.freecodecamp.org/news/role-based-access-control-nodejs-rest-api-jwt/</link>
                <guid isPermaLink="false">6a4fb4570140649a4367b476</guid>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Zia Ullah ]]>
                </dc:creator>
                <pubDate>Thu, 09 Jul 2026 14:46:47 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/d742efbd-8170-4fb6-8851-1f7c6ef9125e.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>The first time I built an API without thinking about roles, I gave every logged-in user the same access. It worked fine until a regular user accidentally hit a delete endpoint and wiped test data. That was the day I actually sat down and learned RBAC properly.</p>
<p>Role-Based Access Control sounds fancy, but the idea is simple: what you can do depends on <em>who you are</em>, not just <em>that you're logged in</em>. An admin deletes users. An editor creates posts. A regular user just reads. Same app, completely different experience depending on who's asking.</p>
<p>That's what we're building here. A REST API with three roles: JWT to carry those roles on every request, and a pair of middleware functions that check permissions before your route handlers even run. There's no database hit per request, and no if/else soup in your business logic.</p>
<p>By the end, you'll have three working roles (<code>admin</code>, <code>editor</code>, <code>user</code>) each locked to their own endpoints. More importantly, the pattern is transferable: once it clicks, you'll wire it into your next project without needing a tutorial.</p>
<p><strong>Full source code on GitHub:</strong> <a href="https://github.com/ziaongit/nodejs-rbac-jwt-api">github.com/ziaongit/nodejs-rbac-jwt-api</a></p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-youll-learn">What You'll Learn</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-well-build">What We'll Build</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-setting-up-the-in-memory-data-store">Setting Up the In-Memory Data Store</a></p>
</li>
<li><p><a href="#heading-building-the-auth-routes">Building the Auth Routes</a></p>
</li>
<li><p><a href="#heading-building-the-rbac-middleware">Building the RBAC Middleware</a></p>
</li>
<li><p><a href="#heading-building-the-protected-routes">Building the Protected Routes</a></p>
</li>
<li><p><a href="#heading-putting-it-all-together">Putting It All Together</a></p>
</li>
<li><p><a href="#heading-testing-the-api">Testing the API</a></p>
</li>
<li><p><a href="#heading-key-takeaways">Key Takeaways</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-youll-learn">What You'll Learn</h2>
<ul>
<li><p>What RBAC is and how it differs from basic authentication</p>
</li>
<li><p>How to embed roles in JWT payloads</p>
</li>
<li><p>How to write reusable Express middleware for token verification and role checking</p>
</li>
<li><p>How to protect API routes based on user roles</p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Node.js (v18+) installed</p>
</li>
<li><p>Basic knowledge of Express.js</p>
</li>
<li><p>Familiarity with how JWTs work (we'll cover the relevant parts)</p>
</li>
<li><p>npm installed</p>
</li>
</ul>
<h2 id="heading-what-well-build">What We'll Build</h2>
<p>We'll build a REST API for a simple content management system with three user roles:</p>
<table>
<thead>
<tr>
<th>Role</th>
<th>Permissions</th>
</tr>
</thead>
<tbody><tr>
<td><code>user</code></td>
<td>Read content</td>
</tr>
<tr>
<td><code>editor</code></td>
<td>Read + create content</td>
</tr>
<tr>
<td><code>admin</code></td>
<td>Full access — read, create, delete content, manage users</td>
</tr>
</tbody></table>
<p>The API will expose these endpoints:</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Endpoint</th>
<th>Access</th>
</tr>
</thead>
<tbody><tr>
<td>POST</td>
<td>/api/auth/register</td>
<td>Public</td>
</tr>
<tr>
<td>POST</td>
<td>/api/auth/login</td>
<td>Public</td>
</tr>
<tr>
<td>GET</td>
<td>/api/content</td>
<td>user, editor, admin</td>
</tr>
<tr>
<td>POST</td>
<td>/api/content</td>
<td>editor, admin</td>
</tr>
<tr>
<td>DELETE</td>
<td>/api/content/:id</td>
<td>admin only</td>
</tr>
<tr>
<td>GET</td>
<td>/api/admin/users</td>
<td>admin only</td>
</tr>
</tbody></table>
<h2 id="heading-project-setup">Project Setup</h2>
<p>Create a new folder and initialize the project:</p>
<pre><code class="language-bash">mkdir nodejs-rbac-jwt-api
cd nodejs-rbac-jwt-api
npm init -y
</code></pre>
<p>Install the dependencies:</p>
<pre><code class="language-bash">npm install express jsonwebtoken bcryptjs dotenv
npm install --save-dev nodemon
</code></pre>
<p>Here's what each package does:</p>
<ul>
<li><p><strong>express</strong>: web framework for building the API</p>
</li>
<li><p><strong>jsonwebtoken</strong>: creates and verifies JWTs</p>
</li>
<li><p><strong>bcryptjs</strong>: securely hashes passwords</p>
</li>
<li><p><strong>dotenv</strong>: reads your <code>.env</code> file so you're not hardcoding secrets in your source code</p>
</li>
</ul>
<p>Update <code>package.json</code> to add start scripts:</p>
<pre><code class="language-json">"scripts": {
  "start": "node src/app.js",
  "dev": "nodemon src/app.js"
}
</code></pre>
<p>Create the project structure:</p>
<pre><code class="language-plaintext">nodejs-rbac-jwt-api/
├── src/
│   ├── middleware/
│   │   └── auth.js
│   ├── routes/
│   │   ├── auth.js
│   │   ├── content.js
│   │   └── admin.js
│   ├── data/
│   │   └── users.js
│   └── app.js
├── .env
├── .env.example
└── package.json
</code></pre>
<p>Create your <code>.env</code> file:</p>
<pre><code class="language-plaintext">JWT_SECRET=your_super_secret_key_change_this_in_production
PORT=3000
</code></pre>
<p><strong>Important:</strong> Never commit your <code>.env</code> file to version control. Add it to <code>.gitignore</code>.</p>
<h2 id="heading-setting-up-the-in-memory-data-store">Setting Up the In-Memory Data Store</h2>
<p>We don't have a database here, just an array in memory. The point was to keep the focus on RBAC, not spend half the tutorial on database config. In a real project, swap the array for whatever database you're already using.</p>
<p>Create <code>src/data/users.js</code>:</p>
<pre><code class="language-javascript">// In-memory users store
// In production, replace this with a real database (MongoDB, PostgreSQL, etc.)
const users = [];

const findUserByEmail = (email) =&gt; users.find((u) =&gt; u.email === email);
const findUserById = (id) =&gt; users.find((u) =&gt; u.id === id);
const createUser = (user) =&gt; {
  users.push(user);
  return user;
};
const getAllUsers = () =&gt; users.map(({ password, ...user }) =&gt; user);

module.exports = { findUserByEmail, findUserById, createUser, getAllUsers };
</code></pre>
<p>One thing worth noting: <code>getAllUsers</code> uses destructuring to drop the password before returning anything. Never send password fields in API responses, even hashed ones.</p>
<h2 id="heading-building-the-auth-routes">Building the Auth Routes</h2>
<p>The auth routes handle registration and login. Login is where roles first enter the picture — we embed the user's role directly into the JWT payload.</p>
<p>Create <code>src/routes/auth.js</code>:</p>
<pre><code class="language-javascript">const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { findUserByEmail, createUser } = require('../data/users');

const router = express.Router();

// POST /api/auth/register
router.post('/register', async (req, res) =&gt; {
  const { name, email, password, role } = req.body;

  if (!name || !email || !password) {
    return res.status(400).json({ message: 'Name, email, and password are required' });
  }

  if (findUserByEmail(email)) {
    return res.status(409).json({ message: 'Email already registered' });
  }

  // Only allow valid roles — default to 'user' if none provided
  const validRoles = ['user', 'editor', 'admin'];
  const assignedRole = validRoles.includes(role) ? role : 'user';

  const hashedPassword = await bcrypt.hash(password, 10);

  const newUser = {
    id: Date.now().toString(),
    name,
    email,
    password: hashedPassword,
    role: assignedRole,
  };

  createUser(newUser);

  res.status(201).json({
    message: 'User registered successfully',
    user: {
      id: newUser.id,
      name: newUser.name,
      email: newUser.email,
      role: newUser.role,
    },
  });
});

// POST /api/auth/login
router.post('/login', async (req, res) =&gt; {
  const { email, password } = req.body;

  if (!email || !password) {
    return res.status(400).json({ message: 'Email and password are required' });
  }

  const user = findUserByEmail(email);
  if (!user) {
    return res.status(401).json({ message: 'Invalid credentials' });
  }

  const isMatch = await bcrypt.compare(password, user.password);
  if (!isMatch) {
    return res.status(401).json({ message: 'Invalid credentials' });
  }

  // Issue JWT — embed role in the payload
  const token = jwt.sign(
    {
      id: user.id,
      email: user.email,
      role: user.role,   // ← This is the key part for RBAC
    },
    process.env.JWT_SECRET,
    { expiresIn: '24h' }
  );

  res.json({
    message: 'Login successful',
    token,
  });
});

module.exports = router;
</code></pre>
<p>The most important line is the JWT payload:</p>
<pre><code class="language-javascript">jwt.sign({ id, email, role }, process.env.JWT_SECRET, { expiresIn: '24h' })
</code></pre>
<p>By embedding <code>role</code> in the token, every subsequent request carries the user's permissions without requiring a database lookup. The server just verifies the token and reads the role from the payload.</p>
<h2 id="heading-building-the-rbac-middleware">Building the RBAC Middleware</h2>
<p>This is the core of the system. We need two separate middleware functions:</p>
<ol>
<li><p><code>verifyToken</code> confirms the JWT is valid and attaches the decoded payload to <code>req.user</code></p>
</li>
<li><p><code>checkRole</code> confirms the user has the required role for a specific route</p>
</li>
</ol>
<p>Keeping them separate gives you flexibility. Some routes only need authentication. Others need both authentication and a specific role.</p>
<p>Create <code>src/middleware/auth.js</code>:</p>
<pre><code class="language-javascript">const jwt = require('jsonwebtoken');

// Middleware 1: Verify the JWT token
const verifyToken = (req, res, next) =&gt; {
  const authHeader = req.headers['authorization'];
  const token = authHeader &amp;&amp; authHeader.split(' ')[1]; // Expects: Bearer &lt;token&gt;

  if (!token) {
    return res.status(401).json({ message: 'Access denied. No token provided.' });
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded; // Attach decoded payload (including role) to request
    next();
  } catch (err) {
    return res.status(403).json({ message: 'Invalid or expired token.' });
  }
};

// Middleware 2: Check if user has one of the required roles
const checkRole = (...allowedRoles) =&gt; {
  return (req, res, next) =&gt; {
    if (!req.user) {
      return res.status(401).json({ message: 'Not authenticated.' });
    }

    if (!allowedRoles.includes(req.user.role)) {
      return res.status(403).json({
        message: `Access denied. Required role: ${allowedRoles.join(' or ')}. Your role: ${req.user.role}`,
      });
    }

    next();
  };
};

module.exports = { verifyToken, checkRole };
</code></pre>
<p><code>checkRole</code> uses a rest parameter (<code>...allowedRoles</code>) so you can pass in one or multiple roles:</p>
<pre><code class="language-javascript">checkRole('admin')                  // only admin
checkRole('editor', 'admin')        // editor or admin
checkRole('user', 'editor', 'admin') // all roles
</code></pre>
<p>This makes route definitions clean and readable — the permissions are visible right at the route level.</p>
<h2 id="heading-building-the-protected-routes">Building the Protected Routes</h2>
<p>Now let's wire up routes that use the middleware.</p>
<p>Create <code>src/routes/content.js</code>:</p>
<pre><code class="language-javascript">const express = require('express');
const { verifyToken, checkRole } = require('../middleware/auth');

const router = express.Router();

// In-memory content store
const content = [
  { id: '1', title: 'Getting Started with Node.js', author: 'admin' },
  { id: '2', title: 'Express Middleware Explained', author: 'editor' },
];

// GET /api/content — all authenticated users
router.get('/', verifyToken, checkRole('user', 'editor', 'admin'), (req, res) =&gt; {
  res.json({ content });
});

// POST /api/content — editors and admins only
router.post('/', verifyToken, checkRole('editor', 'admin'), (req, res) =&gt; {
  const { title } = req.body;

  if (!title) {
    return res.status(400).json({ message: 'Title is required' });
  }

  const newItem = {
    id: Date.now().toString(),
    title,
    author: req.user.email,
  };

  content.push(newItem);
  res.status(201).json({ message: 'Content created', item: newItem });
});

// DELETE /api/content/:id — admin only
router.delete('/:id', verifyToken, checkRole('admin'), (req, res) =&gt; {
  const index = content.findIndex((c) =&gt; c.id === req.params.id);

  if (index === -1) {
    return res.status(404).json({ message: 'Content not found' });
  }

  content.splice(index, 1);
  res.json({ message: 'Content deleted successfully' });
});

module.exports = router;
</code></pre>
<p>Notice how readable each route is:</p>
<pre><code class="language-javascript">router.delete('/:id', verifyToken, checkRole('admin'), handler)
</code></pre>
<p>You can understand the access control without reading the handler body. This is one of the key advantages of middleware-based RBAC: permissions live at the routing layer, not buried in business logic.</p>
<p>Create <code>src/routes/admin.js</code>:</p>
<pre><code class="language-javascript">const express = require('express');
const { verifyToken, checkRole } = require('../middleware/auth');
const { getAllUsers } = require('../data/users');

const router = express.Router();

// GET /api/admin/users — admin only
router.get('/users', verifyToken, checkRole('admin'), (req, res) =&gt; {
  res.json({ users: getAllUsers() });
});

module.exports = router;
</code></pre>
<h2 id="heading-putting-it-all-together">Putting It All Together</h2>
<p>Create <code>src/app.js</code>:</p>
<pre><code class="language-javascript">require('dotenv').config();
const express = require('express');

const authRoutes = require('./routes/auth');
const contentRoutes = require('./routes/content');
const adminRoutes = require('./routes/admin');

const app = express();

app.use(express.json());

// Routes
app.use('/api/auth', authRoutes);
app.use('/api/content', contentRoutes);
app.use('/api/admin', adminRoutes);

// Health check
app.get('/', (req, res) =&gt; {
  res.json({ message: 'RBAC API is running' });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () =&gt; {
  console.log(`Server running on port ${PORT}`);
});
</code></pre>
<h2 id="heading-testing-the-api">Testing the API</h2>
<p>Start the server:</p>
<pre><code class="language-bash">npm run dev
</code></pre>
<h3 id="heading-step-1-register-users-with-different-roles">Step 1: Register Users with Different Roles</h3>
<p>Register an admin:</p>
<pre><code class="language-bash">curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"name": "Admin User", "email": "admin@example.com", "password": "password123", "role": "admin"}'
</code></pre>
<p>Register an editor:</p>
<pre><code class="language-bash">curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"name": "Editor User", "email": "editor@example.com", "password": "password123", "role": "editor"}'
</code></pre>
<p>Register a regular user (no role specified — defaults to <code>user</code>):</p>
<pre><code class="language-bash">curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"name": "Regular User", "email": "user@example.com", "password": "password123"}'
</code></pre>
<h3 id="heading-step-2-log-in-and-get-a-token">Step 2: Log in and Get a Token</h3>
<pre><code class="language-bash">curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com", "password": "password123"}'
</code></pre>
<p>You'll get a response like:</p>
<pre><code class="language-json">{
  "message": "Login successful",
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
</code></pre>
<p>Copy the token.</p>
<h3 id="heading-step-3-test-role-based-access">Step 3: Test Role-based Access</h3>
<p><strong>Read content as a regular user (should succeed):</strong></p>
<pre><code class="language-bash">curl http://localhost:3000/api/content \
  -H "Authorization: Bearer YOUR_TOKEN_HERE"
</code></pre>
<p><strong>Try creating content as a regular user (should fail — 403):</strong></p>
<pre><code class="language-bash">curl -X POST http://localhost:3000/api/content \
  -H "Authorization: Bearer YOUR_TOKEN_HERE" \
  -H "Content-Type: application/json" \
  -d '{"title": "New Article"}'
</code></pre>
<p>Response:</p>
<pre><code class="language-json">{
  "message": "Access denied. Required role: editor or admin. Your role: user"
}
</code></pre>
<p>Now log in as an editor and try the same POST request. It succeeds. Log in as admin and try the DELETE route. Only the admin token will work.</p>
<h3 id="heading-step-4-decode-the-jwt-to-see-the-role">Step 4: Decode the JWT to See the Role</h3>
<p>You can paste any token into <a href="https://jwt.io">jwt.io</a> to inspect the payload. You'll see something like:</p>
<pre><code class="language-json">{
  "id": "1720300000000",
  "email": "admin@example.com",
  "role": "admin",
  "iat": 1720300000,
  "exp": 1720386400
}
</code></pre>
<p>The <code>role</code> field is exactly what <code>checkRole</code> reads on every protected request.</p>
<h2 id="heading-key-takeaways">Key Takeaways</h2>
<p>Roles live in the JWT payload. The role travels with the token — no extra DB call needed every time someone hits a protected route. It gets embedded at login and verified cryptographically on each request.</p>
<p>Middleware is composable. <code>verifyToken</code> and <code>checkRole</code> are separate, reusable functions. You can chain them on any route in any combination.</p>
<p>Permissions are visible at the route level. <code>router.delete('/:id', verifyToken, checkRole('admin'), handler)</code> tells you everything about access control before you even read the handler.</p>
<p><strong>Before you ship this to production:</strong></p>
<ul>
<li><p>The in-memory array was just to keep this tutorial focused — replace it with a real database before anything goes near production. A server restart wipes all your users right now.</p>
</li>
<li><p>That 24h token expiry is too long. Cut it to 15 minutes and add refresh token rotation. A stolen token becomes useless fast.</p>
</li>
<li><p>Re-validate roles from the DB on sensitive operations. A role change won't reflect in an existing token until it expires</p>
</li>
<li><p>HTTPS, always</p>
</li>
<li><p>If your permission logic grows beyond "check a role", look at <a href="https://casl.js.org/">casl</a>. It handles attribute-level rules cleanly</p>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The core of it fits in two middleware functions and a JWT payload. I've used this same pattern across several projects. And once you've built it yourself, you'll start spotting it everywhere, because almost every multi-user app needs some version of it.</p>
<p><strong>Full source code on GitHub:</strong> <a href="https://github.com/ziaongit/nodejs-rbac-jwt-api">github.com/ziaongit/nodejs-rbac-jwt-api</a></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build Secure-by-Default Node.js APIs ]]>
                </title>
                <description>
                    <![CDATA[ Most security problems I've shipped in my career weren't exotic. They weren't nation-state attacks or clever zero-days. They were boring. A missing limit here, a forgotten timeout there, a string comp ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-secure-by-default-node-js-apis/</link>
                <guid isPermaLink="false">6a3c3fc702ebd10f875ab988</guid>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Devlin Duldulao ]]>
                </dc:creator>
                <pubDate>Wed, 24 Jun 2026 20:36:23 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/edebad91-82e3-4d67-b136-bbb99859a393.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most security problems I've shipped in my career weren't exotic. They weren't nation-state attacks or clever zero-days. They were boring. A missing limit here, a forgotten timeout there, a string comparison that leaked a secret one millisecond at a time.</p>
<p>The boring stuff is what gets you, because the boring stuff is what everyone agrees to fix "later," and later has a way of never arriving.</p>
<p>My favorite personal example (favorite in the way a scar is your favorite) was an internal API that compared an access token with a plain equality check and had no limit on request size. It ran fine for a year. It ran fine right up until someone curious discovered they could both fingerprint the token comparison and post a body large enough to make the server sweat.</p>
<p>Neither bug was sophisticated. Both would have been a complete non-event if something had simply refused to let me do the wrong thing in the first place.</p>
<p>This tutorial shows you how to add practical guardrails around every HTTP API you build, regardless of framework. You'll write them by hand, in plain Node.js, with no dependencies. This will let you see exactly what each one does and why.</p>
<p>By the end, you'll have a small server that survives a lot more contact with the public internet than the version most of us shipped early in our careers.</p>
<p>This tutorial uses plain JavaScript so anyone can copy and run it. If you use TypeScript, you can add types afterward. You need Node 22 or newer, a basic understanding of HTTP requests and responses, and a terminal for testing the examples.</p>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ol>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-youll-build">What You'll Build</a></p>
</li>
<li><p><a href="#heading-how-to-start-with-the-naive-server">How to Start with the Naïve Server</a></p>
</li>
<li><p><a href="#heading-how-to-limit-the-request-body">How to Limit the Request Body</a></p>
</li>
<li><p><a href="#heading-how-to-time-out-slow-requests">How to Time Out Slow Requests</a></p>
</li>
<li><p><a href="#heading-how-to-parse-json-safely-and-block-prototype-pollution">How to Parse JSON Safely and Block Prototype Pollution</a></p>
</li>
<li><p><a href="#heading-how-to-set-security-headers-on-every-response">How to Set Security Headers on Every Response</a></p>
</li>
<li><p><a href="#heading-how-to-compare-secrets-in-constant-time">How to Compare Secrets in Constant Time</a></p>
</li>
<li><p><a href="#heading-how-to-validate-input-as-a-gate-not-a-suggestion">How to Validate Input as a Gate, Not a Suggestion</a></p>
</li>
<li><p><a href="#heading-how-to-fail-without-leaking-and-log-so-you-can-see-it">How to Fail Without Leaking and Log So You Can See It</a></p>
</li>
<li><p><a href="#heading-how-to-put-it-all-together">How to Put It All Together</a></p>
</li>
<li><p><a href="#heading-how-to-handle-cors-correctly">How to Handle CORS Correctly</a></p>
</li>
<li><p><a href="#heading-what-this-tutorial-doesnt-cover">What This Tutorial Doesn't Cover</a></p>
</li>
<li><p><a href="#heading-why-defaults-beat-checklists">Why Defaults Beat Checklists</a></p>
</li>
<li><p><a href="#heading-an-honest-note-on-frameworks">An Honest Note on Frameworks</a></p>
</li>
<li><p><a href="#heading-the-takeaway-checklist">The Takeaway Checklist</a></p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Node.js 22 or newer</p>
</li>
<li><p>Basic familiarity with HTTP requests and responses</p>
</li>
<li><p>A terminal and curl, Postman, or a similar client</p>
</li>
</ul>
<p>This isn't a guide to making your API unhackable. It's a practical guide to avoiding the easy attacks and building safer defaults.</p>
<h2 id="heading-what-youll-build">What You'll Build</h2>
<p>A plain Node.js API with request size limits, request timeouts, safe JSON parsing, security headers, timing-safe secret comparison, validation, and error handling.</p>
<h2 id="heading-how-to-start-with-the-naive-server">How to Start with the Naïve Server</h2>
<p>Here's the kind of server I wrote when I was younger and braver and wrong. It reads a JSON body and echoes it back. Pretend it's the start of a real API.</p>
<pre><code class="language-ts">import http from "node:http";

const server = http.createServer((req, res) =&gt; {
  let body = "";
  req.on("data", (chunk) =&gt; (body += chunk));
  req.on("end", () =&gt; {
    const data = JSON.parse(body || "{}");
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ youSent: data }));
  });
});

server.listen(3000, () =&gt; console.log("listening on http://localhost:3000"));
</code></pre>
<p>It works. You can curl it and it answers. It's also a small disaster, and here's the incomplete list of why:</p>
<ul>
<li><p>It reads an unbounded body into memory. Send it a few gigabytes and you have a denial-of-service with no effort.</p>
</li>
<li><p><code>JSON.parse</code> throws on bad input, which here means an uncaught exception that can take the process down.</p>
</li>
<li><p>There's no timeout. A client that sends one byte per minute can hold a connection hostage.</p>
</li>
<li><p>It sets no security headers and happily advertises that it's a Node server.</p>
</li>
<li><p>It parses untrusted JSON straight into an object with no checks, which opens the door to prototype pollution downstream.</p>
</li>
</ul>
<p>You're going to fix each of these issues. The fixes are small. The point is to make them habits, not heroics.</p>
<h2 id="heading-how-to-limit-the-request-body">How to Limit the Request Body</h2>
<p>The first rule of accepting input from strangers is to decide, in advance, how much input you're willing to accept. If you don't set a limit, the limit is "however much RAM the server has," and someone will find that out for you.</p>
<p>There are two layers here. The first is the <code>Content-Length</code> header, which the client sends to declare how big the body is. You can reject early based on it. But you must never trust it alone, because a client can lie or simply not send it.</p>
<p>The real defense is to count bytes as they stream in and stop the moment they cross your line.</p>
<pre><code class="language-ts">const MAX_BODY_BYTES = 100 * 1024; // 100 KB is plenty for most JSON APIs

function readBody(req, limit = MAX_BODY_BYTES) {
  return new Promise((resolve, reject) =&gt; {
    // Cheap early rejection if the client is honest about being too big.
    const declared = Number(req.headers["content-length"]);
    if (Number.isFinite(declared) &amp;&amp; declared &gt; limit) {
      reject(httpError(413, "Payload too large"));
      return;
    }

    let size = 0;
    const chunks = [];

    req.on("data", (chunk) =&gt; {
      size += chunk.length;
      if (size &gt; limit) {
        reject(httpError(413, "Payload too large"));
        req.destroy(); // stop reading; we are done with this client
        return;
      }
      chunks.push(chunk);
    });

    req.on("end", () =&gt; resolve(Buffer.concat(chunks)));
    req.on("error", reject);
  });
}

function httpError(statusCode, message) {
  return Object.assign(new Error(message), { statusCode });
}
</code></pre>
<p>A few things worth noticing here. You accumulate <code>Buffer</code> chunks and only join them at the end, rather than concatenating strings, because string concatenation forces an early decode and can mangle multibyte UTF-8 characters that happen to land on a chunk boundary.</p>
<p>You also call <code>req.destroy()</code> as soon as you cross the limit, so you don't keep pulling bytes you've already decided to refuse.</p>
<p>Pick a limit that matches the route. A JSON API that creates a user doesn't need a 50 MB body. A file upload endpoint is a different conversation, and there you would stream to disk or object storage instead of buffering in memory at all. The mistake is having no limit, not having the wrong one.</p>
<h2 id="heading-how-to-time-out-slow-requests">How to Time Out Slow Requests</h2>
<p>Once you have a body limit, the next trick an attacker reaches for is to be slow instead of large. This is the family of attacks named after slowloris, a sad-looking primate that moves very slowly, which is rude to the animal but accurate about the attack.</p>
<p>The idea is to open many connections and feed them bytes at a glacial pace, never finishing, so the server keeps each one alive waiting politely. Do that enough times and you've exhausted the connection pool without sending anything that looks malicious.</p>
<p>Node has built-in defenses for this, and the defaults are generous and worth tightening for an API.</p>
<pre><code class="language-ts">const server = http.createServer(handler);

// Total time allowed to receive the entire request (headers + body).
server.requestTimeout = 30_000; // 30 seconds

// Time allowed to receive just the headers. Slowloris lives here.
server.headersTimeout = 10_000; // 10 seconds

// Idle socket timeout: kill connections that go quiet.
server.setTimeout(60_000);
</code></pre>
<p>Those three lines handle the network layer. But there's a second kind of slow: your own handler. A database query that hangs, an outbound call to a third party that never answers, a regular expression that decided to think about its life choices. You want a ceiling on how long a single request is allowed to occupy a worker, and you want to be able to cancel the work when that ceiling is hit.</p>
<p>The modern tool for cancellation in Node is <code>AbortController</code>. Here's a small wrapper that gives every handler a deadline and a signal it can pass down to anything that supports cancellation, like <code>fetch</code>.</p>
<pre><code class="language-ts">function withTimeout(handler, ms = 15_000) {
  return async (req, res) =&gt; {
    const controller = new AbortController();
    const timer = setTimeout(() =&gt; controller.abort(), ms);
    try {
      await handler(req, res, controller.signal);
    } finally {
      clearTimeout(timer);
    }
  };
}
</code></pre>
<p>Now a handler can do <code>await fetch(url, { signal })</code> and the request gets cut off if it blows the deadline, instead of camping on a worker forever.</p>
<p>The discipline to learn here is that any time you talk to something outside your process, you give it a deadline. Networks fail in the most boring way possible: by hanging, not by erroring. A timeout turns a hang into a clean error you can handle.</p>
<h2 id="heading-how-to-parse-json-safely-and-block-prototype-pollution">How to Parse JSON Safely and Block Prototype Pollution</h2>
<p>This is the one people skip because it sounds theoretical, and then it shows up in a CVE with their stack in it.</p>
<p>First, the easy half. <code>JSON.parse</code> throws a <code>SyntaxError</code> on malformed input. In the naïve server, that throw was uncaught and could crash the process. So we wrap parsing and turn a parse failure into a clean 400.</p>
<pre><code class="language-ts">function parseJson(buffer) {
  if (buffer.length === 0) return {};
  let text = buffer.toString("utf8");
  try {
    return JSON.parse(text, reviver);
  } catch {
    throw httpError(400, "Invalid JSON body");
  }
}
</code></pre>
<p>Now the interesting half: that <code>reviver</code> argument. Prototype pollution is an attack where a request payload reaches up and modifies <code>Object.prototype</code>, the object that almost every object in your program inherits from. If an attacker can set a property there, they can set it on effectively everything at once.</p>
<p>It's easier to believe once you see it. Here's a recursive merge function, the kind people write all the time to apply updates onto an existing record:</p>
<pre><code class="language-ts">function merge(target, source) {
  for (const key in source) {
    if (source[key] &amp;&amp; typeof source[key] === "object") {
      if (!target[key]) target[key] = {};
      merge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
}
</code></pre>
<p>Looks harmless. Now feed it a payload an attacker controls:</p>
<pre><code class="language-ts">const evil = JSON.parse('{"__proto__": {"isAdmin": true}}');
const account = {};
merge(account, evil);

console.log(account.isAdmin);   // undefined, account itself is fine
console.log(({}).isAdmin);      // true  &lt;-- every object is now "admin"
</code></pre>
<p>That second line is the horror. You never touched <code>({})</code>. You polluted the shared prototype, so a brand new empty object now reports <code>isAdmin: true</code>. If somewhere later your code does <code>if (user.isAdmin)</code> on an object that didn't explicitly set that field, congratulations! Everyone is an admin. The <code>__proto__</code> key tricked the merge into walking up into the prototype that all objects share.</p>
<p>The defense is to refuse the dangerous keys before they ever get into your data. The cleanest way at parse time is the reviver, a function <code>JSON.parse</code> calls for every key as it builds the result. Return <code>undefined</code> for a key and it gets dropped.</p>
<pre><code class="language-ts">const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);

function reviver(key, value) {
  if (FORBIDDEN_KEYS.has(key)) return undefined;
  return value;
}
</code></pre>
<p>That is it. Three key names, blocked at the door, and the merge above becomes harmless because the payload never carries <code>__proto__</code> past the parser.</p>
<p>For extra defense in depth, you can also build internal lookup objects with <code>Object.create(null)</code>, which creates an object with no prototype at all, or use a <code>Map</code> when the keys are user-controlled. And if you want a belt to go with the suspenders, <code>Object.freeze(Object.prototype)</code> early in your process start will make the whole class of attack fail loudly.</p>
<p>I wouldn't rely on freezing alone, because some libraries get unhappy about it, but blocking the keys costs you nothing and should be the default.</p>
<h2 id="heading-how-to-set-security-headers-on-every-response">How to Set Security Headers on Every Response</h2>
<p>Browsers will defend your users for you, but only if you tell them to. That instruction comes as a small set of response headers. For an API that returns JSON, the list is short and the defaults are strict, which is exactly how you want it.</p>
<pre><code class="language-ts">function secureHeaders(res) {
  // Do not let the browser guess content types. Stops a JSON response
  // from being treated as HTML or a script.
  res.setHeader("X-Content-Type-Options", "nosniff");

  // Clickjacking defense: do not allow this response inside a frame.
  res.setHeader("X-Frame-Options", "DENY");
  res.setHeader("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'");

  // Do not leak the full URL (which may contain ids or tokens) on navigation.
  res.setHeader("Referrer-Policy", "no-referrer");

  // Only meaningful over HTTPS: force HTTPS for two years, including subdomains.
  res.setHeader("Strict-Transport-Security", "max-age=63072000; includeSubDomains");

  // Stop advertising what you are running. Free reconnaissance for nobody.
  res.removeHeader("X-Powered-By");
}
</code></pre>
<p>A quick tour, because cargo-culting headers is how you end up with a Content Security Policy that does nothing.</p>
<p><code>X-Content-Type-Options: nosniff</code> stops the browser from second-guessing your <code>Content-Type</code>, which closes a sneaky path where a response gets reinterpreted as something executable. <code>X-Frame-Options</code> and the <code>frame-ancestors</code> directive both refuse to let your responses be embedded in a frame, which is the heart of clickjacking. For a pure JSON API, <code>default-src 'none'</code> is a strong and appropriate CSP, because an API has no business loading scripts, styles, or images.</p>
<p><code>Referrer-Policy: no-referrer</code> keeps your URLs, which sometimes carry ids you would rather not gossip about, from being sent to other sites. <code>Strict-Transport-Security</code> only matters once you're on HTTPS, but once you are, it stops downgrade attacks by telling the browser to refuse plain HTTP.</p>
<p>And removing <code>X-Powered-By</code> is a tiny thing that just denies an attacker a free hint about what to throw at you.</p>
<p>The reason to wrap this in a function and call it on every response is that "every response" is the part humans forget. Make it one call you do at the top of the request, before you can get distracted.</p>
<h2 id="heading-how-to-compare-secrets-in-constant-time">How to Compare Secrets in Constant Time</h2>
<p>Here's a bug that looks completely fine and is completely broken:</p>
<pre><code class="language-ts">if (providedApiKey === expectedApiKey) {
  // grant access
}
</code></pre>
<p>The problem is that <code>===</code> on strings is allowed to be fast. It compares character by character and returns <code>false</code> the instant it finds a mismatch. That means a wrong guess that gets the first character right takes very slightly longer to reject than one that gets it wrong immediately.</p>
<p>That timing difference is tiny, but it's measurable over enough requests, and it lets an attacker recover a secret one character at a time. This is a real attack, it has a name (a timing attack), and the fix is built into Node.</p>
<p>You want a comparison whose running time doesn't depend on where the first difference is. Node gives you <code>crypto.timingSafeEqual</code> for exactly this. It has one sharp edge: it throws if the two buffers have different lengths, and length itself is a leak.</p>
<p>The clean way to handle both problems at once is to hash both inputs to a fixed size first, then compare the hashes.</p>
<pre><code class="language-ts">import { timingSafeEqual, createHash } from "node:crypto";

function safeCompare(a, b) {
  // Hashing normalizes length (so timingSafeEqual is happy) and hides
  // the length of the real secret from a timing observer.
  const ha = createHash("sha256").update(String(a)).digest();
  const hb = createHash("sha256").update(String(b)).digest();
  return timingSafeEqual(ha, hb);
}
</code></pre>
<p>Use this anywhere you compare a value a stranger supplied against a secret you hold: API keys, webhook signatures, password reset tokens, session identifiers. The rule of thumb is simple: if being wrong about the comparison would let someone in, don't use <code>===</code>.</p>
<p>One important caveat so you don't misuse this. For user passwords, don't store them and compare with this function. Passwords get hashed with a slow, purpose-built algorithm so that even if your database leaks, the hashes are expensive to crack.</p>
<p>Node ships <code>scrypt</code> for this in <code>node:crypto</code>, and <code>bcrypt</code> and <code>argon2</code> are popular libraries. The <code>safeCompare</code> above is for comparing high-entropy secrets like tokens and keys, not for human-chosen passwords.</p>
<h2 id="heading-how-to-validate-input-as-a-gate-not-a-suggestion">How to Validate Input as a Gate, Not a Suggestion</h2>
<p>Everything so far has been about surviving hostile input at the transport level. Validation is about refusing input that doesn't match the shape your code expects, before that input reaches your business logic.</p>
<p>A surprising amount of "weird production behavior" is just a handler that assumed a field was a string and got an array, or assumed a number and got the string "NaN".</p>
<p>You can hand-roll validation for a small API, and it's healthy to see what that looks like before you reach for a library:</p>
<pre><code class="language-ts">function expect(condition, message) {
  if (!condition) throw httpError(400, message);
}

function parseCreateUser(data) {
  expect(typeof data.email === "string" &amp;&amp; data.email.includes("@"), "email is required");
  expect(typeof data.password === "string", "password is required");
  expect(data.password.length &gt;= 12, "password must be at least 12 characters");
  // Return only the fields you actually want. Ignore everything else.
  return { email: data.email, password: data.password };
}
</code></pre>
<p>Notice the last line. You build a fresh object with only the fields you asked for, rather than passing <code>data</code> straight through. This quietly closes a mass-assignment hole, where a client sends <code>{"email": "...", "password": "...", "role": "admin"}</code> and a careless handler writes the whole object into the database, role included. If you only copy the fields you meant to accept, the extra ones never matter.</p>
<p>For anything beyond a few routes, a schema library pays for itself fast. Zod and Valibot are the popular choices, and both let you describe the shape once and get validation plus inferred types out of it.</p>
<pre><code class="language-ts">import { z } from "zod";

const CreateUser = z
  .object({
    email: z.string().email(),
    password: z.string().min(12),
  })
  .strict(); // reject unknown keys instead of ignoring them

const result = CreateUser.safeParse(data);
if (!result.success) throw httpError(400, "Validation failed");
const user = result.data;
</code></pre>
<p>That <code>.strict()</code> call is doing the same mass-assignment defense as our hand-rolled version, but declaratively. Whether you hand-roll it or use a library, the principle is the same: input is guilty until proven to match a shape you defined on purpose.</p>
<h2 id="heading-how-to-fail-without-leaking-and-log-so-you-can-see-it">How to Fail Without Leaking and Log So You Can See It</h2>
<p>Errors are going to happen. The real question is what your server says when they do, and whether you can reconstruct what went wrong afterward.</p>
<p>There are two common ways to get this wrong, and they are opposites. Either you hand attackers a map of your internals, or you blind yourself during an incident.</p>
<p>The leaking version looks like this, and yes, I've shipped it:</p>
<pre><code class="language-ts">catch (err) {
  res.writeHead(500);
  res.end(err.stack); // please do not
}
</code></pre>
<p>That stack trace can include file paths, library versions, query fragments, and sometimes secrets that got interpolated into an error message. It's a free briefing for whoever is poking at you.</p>
<p>The rule is blunt: a 500 should tell the client nothing useful, and tell you everything, through your logs.</p>
<p>The opposite failure is hiding the error so thoroughly that when it happens in production at 2am you have nothing to go on.</p>
<p>The fix for both problems is the same small idea: a request id. It's a short unique value you attach to each request, return to the client in a header, and include in every log line for that request. When a user reports "I got an error and it said request abc123," you can find exactly that request in your logs in seconds.</p>
<pre><code class="language-ts">import { randomUUID } from "node:crypto";

function withRequestId(req, res) {
  const requestId = req.headers["x-request-id"] ?? randomUUID();
  res.setHeader("X-Request-Id", requestId);
  return requestId;
}

function log(level, requestId, message, extra = {}) {
  // Structured logs: one JSON object per line, easy to search and ship.
  console.log(
    JSON.stringify({ level, requestId, message, ...extra, at: new Date().toISOString() }),
  );
}

function sendError(res, err, requestId) {
  const status = err.statusCode ?? 500;
  const message = status === 500 ? "Internal Server Error" : err.message;
  if (status === 500) {
    log("error", requestId, "unhandled error", { stack: err.stack });
  }
  if (!res.headersSent) {
    res.writeHead(status, { "Content-Type": "application/json" });
  }
  res.end(JSON.stringify({ error: message, requestId }));
}
</code></pre>
<p>The client gets the <code>requestId</code> but never the details. They can quote it to support, but they can't read your stack trace. Accept an incoming <code>X-Request-Id</code> when a trusted upstream set one, so a single request keeps the same id as it moves across your services, but generate your own whenever it is missing. Structured logs, one JSON object per line, are worth the slight ugliness, because they're trivial to filter and feed into a log aggregator, which a pile of freeform <code>console.log</code> calls is not.</p>
<p>One note on environments. It's fine, even helpful, to return richer error detail when you run locally. Just gate it on an explicit environment check and make production the strict default, so the worst outcome of a misconfiguration is too little information leaked, never too much.</p>
<h2 id="heading-how-to-put-it-all-together">How to Put It All Together</h2>
<p>None of these guardrails is impressive on its own. The power is in having all of them on, by default, on every route, so that the safe path is the path of least resistance.</p>
<p>Here is the naïve server from the start, rebuilt with everything we covered. It's still tiny. It's just no longer naïve.</p>
<pre><code class="language-ts">import http from "node:http";
import { timingSafeEqual, createHash } from "node:crypto";

const MAX_BODY_BYTES = 100 * 1024;
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);

function httpError(statusCode, message) {
  return Object.assign(new Error(message), { statusCode });
}

function reviver(key, value) {
  return FORBIDDEN_KEYS.has(key) ? undefined : value;
}

function readBody(req, limit = MAX_BODY_BYTES) {
  return new Promise((resolve, reject) =&gt; {
    const declared = Number(req.headers["content-length"]);
    if (Number.isFinite(declared) &amp;&amp; declared &gt; limit) {
      return reject(httpError(413, "Payload too large"));
    }
    let size = 0;
    const chunks = [];
    req.on("data", (chunk) =&gt; {
      size += chunk.length;
      if (size &gt; limit) {
        reject(httpError(413, "Payload too large"));
        req.destroy();
        return;
      }
      chunks.push(chunk);
    });
    req.on("end", () =&gt; resolve(Buffer.concat(chunks)));
    req.on("error", reject);
  });
}

function parseJson(buffer) {
  if (buffer.length === 0) return {};
  try {
    return JSON.parse(buffer.toString("utf8"), reviver);
  } catch {
    throw httpError(400, "Invalid JSON body");
  }
}

function secureHeaders(res) {
  res.setHeader("X-Content-Type-Options", "nosniff");
  res.setHeader("X-Frame-Options", "DENY");
  res.setHeader("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'");
  res.setHeader("Referrer-Policy", "no-referrer");
  res.setHeader("Strict-Transport-Security", "max-age=63072000; includeSubDomains");
  res.removeHeader("X-Powered-By");
}

function safeCompare(a, b) {
  const ha = createHash("sha256").update(String(a)).digest();
  const hb = createHash("sha256").update(String(b)).digest();
  return timingSafeEqual(ha, hb);
}

function sendJson(res, status, payload) {
  if (!res.headersSent) {
    res.writeHead(status, { "Content-Type": "application/json" });
  }
  res.end(JSON.stringify(payload));
}

function sendError(res, err) {
  const status = err.statusCode ?? 500;
  // Never leak internal error details on a 500. Log them, do not ship them.
  const message = status === 500 ? "Internal Server Error" : err.message;
  if (status === 500) console.error(err);
  sendJson(res, status, { error: message });
}

const API_KEY = process.env.API_KEY ?? "dev-only-key";

async function handler(req, res) {
  secureHeaders(res);

  // A single protected route, as an example.
  if (req.method === "POST" &amp;&amp; req.url === "/users") {
    const provided = req.headers["x-api-key"] ?? "";
    if (!safeCompare(provided, API_KEY)) {
      throw httpError(401, "Unauthorized");
    }

    const data = parseJson(await readBody(req));

    if (typeof data.email !== "string" || !data.email.includes("@")) {
      throw httpError(400, "email is required");
    }
    if (typeof data.password !== "string" || data.password.length &lt; 12) {
      throw httpError(400, "password must be at least 12 characters");
    }

    // Only the fields we asked for. No mass assignment.
    const user = { email: data.email };
    return sendJson(res, 201, { created: user });
  }

  throw httpError(404, "Not found");
}

const server = http.createServer((req, res) =&gt; {
  handler(req, res).catch((err) =&gt; sendError(res, err));
});

server.requestTimeout = 30_000;
server.headersTimeout = 10_000;
server.setTimeout(60_000);

server.listen(3000, () =&gt; console.log("listening on http://localhost:3000"));
</code></pre>
<p>Read that top to bottom and notice how the security isn't a separate "security middleware" bolted on at the end. It's woven into the normal flow.</p>
<p>The body has a limit. The JSON is parsed safely. The headers go out every time. The API key check is timing-safe. The validation runs before any logic. The error handler refuses to leak internals. And the whole thing is still small enough to hold in your head, which matters, because security you can't understand is security you'll eventually disable by accident.</p>
<p>Try breaking it. Send a huge body and watch the 413. Send <code>{"__proto__": {"isAdmin": true}}</code> and confirm <code>({}).isAdmin</code> is still <code>undefined</code> afterward. Send a wrong API key and notice you can't tell from the response time how close you were. That last one is invisible by design, which is the whole point.</p>
<h2 id="heading-how-to-handle-cors-correctly">How to Handle CORS Correctly</h2>
<p>CORS, which stands for Cross-Origin Resource Sharing, is one of the most misunderstood security features in web development, and the misunderstanding is the dangerous kind.</p>
<p>Here's the part people get wrong: CORS doesn't protect your server. It's not a firewall. It's a browser feature that decides whether JavaScript running on one website is allowed to read the response from your API on another. Your server stays perfectly reachable from curl, from Postman, and from any other server, CORS headers or not.</p>
<p>What that means in practice is that the most common "fix" people apply is also the most common mistake:</p>
<pre><code class="language-ts">res.setHeader("Access-Control-Allow-Origin", "*"); // understand this before you ship it
</code></pre>
<p>A wildcard says "any website's JavaScript may read my responses." For a genuinely public, read-only API with no credentials, that can be perfectly fine. For anything that uses cookies or returns data tied to a logged-in user, it's a mistake, and browsers will refuse to combine <code>*</code> with credentials anyway.</p>
<p>The correct approach is to allow only the origins you actually trust:</p>
<pre><code class="language-ts">const ALLOWED_ORIGINS = new Set(["https://app.example.com"]);

function applyCors(req, res) {
  const origin = req.headers.origin;
  if (origin &amp;&amp; ALLOWED_ORIGINS.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Vary", "Origin"); // so a cache does not mix origins up
    res.setHeader("Access-Control-Allow-Credentials", "true");
  }
}
</code></pre>
<p>Keep this mental model: CORS loosens the browser's default protection in a controlled way. Setting it to <code>*</code> doesn't make your API more exposed to attacks from other servers, because servers were never restricted in the first place. It makes your data readable by any web page a victim happens to visit, which is a privacy and data-exposure decision, not a "make the console error go away" decision. Decide it on purpose, origin by origin.</p>
<h2 id="heading-what-this-tutorial-doesnt-cover">What This Tutorial Doesn't Cover</h2>
<p>Honesty time, because a tutorial that pretends to be totally complete is doing you a disservice. The guardrails above are the baseline, not the finish line.</p>
<p>Here's what's deliberately out of scope and where to look next:</p>
<ul>
<li><p><strong>Authentication and authorization:</strong> You checked one API key. Real apps need sessions or tokens, and a real story for who is allowed to do what. That is a whole topic on its own.</p>
</li>
<li><p><strong>Rate limiting:</strong> A single client shouldn't be able to hammer your login route ten thousand times a minute. In-memory counters work for one instance. Behind a load balancer you need a shared store like Redis.</p>
</li>
<li><p><strong>Outbound request safety (SSRF):</strong> The moment your server makes requests to URLs a user supplied, you have a new attack surface: someone can point you at internal addresses or the cloud metadata endpoint. That deserves its own article.</p>
</li>
<li><p><strong>TLS:</strong> Everything HSTS-related assumes you actually terminate HTTPS somewhere, whether that's the runtime, a reverse proxy, or your platform.</p>
</li>
<li><p><strong>Logging and monitoring:</strong> You can't respond to what you can't see. Structured logs with request ids are the unglamorous foundation of every incident response that went well.</p>
</li>
</ul>
<p>Each of these is a future tutorial, and each one follows the same philosophy as this one: make the safe choice the default, and make the unsafe choice something you have to go out of your way to do.</p>
<h2 id="heading-why-defaults-beat-checklists">Why Defaults Beat Checklists</h2>
<p>You might be wondering why I keep saying "by default" instead of just handing you a checklist and wishing you luck. The reason is that I've watched a lot of checklists lose to a deadline.</p>
<p>A checklist is a list of things a human has to remember to do, correctly, every single time, forever. That includes the junior dev who joined last week, and the senior dev who's exhausted and shipping a hotfix at midnight.</p>
<p>Security that depends on perfect human memory is security that quietly degrades the moment the team gets busy, which is precisely the moment an attacker is hoping for.</p>
<p>A default is a different kind of thing. A default is what happens when nobody does anything at all. If the safe behavior is the default, then forgetting produces a safe app. If the unsafe behavior is the default, then forgetting produces a vulnerability, and people forget constantly, because they're human and they have forty other things on their plate.</p>
<p>This is exactly why the helpers in this article are wrappers you call once at the top of a request, instead of steps you sprinkle through your handlers and hope you got them all. It's the same reason frameworks that take security seriously turn protections on and make you opt out, rather than leaving them off and making you opt in.</p>
<p>The wording sounds like a small difference. Measured across a real team over a real year, the difference in outcomes is enormous. Design it so the lazy path and the safe path are the same path, and you'll be surprised how secure "lazy" can be.</p>
<h2 id="heading-an-honest-note-on-frameworks">An Honest Note on Frameworks</h2>
<p>If wiring all of this by hand every time sounds tedious, that's exactly the right instinct, and it's why some frameworks ship these protections on by default so you don't have to remember them.</p>
<p>Full disclosure: I maintain one of them, an open-source project called DaloyJS. I'm not here to sell it, and everything in this article is plain Node that works the same no matter what you build on.</p>
<p>I mention it only because the lesson that produced it is the lesson of this whole piece: the defaults are the product. A framework that makes you opt into safety will, statistically, be run by someone who forgot to.</p>
<p>Whether you use a framework or roll your own, copy the helpers above into your project today. They're dependency-free and they will quietly prevent a category of bad days.</p>
<h2 id="heading-the-takeaway-checklist">The Takeaway Checklist</h2>
<p>If you remember nothing else, remember this list and put it somewhere your team will see it:</p>
<ul>
<li><p><strong>Limit the body:</strong> Count bytes as they stream, reject past your cap, never trust <code>Content-Length</code> alone.</p>
</li>
<li><p><strong>Time out everything:</strong> Tighten Node's request and header timeouts, and give every outbound call a deadline with <code>AbortController</code>.</p>
</li>
<li><p><strong>Parse JSON defensively:</strong> Catch parse errors into a clean 400, and strip <code>__proto__</code>, <code>constructor</code>, and <code>prototype</code> with a reviver.</p>
</li>
<li><p><strong>Set security headers on every response:</strong> Wrap them in one function so "every response" actually means every response.</p>
</li>
<li><p><strong>Compare secrets in constant time:</strong> Use <code>crypto.timingSafeEqual</code> on hashed inputs, never <code>===</code>, and use a real password hash for passwords.</p>
</li>
<li><p><strong>Validate input as a gate:</strong> Define the shape on purpose, reject what doesn't match, and copy only the fields you asked for.</p>
</li>
</ul>
<p>None of this is clever. That's the best thing about it. The boring stuff is what gets you, so make the boring stuff automatic, and go spend your cleverness on the parts of your product that actually need it.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an Offline AI Image Generator in Node.js with QVAC and Socket.io ]]>
                </title>
                <description>
                    <![CDATA[ A few years ago, the first day I finally got access to an AI image generator, I was so excited that I immediately sat down and wrote an article about it (using Node.js and OpenAI's DALL-E). The magic  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-an-offline-ai-image-generator-in-node-js-with-qvac-and-socket-io/</link>
                <guid isPermaLink="false">6a3417433a2cf3cf64bb2225</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ image generation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ stable diffusion ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Express ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Jibril-M🍀 ]]>
                </dc:creator>
                <pubDate>Thu, 18 Jun 2026 16:05:23 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/db3fe63d-6df2-4250-b8f2-c166d9eafc3b.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>A few years ago, the first day I finally got access to an AI image generator, I was so excited that I immediately sat down and wrote an article about it <a href="https://dev.to/djibrilm/dall-e-with-nodejs-5chb">(using Node.js and OpenAI's DALL-E)</a>. The magic of turning thoughts directly into digital pixels felt like holding a real-life magic wand.</p>
<p>But back then, accessing these models wasn't a walk in the park. Our primary option was Midjourney, which meant you had to struggle on Discord, and sometimes you couldn't do anything due to rate limits and servers being very busy.</p>
<p>Accessing image generation back then felt like trying to order a coffee during a flash mob.</p>
<p>Thankfully, the landscape has completely shifted. Today, not only can we run state-of-the-art models like Stable Diffusion on consumer hardware, but we can do it locally, offline, and completely free of charge. We don't need any API keys, there aren't any subscription rate limits, and there's no Discord channels to deal with.</p>
<p>In this tutorial, we'll build a local web application using Node.js, Express, Socket.io, and the QVAC SDK to run a quantized Stable Diffusion 2.1 model.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-is-qvac">What is QVAC?</a></p>
</li>
<li><p><a href="#heading-how-stable-diffusion-works-under-the-hood">How Stable Diffusion Works Under the Hood</a></p>
</li>
<li><p><a href="#heading-gpu-limitations">GPU Limitations: Metal, AMD, and the Intel Mac Trap</a></p>
</li>
<li><p><a href="#heading-the-image-generation-pipeline">The Image Generation Pipeline</a></p>
</li>
<li><p><a href="#heading-complete-implementation">Complete Implementation</a></p>
</li>
<li><p><a href="#heading-codebase-breakdown">Codebase Breakdown</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-resources-and-further-reading">Resources and Further Reading</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To get the most out of this tutorial, you should have a solid foundation in web backend and frontend basics:</p>
<ul>
<li><p><strong>Node.js and ES Modules</strong>: Basic familiarity with modern JavaScript modules (<code>import</code>/<code>export</code>), async loops, and event listeners.</p>
</li>
<li><p><strong>Express and WebSockets</strong>: Familiarity with routing static files and sending real-time messages over WebSockets with <code>socket.io</code>.</p>
</li>
<li><p><strong>HTML and Vanilla CSS</strong>: Understanding of basic DOM manipulation and style bindings.</p>
</li>
<li><p><strong>Development environment</strong>: A local machine with Node.js installed.</p>
</li>
</ul>
<h2 id="heading-what-is-qvac">What is QVAC?</h2>
<p>Developed by Tether, QVAC is a family of local inference tools designed to execute machine learning models directly on client hardware.</p>
<p>Instead of routing inference requests to expensive cloud-hosted APIs (such as DALL-E or Midjourney), QVAC bundles pre-compiled machine learning runtimes (like <code>llama.cpp</code> for text, <code>whisper.cpp</code> for transcription, and custom diffusion backends) directly into Node.js, mobile, and desktop runtimes.</p>
<p>Running local AI models with QVAC offers several practical advantages:</p>
<ul>
<li><p><strong>Zero API costs</strong>: Generate as many images as your hardware can handle without recurring costs.</p>
</li>
<li><p><strong>Privacy-first</strong>: Prompts and generated images are kept entirely in memory on your local machine.</p>
</li>
<li><p><strong>Offline independence</strong>: Run your application in isolated networks, on flights, or in regions without internet access.</p>
</li>
</ul>
<h2 id="heading-how-stable-diffusion-works-under-the-hood">How Stable Diffusion Works Under the Hood</h2>
<p>To execute image generation locally without running out of RAM, QVAC leverages a quantized <strong>Stable Diffusion 2.1 GGUF</strong> model (<code>SD_V2_1_1B_Q8_0</code>).</p>
<p>But how does this actual image generation process work conceptually? Let's make one thing clear: <strong>this is not a scientific paper</strong>. We aren't going to dive into the underlying multivariable calculus, probability distributions, or stochastic differential equations because I'm not a low-level machine learning researcher (and let's be honest, neither of us wants to stare at Greek symbols and linear algebra formulas on a screen when we could be writing clean JavaScript).</p>
<p>Instead, let's understand how these models work conceptually, using some intuitive developer analogies.</p>
<h3 id="heading-the-world-class-sculptor-analogy">The World-Class Sculptor Analogy</h3>
<p>At its core, modern AI image generation turns randomness into reality. Instead of "painting" an image from scratch, pixel-by-pixel, like a human illustrator with a brush, the AI essentially acts as a world-class sculptor, carving an image out of a block of digital static.</p>
<p>The most dominant technology behind this today is <strong>Diffusion</strong>, which powers models like Stable Diffusion, Midjourney, and Google's Imagen series.</p>
<p>Here is the conceptual step-by-step breakdown of how this block of static turns into art:</p>
<h4 id="heading-1-the-training-phase-learning-the-patterns">1. The Training Phase (Learning the Patterns)</h4>
<p>Before a model can generate anything, it has to look at billions of images and their corresponding text descriptions. During this phase, developers do something counterintuitive: <strong>they intentionally ruin the images</strong>.</p>
<ul>
<li><p><strong>Adding noise:</strong> The system takes a clear picture (for example, of a cat) and gradually adds random digital static (noise) pixel-by-pixel until the original image is completely unrecognizable.</p>
</li>
<li><p><strong>Learning to reverse it:</strong> The AI's job is to look at a noisy image and predict exactly how much noise was added at that specific step. By doing this billions of times, it becomes an expert at denoising – that is, turning chaos back into order.</p>
</li>
</ul>
<h4 id="heading-2-connecting-words-to-visuals-clip">2. Connecting Words to Visuals (CLIP)</h4>
<p>To make sure the AI knows what a <em>"cat wearing a top hat"</em> looks like, it uses a text-to-image bridge, often powered by a system called <strong>CLIP</strong> (Contrastive Language-Image Pre-training).</p>
<ul>
<li><p>CLIP translates human language into a mathematical map (called an <strong>embedding</strong>).</p>
</li>
<li><p>In this map, the words "cat" and the actual pixels of a cat sit very close together. This ensures that when you type a prompt, the AI knows exactly which visual concepts to pull from its memory.</p>
</li>
</ul>
<h4 id="heading-3-the-generation-phase-the-reverse-diffusion-loop">3. The Generation Phase (The Reverse Diffusion Loop)</h4>
<p>When you type a prompt and hit "Generate," the magic happens in reverse:</p>
<ul>
<li><p><strong>The blank canvas:</strong> The AI starts with a canvas of pure, 100% random digital noise (it looks like old television static).</p>
</li>
<li><p><strong>The prompt guidance:</strong> The AI looks at your prompt and uses its text embedding to guide its eye. It looks at the random static and asks, <em>"Where in this mess can I start to see a cat?"</em></p>
</li>
<li><p><strong>Step-by-step denoising:</strong> The AI subtracts a little bit of noise, sharpening the image slightly. It repeats this loop 20 to 50 times. With every step, fuzzy shapes turn into rough outlines, textures appear, and eventually, a crisp, clean, brand-new image emerges.</p>
</li>
</ul>
<p><strong>Fun fact about seeds:</strong> Because the process starts with completely random static every single time, typing the exact same prompt twice will always give you a completely different image (unless you lock down the starting randomness using a specific number called a <strong>Seed</strong>).</p>
<p>Here's an illustration of denoising with diffusion models:</p>
<img src="https://cdn.hashnode.com/uploads/covers/68e4f3e9867c1707d1b057a9/19131e8b-0f4f-4b76-a85b-2c7be2460a10.png" alt="Denoising with diffusion models diagram " style="display: block;" width="3584" height="1234" loading="lazy">

<h3 id="heading-latent-diffusion-keeping-it-fast-the-vae">Latent Diffusion: Keeping it Fast (The VAE)</h3>
<p>Generating high-resolution images pixel-by-pixel requires massive computing power. If we tried to do this directly in pixel space on consumer hardware, our computers would melt, and a single generation would take hours.</p>
<p>To fix this, modern models use <strong>Latent Diffusion</strong>.</p>
<p>Instead of working with the full-sized image, a component called an <strong>encoder</strong> compresses the image into a smaller, abstract mathematical space (the <strong>"latent space"</strong>). Think of it as a shrunken playground where all the noisy/denoising math happens. Because this playground is so small, the computations are incredibly fast.</p>
<p>Once the denoising loop finishes in the latent space, another component called the <strong>decoder</strong> (specifically, a Variational Autoencoder, or VAE) blows it back up into a sharp, high-resolution image for you to see.</p>
<h3 id="heading-architectures-supported-by-qvac">Architectures Supported by QVAC</h3>
<p>When you run local inference with QVAC, the SDK hooks into optimized, community-maintained C++ backends. QVAC manages the hardware bindings and model lifecycles for different AI modalities:</p>
<ol>
<li><p><strong>Text generation (</strong><code>llama.cpp</code><strong>):</strong> Used for large language models (LLMs) like Llama 3 or Mistral, executing auto-regressive token prediction.</p>
</li>
<li><p><strong>Audio transcription (</strong><code>whisper.cpp</code><strong>):</strong> Used for highly optimized speech-to-text transcription.</p>
</li>
<li><p><strong>Image Generation (</strong><code>stable-diffusion.cpp</code> <strong>/</strong> <code>sdcpp-generation</code><strong>):</strong> Our focus in this tutorial. QVAC supports two distinct approaches for image generation depending on the model architecture you choose:</p>
<ul>
<li><p><strong>The Bundled Model Approach (Stable Diffusion 1.5/2.1/XL):</strong> The traditional approach where the entire pipeline (Text Encoders, VAE, and the main Diffusion UNet) is baked into a single, unified GGUF file (for example, <code>SD_V2_1_1B_Q8_0</code>).  </p>
<p>This is incredibly convenient for local deployments because you only need to manage and load one file to start generating images.</p>
</li>
<li><p><strong>The Modular Multi-Model Approach (Flux):</strong> Modern architectures like <strong>FLUX.1</strong> use a much more complex setup. Instead of a single file, Flux splits its computational brain into separate components. You load a core Diffusion Transformer (DiT) model, but you must also separately load large text encoders (like T5-v1.1-xxl and CLIP-L) and an independent VAE model.  </p>
<p>While this requires more complex orchestration to load multiple GGUF files simultaneously, it provides vastly superior prompt adherence and photorealism by utilizing dedicated, massive text-understanding models.</p>
</li>
</ul>
</li>
<li><p><strong>Speech synthesis (TTS):</strong> Specialized architectures like <em>Chatterbox</em> (transformer-based zero-shot voice cloning) and <em>Supertonic</em> (diffusion-based speech denoising).</p>
</li>
</ol>
<h2 id="heading-gpu-limitations-metal-amd-and-the-intel-mac-trap">GPU Limitations: Metal, AMD, and the Intel Mac Trap</h2>
<p>When running machine learning models locally on Apple Mac hardware, QVAC will try to automatically accelerate execution by compiling compute pipelines for the <strong>Metal</strong> API to utilize the system's GPU.</p>
<p>If you're on an Apple Silicon Mac (M1, M2, M3, M4, or M5 chip), this works seamlessly, and generation will compile on the Apple Neural Engine and Unified GPU memory in seconds.</p>
<p>But if you're running on an older Intel-based Mac with a discrete <strong>AMD Radeon GPU</strong> (such as the AMD Radeon Pro 5500M commonly found in 16-inch MacBook Pros), you'll run into a major driver-level limitation:</p>
<ul>
<li><p>The macOS Metal driver for older AMD discrete GPUs doesn't support the modern machine learning compute shaders and matrix reduction operators used by <code>stable-diffusion.cpp</code>.</p>
</li>
<li><p>When the inference worker attempts to run these unsupported operations, the driver fails to compile the pipeline and triggers a hard C++ crash (<code>SIGABRT</code>) inside the <code>ggml-metal-ops.cpp</code> shader encoder, abruptly exiting the background worker process.</p>
</li>
</ul>
<p>If you hit this hardware roadblock, the default GPU configuration will crash the application every time you trigger an image generation.</p>
<p>To resolve this, you should configure the model to run on the CPU instead by setting the model configuration parameter <code>device</code> to <code>"cpu"</code> and specifying the threads (for example, <code>threads: 4</code>). While generating images on the CPU takes longer than on a GPU, it runs successfully on any machine, regardless of how old or limited its GPU is.</p>
<h2 id="heading-the-image-generation-pipeline">The Image Generation Pipeline</h2>
<p>To coordinate the local execution lifecycle, our app sets up a real-time event pipeline:</p>
<pre><code class="language-plaintext">[Browser Client]                                  [Node.js Server]
       |                                                 |
       | ------ 1. Connects &amp; Checks Model ---------&gt;    |
       | &lt;----- 2. Downloads &amp; Loads Model ----------     | (Model Cached locally)
       |                                                 |
       | ------ 3. Submits prompt ("Cozy cabin...") -&gt;  |
       |                                                 |
       |                                                 | === [ QVAC Inference Engine ] ===
       |                                                 | 
       | &lt;----- 4. Denoising Step Updates (e.g. 5/20) -- | (Streams steps in real time)
       |                                                 |
       | &lt;----- 5. Sends final image (Base64 DataURL) -- | (Direct in-memory payload)
       |                                                 |
</code></pre>
<h2 id="heading-complete-implementation">Complete Implementation</h2>
<p>Let's look at the implementation. You can <a href="https://github.com/DjibrilM/qvac-local-image-generation-Case-study-">clone the full project repository</a> to follow along, or build it from scratch by creating a project folder, running <code>npm init -y</code>, installing the dependencies (<code>@qvac/sdk</code>, <code>express</code>, <code>socket.io</code>, <code>concurrently</code>), and configuring <code>"type": "module"</code> in your <code>package.json</code>.</p>
<h3 id="heading-1-server-configuration-serverjs">1. Server Configuration (<code>server.js</code>)</h3>
<p>Create a file named <code>server.js</code> and paste the following implementation:</p>
<pre><code class="language-javascript">import express from 'express';
import path from 'path';
import http from 'http';
import { Server } from 'socket.io';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { loadModel, unloadModel, getLoadedModelInfo, diffusion, SD_V2_1_1B_Q8_0 } from "@qvac/sdk";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const app = express();
const server = http.createServer(app);
const io = new Server(server);

const PORT = process.env.PORT || 3000;

app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));

const CONFIG_PATH = path.join(__dirname, '.device-preference.json');

function getPreferredDevice() {
  try {
    if (fs.existsSync(CONFIG_PATH)) {
      const data = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
      return data.device || null;
    }
  } catch (err) {
    console.error('Failed to read device preference:', err.message);
  }
  return null;
}

function setPreferredDevice(device) {
  try {
    fs.writeFileSync(CONFIG_PATH, JSON.stringify({ device }), 'utf8');
  } catch (err) {
    console.error('Failed to write device preference:', err.message);
  }
}

// Global model state
let loadedModelId = process.modelId || null;
let modelLoadPercent = 0;
let modelLoadStatus = 'Awaiting trigger...';
let isModelLoading = false;

const modelSize = (SD_V2_1_1B_Q8_0.expectedSize / (1024 * 1024 * 1024)).toFixed(2) + ' GB';

function broadcastModelProgress(percent, status) {
  io.emit('model-download-progress', { percent, status, size: modelSize });
}

io.on('connection', (socket) =&gt; {
  console.log('Client connected:', socket.id);

  socket.on('disconnect', () =&gt; {
    console.log('Client disconnected:', socket.id);
  });

  // Trigger model download
  socket.on('trigger-model-download', async () =&gt; {
    // If already loaded, verify it's still alive in the worker
    if (loadedModelId) {
      try {
        await getLoadedModelInfo({ modelId: loadedModelId });
        socket.emit('model-download-progress', {
          percent: 100,
          status: 'Model fully loaded locally.',
          size: modelSize
        });
        return;
      } catch (err) {
        console.log('Model ID was stale/not found, resetting state and reloading...', err.message);
        loadedModelId = null;
        process.modelId = null;
      }
    }

    // If currently loading, report current progress
    if (isModelLoading) {
      socket.emit('model-download-progress', {
        percent: Math.round(modelLoadPercent),
        status: modelLoadStatus,
        size: modelSize
      });
      return;
    }

    isModelLoading = true;
    modelLoadPercent = 0;
    modelLoadStatus = 'Initiating model download...';
    broadcastModelProgress(modelLoadPercent, modelLoadStatus);

    try {
      console.log('Starting model download...');
      const preferredDevice = getPreferredDevice();
      const loadConfig = { prediction: "v" };
      if (preferredDevice) {
        loadConfig.device = preferredDevice;
        if (preferredDevice === 'cpu') {
          loadConfig.threads = 4;
        }
        console.log(`Using cached device preference: ${preferredDevice}`);
      }

      loadedModelId = await loadModel({
        modelSrc: SD_V2_1_1B_Q8_0,
        modelType: "sdcpp-generation",
        modelConfig: loadConfig,
        onProgress: (p) =&gt; {
          modelLoadPercent = p.percentage;
          modelLoadStatus = p.percentage &gt;= 100 ? 'Model fully loaded locally.' : `Downloading model weights... (${p.percentage.toFixed(1)}%)`;
          broadcastModelProgress(Math.round(modelLoadPercent), modelLoadStatus);
        }
      });
      process.modelId = loadedModelId;

      isModelLoading = false;
      console.log('Model loaded successfully. ID:', loadedModelId);
    } catch (err) {
      isModelLoading = false;
      modelLoadPercent = 0;
      modelLoadStatus = 'Failed to load model: ' + err.message;
      console.error('Failed to load model:', err);
      broadcastModelProgress(0, modelLoadStatus);
      socket.emit('error_event', { message: 'Failed to load model: ' + err.message });
    }
  });

  socket.on('generate', async (data) =&gt; {
    const { prompt, ratio } = data;
    if (!prompt || prompt.trim() === '') {
      socket.emit('error_event', { message: 'Prompt is required' });
      return;
    }

    if (!loadedModelId) {
      socket.emit('error_event', { message: 'Model is not loaded yet' });
      return;
    }

    const runDiffusion = async (modelIdToUse) =&gt; {
      socket.emit('progress', {
        percent: 0,
        status: 'Starting diffusion process...',
        sub: 'DIFFUSION INITIALIZING'
      });

      console.log(`Generating image for prompt: "\({prompt}" with ratio: \){ratio} using model ID: ${modelIdToUse}`);

      const { progressStream, outputs, stats } = diffusion({
        modelId: modelIdToUse,
        prompt,
      });

      // Stream progress steps
      for await (const { step, totalSteps } of progressStream) {
        const percent = Math.round((step / totalSteps) * 100);
        socket.emit('progress', {
          percent,
          status: `Denoising step \({step}/\){totalSteps}...`,
          sub: 'RUNNING DIFFUSION'
        });
      }

      // Resolve output buffers
      const buffers = await outputs;
      if (!buffers || buffers.length === 0) {
        throw new Error('No image buffer returned from diffusion model.');
      }

      // Convert image buffer to a base64 Data URL instead of saving to disk
      const base64Data = Buffer.from(buffers[0]).toString('base64');
      const dataUrl = `data:image/png;base64,${base64Data}`;

      // Emit success
      socket.emit('success', {
        url: dataUrl,
        prompt,
        seed: (await stats).seed || -1
      });

      console.log(`Image generated and emitted successfully as base64 Data URL.`);
    };

    try {
      await runDiffusion(loadedModelId);
    } catch (err) {
      console.error('Image generation failed:', err);

      const isCrash = err.code === 50205 || (err.message &amp;&amp; err.message.includes('WORKER_CRASHED'));
      if (isCrash) {
        console.log('Worker crashed during GPU execution. Attempting CPU fallback...');

        // Save device preference so we load CPU directly next time and prevent double loading
        setPreferredDevice('cpu');

        // Reset the stale model state
        loadedModelId = null;
        process.modelId = null;

        socket.emit('progress', {
          percent: 0,
          status: 'GPU driver crashed. Automatically falling back to CPU mode...',
          sub: 'CPU FALLBACK LOADING'
        });

        try {
          console.log('Loading model on CPU...');
          isModelLoading = true;
          modelLoadPercent = 0;
          modelLoadStatus = 'Loading CPU model weights...';
          broadcastModelProgress(modelLoadPercent, modelLoadStatus);

          loadedModelId = await loadModel({
            modelSrc: SD_V2_1_1B_Q8_0,
            modelType: "sdcpp-generation",
            modelConfig: { prediction: "v", device: 'cpu', threads: 4 },
            onProgress: (p) =&gt; {
              modelLoadPercent = p.percentage;
              modelLoadStatus = `Loading CPU model weights... (${p.percentage.toFixed(1)}%)`;
              broadcastModelProgress(Math.round(modelLoadPercent), modelLoadStatus);
            }
          });
          process.modelId = loadedModelId;
          isModelLoading = false;
          console.log('Model loaded successfully on CPU. ID:', loadedModelId);

          // Retry diffusion on CPU
          await runDiffusion(loadedModelId);
        } catch (cpuErr) {
          console.error('CPU fallback execution failed:', cpuErr);
          isModelLoading = false;
          socket.emit('error_event', { message: 'Image generation failed on CPU: ' + cpuErr.message });
        }
      } else {
        if (err.message &amp;&amp; (err.message.includes('MODEL_NOT_FOUND') || err.message.includes('not found'))) {
          loadedModelId = null;
          process.modelId = null;
          broadcastModelProgress(0, 'Model state lost. Please re-trigger download.');
        }
        socket.emit('error_event', { message: 'Image generation failed: ' + err.message });
      }
    }
  });
});

app.get('*', (req, res) =&gt; {
  res.sendFile(path.join(__dirname, 'public', 'index.html'));
});

server.listen(PORT, () =&gt; {
  console.log(`Server is running at http://localhost:${PORT}`);
});

// Clean exit handler
async function handleCleanup() {
  const modelId = process.modelId || loadedModelId;
  if (modelId &amp;&amp; modelId !== 'mock-model-id') {
    try {
      await unloadModel({ modelId, clearStorage: false });
    } catch (err) {}
  }
  process.exit(0);
}

process.on('SIGINT', handleCleanup);
process.on('SIGTERM', handleCleanup);
</code></pre>
<h3 id="heading-2-frontend-architecture-summary">2. Frontend Architecture Summary</h3>
<p>Since our application runs completely locally, the frontend is a single-page web app built with vanilla HTML, CSS, and client-side JavaScript that communicates with our Express server over <strong>Socket.io</strong> WebSockets.</p>
<p>Rather than cluttering this tutorial with hundreds of lines of UI templates and style sheets, we'll keep the focus entirely on the backend orchestration. You can grab the complete HTML layout, Tailwind styles, and client script from the <a href="#heading-resources-and-further-reading">GitHub Repository</a>.</p>
<p>Here is a summary of how the client communicates with the server under the hood:</p>
<ol>
<li><p><strong>Preflight sync (</strong><code>trigger-model-download</code><strong>):</strong> As soon as the page loads, the client establishes a WebSocket connection and emits <code>trigger-model-download</code>. The server intercepts this to check if the model is cached/loading, and begins broadcasting progress.</p>
</li>
<li><p><strong>Denoising stream (</strong><code>progress</code><strong>):</strong> During image generation, the server constantly streams progress events containing denoising statistics (for example <code>Denoising step 12/20...</code>). The client updates the visual progress bar and status labels accordingly.</p>
</li>
<li><p><strong>Data URL delivery (</strong><code>success</code><strong>):</strong> When the diffusion steps are completed, the server converts the binary image buffer into a Base64 string and emits a <code>success</code> event. The client binds this Base64 Data URL directly to the source of the <code>&lt;img&gt;</code> element for direct local display and instant download.</p>
</li>
</ol>
<h2 id="heading-codebase-breakdown">Codebase Breakdown</h2>
<p>Let’s lift the hood on the key mechanisms that make our local offline image generator work smoothly.</p>
<h3 id="heading-1-multi-client-model-id-binding-processmodelid">1. Multi-Client Model ID Binding (<code>process.modelId</code>)</h3>
<p>Quantized weights take a significant amount of memory. Every time we call <code>loadModel()</code>, QVAC boots a separate C++ background process (a <code>Bare</code> worker) to host the GGML runtime.</p>
<p>To prevent spawning multiple processes or loading the 2.3 GB GGUF model multiple times when a client refreshes a page or opens another browser tab, we store the loaded model ID globally on Node’s <code>process</code> object:</p>
<pre><code class="language-javascript">let loadedModelId = process.modelId || null;
// ...
process.modelId = loadedModelId;
</code></pre>
<p>This acts as a process-wide singleton registry. But using a global variable introduces a challenge: <strong>stale worker processes</strong>. If a client triggers a model load, gets an ID, and the background worker process later crashes or is killed, <code>process.modelId</code> remains populated with a dead reference.</p>
<p>To resolve this, every time a new client connects and requests a model download trigger, we preflight the model ID using <code>getLoadedModelInfo</code>:</p>
<pre><code class="language-javascript">if (loadedModelId) {
  try {
    await getLoadedModelInfo({ modelId: loadedModelId });
    socket.emit('model-download-progress', { percent: 100, status: 'Model fully loaded locally.' });
    return;
  } catch (err) {
    console.log('Model ID was stale, resetting state...', err.message);
    loadedModelId = null;
    process.modelId = null;
  }
}
</code></pre>
<p>If the background worker is dead, <code>getLoadedModelInfo</code> throws an error. The catch block intercepts this, wipes the stale references, and safely restarts the loading routine.</p>
<p>[!IMPORTANT] <strong>Process singleton integrity:</strong> Always preflight model state visibility before initiating inference. Without validation checks, attempting <code>diffusion()</code> on a stale model ID will trigger immediate client-side connection timeouts and silent backend worker failures.</p>
<h3 id="heading-2-in-memory-image-serialization-zero-disk-writes">2. In-Memory Image Serialization (Zero Disk Writes)</h3>
<p>Writing generated images to the server's hard drive creates significant I/O overhead. It forces you to write custom cron cleanup scripts to delete old image files, and runs the risk of running out of disk space on systems with high user traffic.</p>
<p>Since QVAC’s <code>diffusion()</code> function outputs generated PNG files directly as in-memory binary buffers (<code>Uint8Array</code>), we bypass the local file system entirely. We serialize the binary array into a Base64 string directly in memory:</p>
<pre><code class="language-javascript">const base64Data = Buffer.from(buffers[0]).toString('base64');
const dataUrl = `data:image/png;base64,${base64Data}`;
</code></pre>
<p>This Data URL is transmitted over WebSockets to the client, which immediately binds it to the image element:</p>
<ul>
<li><p><strong>Zero disk overhead:</strong> The server doesn't write a single byte to the hard drive, preserving SSD life and preventing storage bloat.</p>
</li>
<li><p><strong>Instant delivery:</strong> Transmission is handled entirely within network memory buffers, bypassing disk serialization latency.</p>
</li>
<li><p><strong>Effortless client integration:</strong> The client doesn't need to request a static image URL path. It directly renders the Base64 Data URL, allowing users to save or download the image instantly.</p>
</li>
</ul>
<h3 id="heading-3-gpu-to-cpu-fallback-amp-preference-cache-strategy">3. GPU-to-CPU Fallback &amp; Preference Cache Strategy</h3>
<p>One of the biggest challenges with local-first AI is client hardware heterogeneity. For example, older Intel Macs with discrete AMD Radeon GPUs support Apple's Metal framework, but lack the modern tensor reduction operators used by the Stable Diffusion engine, causing a hard C++ crash (<code>SIGABRT</code>) inside <code>ggml-metal-ops.cpp</code>.</p>
<p>To keep the application running and ensure we don't trigger the model loading twice (once on the incompatible GPU on startup, and once on the CPU fallback after the first prompt crash), we use a persistent <strong>device preference cache</strong> file (<code>.device-preference.json</code>) alongside our C++ worker crash interceptor:</p>
<pre><code class="language-javascript">try {
  await runDiffusion(loadedModelId);
} catch (err) {
  const isCrash = err.code === 50205 || err.message.includes('WORKER_CRASHED');
  if (isCrash) {
    // 1. Cache the CPU preference on disk
    setPreferredDevice('cpu');

    // 2. Reset stale references
    loadedModelId = null;
    process.modelId = null;

    // 3. Automatically load the model on CPU with multi-threading
    loadedModelId = await loadModel({
      modelSrc: SD_V2_1_1B_Q8_0,
      modelType: "sdcpp-generation",
      modelConfig: { prediction: "v", device: "cpu", threads: 4 }
    });
    process.modelId = loadedModelId;

    // 4. Transparently retry generation
    await runDiffusion(loadedModelId);
  }
}
</code></pre>
<p>This approach utilizes a two-layered defense:</p>
<ol>
<li><p><strong>Dynamic recovery:</strong> If a GPU driver error triggers a crash, the app intercepts it, saves <code>"device": "cpu"</code> to the <code>.device-preference.json</code> file, dynamically reloads the weights into CPU threads, and retries the generation. The client simply sees a status update indicating CPU fallback is occurring, surviving what would otherwise be a fatal crash.</p>
</li>
<li><p><strong>Preference persistence:</strong> The next time the server starts or a page is loaded, the preflight loading routine reads the cached preference from the disk and loads the CPU model immediately:</p>
</li>
</ol>
<pre><code class="language-javascript">const preferredDevice = getPreferredDevice(); // Reads .device-preference.json
const loadConfig = { prediction: "v" };
if (preferredDevice) {
  loadConfig.device = preferredDevice;
  if (preferredDevice === 'cpu') {
    loadConfig.threads = 4;
  }
}
loadedModelId = await loadModel({
  modelSrc: SD_V2_1_1B_Q8_0,
  modelType: "sdcpp-generation",
  modelConfig: loadConfig,
  // ...
});
</code></pre>
<p>This prevents the server from making redundant GPU load attempts on subsequent sessions, ensuring that the model is loaded only once and directly onto the correct hardware execution target.</p>
<p>[!WARNING] <strong>CPU Fallback Latency:</strong> While CPU mode guarantees resilience across older hardware, it uses sequential multi-threaded calculations instead of GPU hardware cores. Consequently, generation times will be significantly longer (typically 1 to 2 minutes on CPU compared to 10 to 15 seconds on a compatible GPU). Make sure to design responsive progress loaders in the UI to manage user expectations during fallback.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Running local-first Stable Diffusion with QVAC gives you absolute control over your inference costs and data privacy. By coupling on-device GGML models with a simple Node.js WebSocket backend, you can build responsive web tools that run completely offline without ever spending money on cloud APIs.</p>
<p>As mobile and desktop system-on-chip architectures continue to pack more neural engines, local-first AI architectures will become an increasingly powerful option for modern developers.</p>
<h2 id="heading-resources-and-further-reading">Resources and Further Reading</h2>
<ul>
<li><p><a href="https://docs.qvac.tether.io/ai-capabilities/image-generation/#image-to-image"><strong>QVAC Image-to-Image Generation Documentation</strong></a></p>
</li>
<li><p><a href="https://github.com/tetherto/qvac-sdk"><strong>QVAC SDK GitHub Repository</strong></a></p>
</li>
<li><p><a href="https://huggingface.co/models?search=gguf"><strong>Stable Diffusion GGUF models on Hugging Face</strong></a></p>
</li>
<li><p><a href="https://socket.io/docs/v4/"><strong>Socket.io WebSockets Guide</strong></a></p>
</li>
<li><p><a href="https://github.com/DjibrilM/qvac-local-image-generation-Case-study-"><strong>Full codebase</strong></a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
