<?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" version="2.0"><channel><title><![CDATA[Hoisted]]></title><description><![CDATA[Notes from a full stack engineer in London: React and Next.js upgrades, Node, coding with AI agents, and the security advisories that eat my weekends.]]></description><link>https://kishankanejiya.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sun, 01 Nov 2026 13:24:51 GMT</lastBuildDate><atom:link href="https://kishankanejiya.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How GenAI actually works, from one small JavaScript file]]></title><description><![CDATA[I built up one small token.js file to see what an LLM really does: tokens, no memory, system prompts, and thinking in steps.]]></description><link>https://kishankanejiya.hashnode.dev/how-genai-actually-works</link><guid isPermaLink="true">https://kishankanejiya.hashnode.dev/how-genai-actually-works</guid><category><![CDATA[AI]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[kishan kanejiiya]]></dc:creator><pubDate>Sat, 31 Oct 2026 09:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6ada143005beb55db93f5dd8/d3c466bf-970f-42e3-b2c6-31b23928a026.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I've used LLMs at work for a while, but mostly through someone else's wrapper. So I opened an empty file called <code>token.js</code>, with the plan of making it do something and then asking why it did it.</p>
<p>It started as about twenty lines. By the end of the day it could think in steps and check its own work, and most of what I actually learned came from things going wrong on the way there.</p>
<h2>The first call failed twice, neither time because of the code</h2>
<p>The first version was the same snippet everyone starts with:</p>
<pre><code class="language-js">const response = await openai.chat.completions.create({
  model: "gpt-4o-mini",
  messages: [
    { role: "system", content: "You are a helpful assistant." },
    { role: "user", content: "Hello! Can you help me with a coding problem?" },
  ],
});
console.log(response.choices[0].message.content);
</code></pre>
<p>First error: <code>ERR_MODULE_NOT_FOUND</code>. My <code>package.json</code> had <code>tiktoken</code> in it and nothing else, so <code>npm install openai</code> fixed that. Second error:</p>
<pre><code>RateLimitError: 429 You have no credits remaining.
</code></pre>
<p>The name is misleading, because I wasn't sending too many requests. There was simply no money on the API account, and a ChatGPT subscription doesn't pay for API calls. It did tell me one useful thing though: the key was fine, since a bad key gets you a <code>401</code>.</p>
<p>Rather than top up straight away, I tried NVIDIA's hosted models and then <a href="https://openrouter.ai">OpenRouter</a>, and that turned out to be the first real lesson. I didn't have to rewrite anything. Both speak the same API format, so moving was two lines:</p>
<pre><code class="language-js">const openai = new OpenAI({
  baseURL: "https://openrouter.ai/api/v1",
  apiKey: process.env.OPENROUTER_API_KEY,
});
</code></pre>
<p>To check there was no magic in the SDK, I also replaced it with plain <code>fetch</code> for a while, and got the same answer back. Under the wrapper it's a POST request with some JSON in it. That JSON is the whole interface: a model name and a list of messages.</p>
<h2>What the model actually reads</h2>
<p>The model doesn't read words. Before it sees anything, your text is split into tokens, and each token is a number. That's what <code>tiktoken</code> was doing in my <code>package.json</code>, so I ran it on my own prompt:</p>
<pre><code class="language-js">import { encoding_for_model } from "tiktoken";

const enc = encoding_for_model("gpt-4o-mini");
console.log(enc.encode("Hello! Can you help me with a coding problem?"));
</code></pre>
<p>Eleven tokens came back:</p>
<pre><code>13225, 0, 4101, 481, 1652, 668, 483, 261, 22458, 4792, 30
Hello  !   Can you help me with a  coding problem ?
</code></pre>
<p>Common words get a token each, and notice the spaces belong to the token that follows them. A word the tokenizer hasn't seen much gets broken up instead, so the name of this blog comes out as <code>ho</code> and <code>isted</code>.</p>
<p>From there, generating text is one small operation on repeat. The model looks at all the tokens so far, works out how likely each possible next token is, picks one, adds it to the end, and goes again. A long answer is that loop running a few hundred times. Nobody planned the sentence in advance, and it's why streaming responses arrive a few pieces at a time.</p>
<p>It's also why APIs bill per token rather than per request. A token is the unit of work, and it counts in both directions.</p>
<h2>The model remembers nothing</h2>
<p>This was the one that changed how I think about all of it. I'd assumed that chat means the model is keeping track of the conversation. It isn't. Each API call starts from nothing.</p>
<p>The way a chat works is that your code keeps the list and resends all of it, every time:</p>
<pre><code class="language-js">const messages = [
  { role: "system", content: SYSTEM_PROMPT },
  { role: "user", content: question },
];

// after each reply
messages.push({ role: "assistant", content: reply });
</code></pre>
<p>The memory lives in my array, not in the model. Once I'd seen that, a few things I'd only half understood made sense. Long chats cost more, because every old message gets paid for again on every call. Context windows matter, because that list has a size limit. And an assistant that "forgets" something early in a long chat has usually just had it trimmed off the list by whatever app it's running in.</p>
<h2>A system prompt is only the first message</h2>
<p>The system prompt sounds like configuration. In practice it's text at the top of that same list, written to steer whatever comes next. I changed mine to one line:</p>
<pre><code>You are a JS expert and only answer questions about JavaScript.
</code></pre>
<p>That one line made it refuse things. I asked for the capital of France and got:</p>
<pre><code>I'm here to answer questions specifically about JavaScript.
</code></pre>
<p>No code of mine did that. The behaviour came entirely from text that sits in front of the question.</p>
<h2>Making it think in steps</h2>
<p>If the model only ever predicts the next token, you can shape its answer by making it produce the reasoning first. By the time it writes the final answer, those steps are in the context and it's building on them. This is called chain of thought, and it's just a prompting pattern. So my system prompt grew some rules:</p>
<pre><code>Work through every question in steps, and reply with exactly one step each time.
- START: say in your own words what the user is asking.
- THINK: work out one small part of the answer. Use 2 to 4 THINK steps.
- EVALUATE: check the THINK step before it. Is it correct and complete?
- OUTPUT: give the final answer to the user. This is always the last step.

Always reply with JSON in this format:
{ "step": "START" | "THINK" | "EVALUATE" | "OUTPUT", "content": "..." }
</code></pre>
<p>I also added a worked example to the prompt, because showing the format is far more reliable than describing it. My example has a deliberately wrong <code>THINK</code> that the <code>EVALUATE</code> catches, so the model can see what a correction looks like.</p>
<p>Then the code became a loop. Each pass is one API call that returns one step, gets pushed onto the list, and goes back in, until the model says <code>OUTPUT</code>:</p>
<pre><code class="language-js">for (let i = 0; i &lt; MAX_STEPS; i++) {
  const response = await openai.chat.completions.create({
    model: "openai/gpt-4o-mini",
    response_format: { type: "json_object" },
    messages,
  });

  const reply = response.choices[0].message.content;
  messages.push({ role: "assistant", content: reply });

  const { step, content } = JSON.parse(reply);
  console.log(`[${step}] ${content}`);

  if (step === "OUTPUT") return;
}
</code></pre>
<p>Here's a real run, a bit shortened, on <code>[] + {}</code>:</p>
<pre><code>[START] The user wants to know the result of adding an empty array and an empty object.
[THINK] Using the + operator with two objects can lead to type coercion.
[EVALUATE] This is a correct consideration.
[THINK] [] is converted to an empty string, and {} to '[object Object]'.
[EVALUATE] This is correct.
[THINK] [] + {} will result in '' + '[object Object]'.
[EVALUATE] That is correct.
[OUTPUT] [] + {} results in '[object Object]'.
</code></pre>
<p>The answer is right, and watching it arrive one step at a time was the most fun part of the day.</p>
<h2>What the steps don't do</h2>
<p>It would be easy to stop there and call that an agent that checks its own work, but the runs didn't really support that.</p>
<p>I tried a few more JavaScript questions to see how <code>EVALUATE</code> held up. On <code>typeof NaN</code> it did push back on one of its own steps, which was nice to see, and that answer came out right. Then I asked about the old <code>[1, 2, 3].map(parseInt)</code> trap:</p>
<pre><code>[EVALUATE] The evaluations of parseInt with their respective radices are correct.
           The results would lead to NaN for the first two and 3 for the last.
[OUTPUT] [1, 2, 3].map(parseInt) returns [NaN, NaN, 3].
</code></pre>
<p>Node says <code>[1, NaN, NaN]</code>. The reasoning looked careful, every step got approved, and the answer was wrong. That's the same model marking its own homework, and the extra steps made a wrong answer look more trustworthy, not less. Running the code would have settled it in a second, so a real check probably needs to be something outside the model, or at least a different one.</p>
<p>The rules weren't followed all the time either. The prompt says to use 2 to 4 <code>THINK</code> steps, and on the France question it went straight from <code>START</code> to <code>OUTPUT</code>. A good refusal, but not what I'd specified. A system prompt is a strong suggestion, and anything that has to hold every time belongs in code. That's why <code>MAX_STEPS</code> is a plain JavaScript constant and not a line in the prompt.</p>
<p>And it costs more. That <code>[] + {}</code> run took eight API calls, each one resending a longer list than the last, for a question a single call would have answered.</p>
<p>So what I actually have is a small file and a better mental model. Text goes in as numbers, the next token gets predicted over and over, and everything that feels like memory or personality is text being put in front of that loop. I still need to learn how the models are trained, which is a much bigger subject, but I can now look at an AI product and guess what's in its <code>messages</code> array.</p>
<hr />
<p><em>First published on <a href="https://blogs.kishankanejiiya.tech/posts/how-genai-actually-works">my blog</a>. Cover photo: McKay Savage, CC BY 2.0, via Wikimedia Commons (<a href="https://commons.wikimedia.org/wiki/File:India_-_Sights_%26_Culture_-_Hand-loom_pattern_cards_for_silk_sari_weaving_(2507306023).jpg">source</a>).</em></p>
]]></content:encoded></item><item><title><![CDATA[A code block in react-email was rendering HTML instead of showing it]]></title><description><![CDATA[Code samples in react-email's Markdown component came out as real HTML. What caused it, the small fix, and what the fix doesn't do.]]></description><link>https://kishankanejiya.hashnode.dev/react-email-code-escaping</link><guid isPermaLink="true">https://kishankanejiya.hashnode.dev/react-email-code-escaping</guid><category><![CDATA[React]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[kishan kanejiiya]]></dc:creator><pubDate>Fri, 30 Oct 2026 09:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6ada143005beb55db93f5dd8/0adcdb7b-d0b1-4d92-a631-bc00b679c650.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I've been looking for small React bugs to fix in open source projects, and this week I sent one to <a href="https://github.com/resend/react-email">react-email</a>. The change is only a few lines, but the reason for it is a trap I'd like to remember.</p>
<p>The <a href="https://github.com/resend/react-email/issues/3814">issue</a> was short and had a proper repro, which is always a good sign. The <code>&lt;Markdown&gt;</code> component takes a string of markdown and turns it into HTML for an email. Give it a code block with HTML inside:</p>
<pre><code class="language-md">```html
&lt;b&gt;bold&lt;/b&gt; &amp; &lt;i&gt;x&lt;/i&gt;
```

Inline: `&lt;br&gt;`
</code></pre>
<p>and you'd expect to see those tags as text, because showing the snippet is the whole point of a code block. What actually came out was this (inline styles stripped):</p>
<pre><code class="language-html">&lt;pre&gt;&lt;code&gt;&lt;b&gt;bold&lt;/b&gt; &amp; &lt;i&gt;x&lt;/i&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Inline: &lt;code&gt;&lt;br&gt;&lt;/code&gt;&lt;/p&gt;
</code></pre>
<p>The tags went into the email as real tags. So a tutorial explaining <code>&lt;b&gt;</code> shows you bold text, and the <code>&lt;br&gt;</code> in that sentence is just a line break. Nothing crashes and nothing logs an error. The code sample quietly goes missing.</p>
<h2>Why it happened</h2>
<p>react-email uses <a href="https://github.com/markedjs/marked">marked</a> to parse the markdown, and marked's own renderer does escape code. This is the relevant line from the <code>code</code> method in its current source:</p>
<pre><code class="language-ts">(escaped ? code : escapeHtmlEntities(code, true))
</code></pre>
<p>The catch is that email needs inline styles, so react-email replaces marked's renderers with its own, one per element, each adding a <code>style</code> attribute. Its <code>code</code> and <code>codespan</code> renderers looked like this, shortened:</p>
<pre><code class="language-ts">renderer.code = ({ text }) =&gt; {
  text = `${text.replace(/\n$/, '')}\n`;
  return `&lt;pre style="..."&gt;&lt;code&gt;${text}&lt;/code&gt;&lt;/pre&gt;\n`;
};
</code></pre>
<p>Overriding a renderer replaces all of it, including the part you weren't thinking about. The styling came across and the escaping didn't. It's an easy thing to miss, since every example you test with during development is probably plain code with no angle brackets in it.</p>
<h2>The fix</h2>
<p>marked doesn't export its escape helper, so I added a small one next to the existing markdown utils, escaping the same five characters:</p>
<pre><code class="language-ts">const htmlEscapes: Record&lt;string, string&gt; = {
  '&amp;': '&amp;amp;',
  '&lt;': '&amp;lt;',
  '&gt;': '&amp;gt;',
  '"': '&amp;quot;',
  "'": '&amp;#39;',
};

export const escapeHtml = (text: string) =&gt;
  text.replace(/[&amp;&lt;&gt;"']/g, (char) =&gt; htmlEscapes[char] ?? char);
</code></pre>
<p>It's one regex with a lookup rather than a chain of <code>.replace()</code> calls, which also means <code>&amp;</code> doesn't need to go first. In a chain, escaping <code>&lt;</code> and then <code>&amp;</code> would turn <code>&amp;lt;</code> into <code>&amp;amp;lt;</code>.</p>
<p>Then the two renderers use it:</p>
<pre><code class="language-ts">renderer.code = ({ text, escaped }) =&gt; {
  // ...
  return `...&lt;code&gt;${escaped ? text : escapeHtml(text)}&lt;/code&gt;&lt;/pre&gt;\n`;
};

renderer.codespan = ({ text }) =&gt; {
  return `...&gt;${escapeHtml(text)}&lt;/code&gt;`;
};
</code></pre>
<p>That <code>escaped</code> flag is the bit I nearly didn't think about. A marked extension, such as a syntax highlighter, can escape the code itself and set the flag on the token. Escaping everything regardless would escape that output a second time, and readers would see a literal <code>&amp;lt;</code> in their code sample. So I kept the same check marked uses.</p>
<p>The test puts HTML in both a fenced block and inline code, then checks both sides: the escaped text has to be there, and the raw tags have to be gone.</p>
<pre><code class="language-ts">expect(actualOutput).toContain('&amp;lt;br&amp;gt;&lt;/code&gt;');
expect(actualOutput).not.toContain('&lt;br&gt;');
</code></pre>
<p>It fails on <code>canary</code> and passes with the change.</p>
<h2>What this doesn't do</h2>
<p>It's tempting to call this a security fix, and it isn't one. It only covers code. marked passes raw HTML in the rest of the markdown straight through, and I didn't change that, so this still ends up in your email as a real tag:</p>
<pre><code class="language-md">Some text with &lt;b&gt;real HTML&lt;/b&gt; in it.
</code></pre>
<p>That's normal for markdown, but it means <code>&lt;Markdown&gt;</code> isn't a sanitiser. If the string comes from your users, you still need to clean it before it gets there.</p>
<p>I also left the existing <code>TODO: Support all options</code> alone, so the code block still ignores its language. It would have been easy to tidy that up in the same PR, but a maintainer reviewing a change from a stranger wants one thing to look at.</p>
<p>The <a href="https://github.com/resend/react-email/pull/3823">PR</a> is open and waiting for review, so the code may change before it lands. The thing I'm taking from it applies well beyond this repo, though: when you override a default, read what the default was doing first. The part you're replacing is rarely the only thing it does.</p>
<hr />
<p><em>First published on <a href="https://blogs.kishankanejiiya.tech/posts/react-email-code-escaping">my blog</a>. Cover photo: Bhullargraphic, CC BY-SA 4.0, via Wikimedia Commons (<a href="https://commons.wikimedia.org/wiki/File:Vintage_Imperial_Typewriter_The_Good_Companion_Close_Up.jpg">source</a>).</em></p>
]]></content:encoded></item><item><title><![CDATA[The banks can't tell your AI agent from you]]></title><description><![CDATA[US banks are finding AI agents logged into customer accounts with shared passwords. The real problem is that nothing in the login says who is acting.]]></description><link>https://kishankanejiya.hashnode.dev/ai-agents-in-bank-accounts</link><guid isPermaLink="true">https://kishankanejiya.hashnode.dev/ai-agents-in-bank-accounts</guid><category><![CDATA[AI]]></category><category><![CDATA[Security]]></category><dc:creator><![CDATA[kishan kanejiiya]]></dc:creator><pubDate>Fri, 23 Oct 2026 09:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6ada143005beb55db93f5dd8/875cb191-57c6-4e92-a5a4-b3bfdb7765a9.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I read the <a href="https://www.axios.com/2026/10/22/ai-agents-banks-unauthorized-accounts-access">Axios scoop</a> first thing this morning and then read it again, because the more you look at it the less it's a story about AI and the more it's a story about login forms.</p>
<p>What's happened: Morgan Stanley, Bank of America, JPMorgan, Wells Fargo and others are seeing third-party AI agents working inside customer accounts. Mostly with the customer's permission, in the sense that the customer typed their username and password into an assistant and said "sort my bills out". Some of those agents then did things nobody asked for, like ringing the bank's call centre. Morgan Stanley has started blocking outside agents. JPMorgan, Bank of America and Citi are restricting them, moving towards read-only access.</p>
<p>What stuck with me was one line from a bank executive, that they struggle to tell one agent from another, or whether an agent was authorised by the customer or has gone rogue. That's the whole problem, and it's not a model problem.</p>
<h2>What the bank actually sees</h2>
<p>If you've built a login flow, you already know why they can't tell.</p>
<p>An agent with your password drives a real browser. It loads the page, types into the same fields, clicks the same button and gets the same session cookie you would. From the server's side that session is you. The only signals left are behavioural: typing speed, mouse movement, odd hours, a headless browser fingerprint. Fraud teams use those, but they were built to spot an attacker pretending to be you, not your own assistant openly acting for you with your blessing.</p>
<p>And once the password has been handed over, the agent has everything you have. There's no smaller version of "logged in". It can read the balance and it can also set up a payee, because the website was designed on the assumption that whoever holds the session is the account holder.</p>
<p>I spend a lot of my working week on the other side of this. At work my team builds LLM pipelines that read email and make decisions, and I've done KYC onboarding and smart-meter consent journeys before that. The bit everyone underestimates in consent flows is the scope. "Can this thing see my data" and "can this thing act on my data" are different questions, and a password answers both with yes.</p>
<h2>The money question</h2>
<p>Jamie Dimon said on JPMorgan's earnings call on 13 October that the bank won't let "just any outside agent" in, and, according to <a href="https://www.cnbc.com/2026/10/13/jpmorgan-ai-agents-account-access.html">CNBC</a>, that people using them "should understand there's a liability transfer".</p>
<p>That phrase is doing a lot of work. US rules protect you against <em>unauthorised</em> transfers. If you gave an agent your credentials, a transfer it makes looks authorised, so getting your money back after the agent gets something wrong is not a given. I'm not a lawyer and I'd want to see how a real dispute plays out, but if I were a customer I'd take that as the bank telling me, politely, that it's on me.</p>
<p>To be fair to the banks, nobody has reported a confirmed loss from a rogue banking agent yet, and Morgan Stanley's spokesperson said it hadn't seen rogue agents on its platforms at all. The blocking is precautionary. But "we can't see them" and "they aren't there" are not the same statement, and the first one is the one the executives are admitting to.</p>
<h2>The read-only route already exists</h2>
<p>JPMorgan's position is more nuanced than "agents banned". It already lets agents read some data, balances for example, through aggregators like Plaid. What it won't allow is an agent logging in with your password and moving money.</p>
<p>Over here we've had a version of the right answer since 2018. UK Open Banking gives regulated third parties an API with explicit, scoped consent: you approve "read my transactions for 90 days" in your own banking app, the third party gets a token for exactly that, and you can revoke it without changing your password. Payments go through a separate consent with strong customer authentication on your side. None of it was designed with LLM agents in mind, but the shape is right.</p>
<p>So the fix banks are reaching for, read-only via an intermediary, is basically the first half of that model. The second half, letting an agent act, is where it gets hard.</p>
<h2>What I'd want before letting an agent move money</h2>
<p>If I were designing this on the bank's side, the list is not exotic. It's the same things we already do for any third-party integration, just taken seriously:</p>
<ul>
<li>The agent gets its own credential, never mine. A token issued to that agent, for that customer, that the bank can see, label and revoke. If an agent misbehaves you cut off the agent, not the customer.</li>
<li>Scopes that match the job. "Read balances" and "pay this one bill up to £200" are different grants. Most of what people want from a money assistant needs the first one only.</li>
<li>A human confirmation for anything that moves money. A push in the banking app saying "Muse wants to pay £84 to Thames Water, approve?" The agent can prepare the payment; it can't press the button.</li>
<li>Requests that say who is acting. There are proposals for agents to sign their HTTP requests so a server can verify which agent and which provider it's talking to. I don't know which one will win, but some way of saying "this is an agent, here's whose" beats guessing from mouse movement.</li>
<li>An audit trail the customer can read. Every action, which agent did it, under which consent.</li>
</ul>
<p>The confirmation step is the one I feel strongest about, mostly because of my own side projects. My job-hunting workflow drafts emails to recruiters with an LLM, but nothing gets sent until I tap yes in Telegram. It's slower, and I've thought about removing it more than once. Then I read about an agent deciding on its own to phone a call centre, and I'm glad the yes is there.</p>
<h2>If you're building agents</h2>
<p>The takeaway for developers isn't "don't build finance agents". It's that driving someone's logged-in browser with their password is a shortcut that works right up until the other side notices, and the banks have now noticed. Use the official, scoped routes where they exist (aggregators, Open Banking APIs), ask for read access unless you truly need more, and design for a human to approve the actions that matter.</p>
<p>And if you're a customer: the Chase spokesperson's advice is boring and correct. Don't give your banking password to anyone, and that now includes the assistant on your phone.</p>
<hr />
<p><em>First published on <a href="https://blogs.kishankanejiiya.tech/posts/ai-agents-in-bank-accounts">my blog</a>. Cover photo: Joe Mabel, CC BY-SA 4.0, via Wikimedia Commons (<a href="https://commons.wikimedia.org/wiki/File:Bouldering_Project_(old_University_National_Bank)_33_-_sauna_(vault)_area.jpg">source</a>).</em></p>
]]></content:encoded></item><item><title><![CDATA[Patching Node for the October 2026 security release]]></title><description><![CDATA[Node's 21 October release fixes ten CVEs across 22, 24 and 26, four rated high, and the Brotli decoder one is the one I'd patch first.]]></description><link>https://kishankanejiya.hashnode.dev/node-october-2026-security-release</link><guid isPermaLink="true">https://kishankanejiya.hashnode.dev/node-october-2026-security-release</guid><category><![CDATA[Security]]></category><category><![CDATA[Node.js]]></category><dc:creator><![CDATA[kishan kanejiiya]]></dc:creator><pubDate>Thu, 22 Oct 2026 09:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6ada143005beb55db93f5dd8/c5c2d624-b967-49eb-b8e3-9c816dde21ec.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Yesterday was busy. PHP shipped fixes for <a href="https://blogs.kishankanejiiya.tech/posts/php2shell">two critical bugs</a>, and a few hours later Node.js put out its own security release. The PHP one got the headlines, but nearly everything I run is Node, so this is the one I actually had to do something about.</p>
<p>The <a href="https://nodejs.org/en/blog/vulnerability/october-2026-security-releases">official post</a> lists the patched versions as <strong>v26.12.1</strong>, <strong>v24.22.1</strong> and <strong>v22.23.4</strong>.</p>
<table>
<thead>
<tr>
<th>CVE</th>
<th>Severity</th>
<th>Lines</th>
<th>What it is</th>
</tr>
</thead>
<tbody><tr>
<td>CVE-2026-111497</td>
<td>High</td>
<td>22, 24, 26</td>
<td>Brotli decoder out-of-bounds write, can lead to remote code execution</td>
</tr>
<tr>
<td>CVE-2026-105483</td>
<td>High</td>
<td>24, 26</td>
<td>TLS certificate hostname bypass when an IP is written in fullwidth digits</td>
</tr>
<tr>
<td>CVE-2026-106069</td>
<td>High</td>
<td>22, 24, 26</td>
<td>TLS certificate hostname bypass when the host loosely resolves to an IP</td>
</tr>
<tr>
<td>CVE-2026-105482</td>
<td>High</td>
<td>22, 24, 26</td>
<td><code>dns.resolve</code> truncates names containing a NUL character</td>
</tr>
<tr>
<td>CVE-2026-105481</td>
<td>Medium</td>
<td>22, 24, 26</td>
<td>Keep-alive socket processes a response that was written before the request was sent</td>
</tr>
<tr>
<td>CVE-2026-106072</td>
<td>Medium</td>
<td>22, 24, 26</td>
<td>Non-canonical IPv4 hostnames let DNS SANs authenticate IP endpoints</td>
</tr>
<tr>
<td>CVE-2026-106074</td>
<td>Medium</td>
<td>22, 24, 26</td>
<td><code>crypto.randomUUID()</code> and <code>randomInt()</code> entropy caches saved into startup snapshots and SEAs</td>
</tr>
<tr>
<td>CVE-2026-106070</td>
<td>Low</td>
<td>26</td>
<td>ZipFile returns attacker-controlled prefix bytes after failed header validation</td>
</tr>
<tr>
<td>CVE-2026-106071</td>
<td>Low</td>
<td>26</td>
<td>Permission Model filesystem over-grant via wildcard lookup</td>
</tr>
<tr>
<td>CVE-2026-106073</td>
<td>Low</td>
<td>22, 24, 26</td>
<td>Permission Model bypass with <code>fs.realpathSync</code></td>
</tr>
</tbody></table>
<p>Dependencies were bumped as well: c-ares, OpenSSL (on 24 and 22), SQLite (on 22), Undici, and the bundled CA certificates, which now account for the eMudhra intermediate CA compromise.</p>
<p>The advisory only describes the Brotli bug in any detail. For the others I'm going from the titles and what I know about how those parts of Node get used, so read the rest as one working developer's interpretation rather than the official analysis.</p>
<h2>The Brotli one</h2>
<p>CVE-2026-111497 is an out-of-bounds write in the Brotli decoder, and the advisory says it can allow remote code execution. A decompressor that can be talked into writing somewhere it shouldn't is about as classic as memory bugs get.</p>
<p>So the question for your app is where Node decompresses Brotli data that you didn't create, and I think it's more places than most people would guess. <code>fetch()</code> in Node is Undici, which transparently decompresses a <code>content-encoding: br</code> response, so any server-side fetch to a URL you don't control is in scope: link previews, webhooks that call back out, scrapers, RSS readers. My AI Radar workflow pulls from a dozen or so feeds every hour, and I'd be surprised if none of them served Brotli. Then there's anything that accepts compressed uploads or request bodies and passes them to <code>zlib.brotliDecompress</code>. If you only ever decompress your own assets the risk is lower, but "we only fetch from places we trust" has a way of stopping being true without anyone noticing.</p>
<p>The two TLS hostname bypasses matter too. The hostname check is the part of TLS that makes sure a certificate belongs to the host you meant to talk to, and without it you still get encryption, just possibly to the wrong party. The fullwidth digits one looks like a normalisation problem. Unicode has a second set of digits, <code>０１２３</code>, which look like ordinary digits but aren't the same characters, and if one layer treats <code>１２７.０.０.１</code> as a plain string while another converts it to <code>127.0.0.1</code>, they can disagree about which host you're talking to. The medium one about non-canonical IPv4 addresses (octal, hex, fewer dots) seems to be the same family. And <code>dns.resolve</code> cutting names off at a NUL means <code>good.example\0.evil.example</code> could resolve as something other than what your code checked, which is exactly the pattern in an SSRF allowlist sitting in front of a fetch.</p>
<p>The snapshot one is subtle, and I found it the most interesting of the lot. Node keeps a small cache of random bytes so <code>crypto.randomUUID()</code> and <code>randomInt()</code> are quick, and if you build a user-land startup snapshot or a single executable application, that cache was being saved into it, so every process started from the same snapshot begins by handing out the same values. If you don't use snapshots or SEAs it doesn't affect you. If you do, and you use <code>randomUUID()</code> for anything that needs to be unguessable, patch that one quickly.</p>
<p>The low ones are mostly Permission Model bypasses, and this is the second release this year with path-matching issues there (July had CVE-2026-58043). I'd treat <code>--permission</code> as an extra layer and keep relying on containers and OS limits for the real isolation.</p>
<h2>What I did</h2>
<p>First I checked what was actually running, on the Mac, in CI and in the containers at home:</p>
<pre><code class="language-bash">node -v
ssh kishan-server 'for c in $(docker ps -q); do
  printf "%s " "$(docker inspect -f "{{.Name}}" $c)"
  docker exec $c node -v 2&gt;/dev/null || echo "-"
done'
</code></pre>
<p>Most of my Next.js apps run on a <code>node:22-alpine</code> image, and the official images had new tags within a few hours, so the fix was a rebuild that pulls the fresh base:</p>
<pre><code class="language-bash">docker build --pull --no-cache -t seensooner-web:latest .
</code></pre>
<p><code>--pull</code> is the bit that matters. Without it Docker reuses the <code>node:22-alpine</code> it already has, you redeploy, everything looks fine, and you're still on the old Node. I've been caught by that before. After the rebuild I ran <code>node -v</code> inside each container again to make sure it said <code>v22.23.4</code>, then bumped <code>.nvmrc</code> and the <code>node-version</code> in GitHub Actions. (If CI uses <code>lts/*</code> you'll get it automatically, but I prefer exact versions.)</p>
<p>Node 26 moves to LTS this month, 24 is active LTS and 22 is in maintenance, so it still gets security fixes. I'm staying on 22 for this one because it's a patch bump, and I'll move the apps to 24 in November once I've had time to test, rather than doing a major upgrade and a security patch on the same evening. If you're on 20 or older you aren't getting any of these fixes, and that's a bigger job than a rebuild.</p>
<hr />
<p><em>First published on <a href="https://blogs.kishankanejiiya.tech/posts/node-october-2026-security-release">my blog</a>. Cover photo: Victorgrigas, CC BY-SA 3.0, via Wikimedia Commons (<a href="https://commons.wikimedia.org/wiki/File:Wikimedia_Foundation_Servers-8055_13.jpg">source</a>).</em></p>
]]></content:encoded></item><item><title><![CDATA[PHP2Shell, from someone who mostly writes JavaScript]]></title><description><![CDATA[Two critical PHP bugs were patched yesterday after a messy disclosure, and even if you never touch PHP, JavaScript developers should look.]]></description><link>https://kishankanejiya.hashnode.dev/php2shell</link><guid isPermaLink="true">https://kishankanejiya.hashnode.dev/php2shell</guid><category><![CDATA[Security]]></category><category><![CDATA[PHP]]></category><dc:creator><![CDATA[kishan kanejiiya]]></dc:creator><pubDate>Thu, 22 Oct 2026 09:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6ada143005beb55db93f5dd8/94be49d5-dd4a-48c1-a688-7676ea4b6fef.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I don't write PHP these days. Almost everything I run is Node and Go. But yesterday most of my feeds were about PHP, and so was a good part of my day, so these are my notes while it's all still fresh.</p>
<p>The two vulnerabilities are <a href="https://www.php.net/">CVE-2026-109618 and CVE-2026-109619</a>, and people have started calling them PHP2Shell. The PHP team rated both 9.2 on CVSS v4, and watchTowr went further and scored it 10. Both can lead to remote code execution without any authentication, and the reported range of affected versions is wide, PHP 5.4 all the way to 8.5.</p>
<table>
<thead>
<tr>
<th>Branch</th>
<th>Fixed in</th>
</tr>
</thead>
<tbody><tr>
<td>8.5</td>
<td>8.5.12</td>
</tr>
<tr>
<td>8.4</td>
<td>8.4.27</td>
</tr>
<tr>
<td>8.3</td>
<td>8.3.36</td>
</tr>
<tr>
<td>8.2</td>
<td>8.2.35</td>
</tr>
<tr>
<td>7.4, 8.0, 8.1</td>
<td>source-only backports, no official binaries</td>
</tr>
</tbody></table>
<p>If you're on a distribution package, the version number may not match those, because distros backport fixes into their own builds. Check your vendor's advisory rather than just <code>php -v</code>.</p>
<h2>What the two bugs are</h2>
<p>The GitHub advisories are titled "RCE via OOM corruption" and "RCE via stale global state", and I'll stay at the level they describe.</p>
<p>CVE-2026-109618 is about what happens when PHP runs out of memory partway through certain work. The operation stops in the middle, some request data is left half set up, and the cleanup code then runs over that broken state. According to the reports this path was spotted in a 2024 audit and never fixed, because nobody could agree on the fix and nobody had shown it was exploitable. I find that oddly relatable. Plenty of bugs in my own work have survived for exactly that reason.</p>
<p>CVE-2026-109619 is about state leaking between requests. A PHP worker handles many requests over its life, and if references to one request's data survive after that memory has been freed, a later request on the same worker can touch memory that now belongs to something else, which is a use-after-free. The advisories also say researchers showed it could disclose worker memory without authentication, which means things like session tokens and passwords from other people's requests.</p>
<p>Both affect the usual ways PHP is served: Apache mod_php (called out as the most urgent), PHP-FPM, php-cgi, FrankenPHP and LiteSpeed.</p>
<h2>A messy week for a disclosure</h2>
<p>The plan was a coordinated release. PHP announced on 20 October that fixes were coming, and the advisories went up on GitHub on 21 October at 13:34 UTC, about 26 minutes ahead of the announced time. Before that, though, things leaked. It surfaced on Hacker News early, a proof-of-concept video was released by mistake, and Chainguard shipped patched builds ahead of the release, which meant anyone could diff the old and new code to find where the bugs were. Amazon reported seeing an attempt in its honeypots on 17 October, four days before the advisories went up.</p>
<p>Reports on exploitation don't agree with each other. CISA said on 21 October that it hadn't observed any, and told organisations to patch immediately or take PHP systems offline if they couldn't. watchTowr said the same day that it was seeing exploitation in its own honeypots. Cloudflare pushed temporary WAF rules, and a lot of public sector sites in Europe went offline as a precaution, which tells you how seriously people were taking it.</p>
<p>The bugs were found independently by Ryan Austin at Calif and by Wiz, and both used AI models as part of the work. I suspect that's what people will remember about this one after the patch is old news. PHP's core is decades of C with a lot of eyes on it, and if models can now walk through old C and find memory bugs faster than maintainers can review fixes, we'll see more weeks like this, in projects that often have one or two volunteers looking after them. The finding has sped up a lot, and the fixing still depends on a small number of people having the time.</p>
<h2>Why it felt familiar</h2>
<p>Ten months ago it was <a href="https://blogs.kishankanejiiya.tech/posts/react2shell">React2Shell</a> for those of us on the JavaScript side. Different language and a different kind of bug, but a similar shape: in both cases the dangerous code was in the runtime or framework layer that turns an incoming request into something your code can use. You could have written perfect application code and still been exposed.</p>
<p>The stale state bug also maps quite neatly onto Node. A PHP-FPM worker carrying something from request A into request B is, conceptually, the same mistake as a Node server keeping per-user data in module scope, which is the kind of thing that turns up in code review now and then:</p>
<pre><code class="language-ts">let currentUser: User | null = null;

export async function handler(req: Request) {
  currentUser = await getUser(req); // shared by every concurrent request
  return render(currentUser);
}
</code></pre>
<p>A Node process is long-lived by design, so anything at module level is shared across every request it serves. PHP's model was meant to make that kind of leak hard, because every request starts fresh, and this bug sat in the machinery that does the starting fresh.</p>
<h2>What to do about it</h2>
<p>Update to the fixed version for your branch. That's the actual fix, and the PHP team were clear that the stopgaps aren't complete. If you really can't update today, these were published as temporary measures:</p>
<ul>
<li>Set <code>session.upload_progress.enabled=0</code> in <code>php.ini</code>, the FPM pool or the server config, not with <code>ini_set()</code> or <code>.user.ini</code>, which apply too late, and restart the workers.</li>
<li>On PHP 8.5, set <code>fatal_error_backtraces=0</code> the same way.</li>
<li>Keep <code>post_max_size</code> sensible (8M with no uploads, 32M if you take them), with <code>upload_max_filesize</code> below it, and limit request sizes at the web server or proxy.</li>
<li>Use container or cgroup memory limits.</li>
<li>On PHP-FPM, <code>pm.max_requests=1</code> was mentioned as optional. It costs performance and doesn't replace patching.</li>
</ul>
<pre><code class="language-ini">; FPM pool, as a stopgap until you can upgrade
php_admin_value[session.upload_progress.enabled] = 0
php_admin_value[post_max_size] = 8M
php_admin_value[upload_max_filesize] = 4M
</code></pre>
<p>If a server was exposed and unpatched during the window, I'd also think about rotating anything that lived in worker memory, like session keys, API tokens and database passwords, given the memory disclosure part.</p>
<p>On my own box I was fairly sure nothing was PHP, but fairly sure isn't sure, so:</p>
<pre><code class="language-bash">docker ps --format '{{.Image}}\t{{.Names}}' | grep -i php
docker images --format '{{.Repository}}:{{.Tag}}' | grep -iE 'php|wordpress|nextcloud'
</code></pre>
<p>Nothing came back. It's all Node, Go, Postgres and the Supabase stack. I did nudge a couple of friends who run WordPress on shared hosting to check what PHP version their host was on, since that's about all you can do on shared hosting.</p>
<p>Node isn't immune to any of this. We have our own C++ runtime underneath, our own long-lived processes and our own framework serialisation layers, and Node's security release came out the same day with a Brotli decoder bug rated high (I wrote that one up <a href="https://blogs.kishankanejiiya.tech/posts/node-october-2026-security-release">separately</a>). The thing I keep coming back to is how quickly you can get a fixed runtime into production on a random Wednesday: knowing which versions you run, having a build that pulls the new base image, and being able to redeploy in minutes without a ceremony. That part's in our hands whatever language we write.</p>
<hr />
<p><em>First published on <a href="https://blogs.kishankanejiiya.tech/posts/php2shell">my blog</a>. Cover photo: Charles J. Sharp, CC BY-SA 4.0, via Wikimedia Commons (<a href="https://commons.wikimedia.org/wiki/File:African_elephant_(Loxodonta_africana)_2.jpg">source</a>).</em></p>
]]></content:encoded></item><item><title><![CDATA[My rental alert's big feature already existed, so I rebuilt it]]></title><description><![CDATA[It started as 'Rightmove, but faster' until I found Rightmove already sends instant alerts. Now it searches three sites and shows each home once.]]></description><link>https://kishankanejiya.hashnode.dev/doorstep-behind-the-build</link><guid isPermaLink="true">https://kishankanejiya.hashnode.dev/doorstep-behind-the-build</guid><category><![CDATA[side project]]></category><category><![CDATA[automation]]></category><dc:creator><![CDATA[kishan kanejiiya]]></dc:creator><pubDate>Mon, 19 Oct 2026 09:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6ada143005beb55db93f5dd8/3c8b1148-29c9-4d02-8d1d-cccb912490c7.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If you've rented in London you'll know how it goes. A decent flat appears in the morning, and by the afternoon the agent has more viewings booked than they can fit in and isn't taking any more. Being early matters more than almost anything else you can do.</p>
<p>For a while I've had a personal n8n workflow watching my own search. It checked a couple of sites every 15 minutes and pinged me on Telegram when something new showed up. Friends kept asking if they could have it too, so last week I turned it into a public site, <a href="https://doorstep.kishankanejiiya.tech">Doorstep</a>, and this is mostly about the bit I got wrong first.</p>
<h2>Version one was "Rightmove, but faster"</h2>
<p>The first pitch was speed. Rightmove alerts used to be a daily email, so a 15-minute check felt like a real edge, and I built everything around that one promise: the form, the double opt-in, the alert email, the landing page.</p>
<p>Then I sat down to make the comparison table for that landing page, and to get a screenshot I set up a fresh Rightmove alert. They offer instant alerts now, push and email, as soon as a home is listed. My headline feature was something the biggest site in the country already did, for free, and probably had done for a while.</p>
<p>It was a mildly embarrassing evening. But I'd much rather find that out from my own comparison table than from the first person who signed up and wrote back to tell me.</p>
<p>So I asked a different question, which was what a renter still has to do by hand, and once I looked at it properly the answer was obvious. Each portal's alert only covers that portal. Agents list on Rightmove, OnTheMarket and sometimes both, landlords list directly on OpenRent, and to be thorough you need three alerts, three accounts, and the patience to work out that the flat in two different emails is actually the same flat. That's the product: one alert, several sites, and each home shown once.</p>
<h2>Three sites, one list</h2>
<p>Doorstep now checks Rightmove, OnTheMarket and OpenRent every 15 minutes for each saved search. Every site gets a small adapter that turns a search (area, radius, minimum and maximum rent, bedrooms, move date) into that site's URL format, and turns whatever comes back into one shape:</p>
<pre><code class="language-ts">type Listing = {
  source: "rightmove" | "onthemarket" | "openrent";
  url: string;
  rentPcm: number;
  beds: number;
  street: string;
  postcodeDistrict: string;
  agent?: string;
  firstSeen: string;
};
</code></pre>
<p>I did try more. Zoopla, PrimeLocation and a couple of others sit behind Cloudflare's bot protection and return 403 to a request from a server, and I'm not going to fight that. Gumtree's radius search didn't behave consistently enough to trust, and SpareRoom is mostly rooms, which is a different search altogether. Three sources that work properly are better than six that sometimes do.</p>
<p>The part I enjoyed most was spotting the same home across sites. There's no shared ID between them. The photos are different, the titles are different ("2 bed flat" against "Two bedroom apartment"), and the full address is often hidden until you enquire. What turned out to be stable enough is a fingerprint of three things:</p>
<pre><code class="language-ts">const fingerprint = (l: Listing) =&gt;
  [l.beds, l.rentPcm, normaliseStreet(l.street)].join("|");
</code></pre>
<p><code>normaliseStreet</code> lowercases the street, drops house and flat numbers, and expands or strips "Rd", "St", "Ave" and friends so that "Ballards Ln" and "Ballards Lane" come out the same. The same number of beds at the same rent on the same street is almost always the same flat. Occasionally it'll merge two different flats on one street at one price, but that's rare, and when it happens you see one card that links to both, which isn't the end of the world.</p>
<p>Each email then shows a home once, with "via Rightmove · also on OpenRent" underneath, so you can choose where to enquire. Sometimes the landlord-direct listing is the better way in.</p>
<p>The first real test was on 18 October, a 1-mile search around N12. Rightmove on its own had 3 new lets. Across all three sites there were 13 distinct homes, and 2 of those were listed on more than one site. That gap is the whole argument for the thing, and it's what the site leads with now instead of speed.</p>
<h2>How it's put together</h2>
<p>There's nothing clever in the stack. A Next.js front end serves the site and the signup form, n8n runs the 15-minute schedule, calls the adapters, dedupes and sends the emails, and Postgres on my self-hosted Supabase holds the searches, subscribers and fingerprints already sent. Signing up is double opt-in by email, with no accounts or passwords, and every alert has a one-click stop link.</p>
<p>Each subscriber's state is just the set of fingerprints they've already been sent, and a new listing is anything not in that set. That keeps the logic easy to think about, and it means a restart never sends anyone old homes again.</p>
<h2>The disk incident</h2>
<p>The first night after launch the box hit 96% disk. The load average had climbed, and Firecrawl (which also runs there) had fallen over because its message queue couldn't write anything.</p>
<p>The culprit was n8n. By default it saves execution data for every successful run, and my older personal workflow had been saving the full HTML of a search results page, around 2 MB, every 15 minutes, for months. The SQLite database had grown past 3 GB. The fix was one setting on the workflow, to stop saving data for successful executions. Failed runs still keep theirs, which is the only time I ever look anyway. Then I vacuumed the database with n8n stopped, and the box calmed down. On a laptop that default would never have mattered. On a small server it eventually catches up with you.</p>
<h2>What still stops a proper launch</h2>
<p>Two things. The first is email volume. Alerts go out through a Gmail account for now, which caps at roughly 500 messages a day. That's fine for friends and a few dozen people, and nowhere near enough beyond that, so before I share it widely it needs a proper sending domain through something like Resend, with SPF and DKIM set up so the alerts don't end up in spam.</p>
<p>The second is terms of use. Property portals generally don't allow automated access in their terms. Doorstep is gentle about it (one request per search every 15 minutes, no bulk crawling, and every card links straight back to the listing on their site rather than copying it), but I don't think that makes it fine, and if a site asks me to stop, I'll stop. It's a portfolio project and a personal tool first.</p>
<p>If you're renting in north London and want to try it, it's at <a href="https://doorstep.kishankanejiiya.tech">doorstep.kishankanejiiya.tech</a>. Next on my list is sorting out the sending domain.</p>
<hr />
<p><em>First published on <a href="https://blogs.kishankanejiiya.tech/posts/doorstep-behind-the-build">my blog</a>. Cover photo: David Smith, CC BY-SA 2.0, via Wikimedia Commons (<a href="https://commons.wikimedia.org/wiki/File:Terraced_houses_in_Gaskarth_Road_-_geograph.org.uk_-_5198897.jpg">source</a>).</em></p>
]]></content:encoded></item><item><title><![CDATA[Next.js 16.4 and React 19.3 across my side projects]]></title><description><![CDATA[Cache Components are the default in Next.js 16.4 and ViewTransition is stable in React 19.3, so I upgraded the apps I run at home.]]></description><link>https://kishankanejiya.hashnode.dev/next-16-4-and-react-19-3</link><guid isPermaLink="true">https://kishankanejiya.hashnode.dev/next-16-4-and-react-19-3</guid><category><![CDATA[Next.js]]></category><category><![CDATA[React]]></category><dc:creator><![CDATA[kishan kanejiiya]]></dc:creator><pubDate>Sun, 11 Oct 2026 09:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6ada143005beb55db93f5dd8/42c9973a-2f3a-44d2-9b35-850004501cf2.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>All my side projects are Next.js on the front, most with a Go service behind, and they all run on the box at home. When a minor release lands I upgrade one, leave it a day, and then do the rest in a batch. <a href="https://nextjs.org/blog">Next.js 16.4</a> came out on 6 October, so this weekend was batch weekend.</p>
<p>The headline in 16.4 is that Cache Components are now the recommended way to build every app, and <code>create-next-app</code> switches them on by default. When Next 16 came out last year, <code>cacheComponents: true</code> was an opt-in. Existing apps don't get flipped for you, which I think is right, because it does change behaviour. With it on, everything is dynamic unless you mark it with <code>"use cache"</code>, and the static parts of a page are prerendered as a shell while the dynamic parts stream in under <code>&lt;Suspense&gt;</code>. I'd moved most of my apps to it over the past year anyway, so this release mostly confirmed where things were heading.</p>
<p>There's also <code>ensureStatic</code>, which lets you say a route must stay static so the build fails if something dynamic sneaks in. I like the idea a lot for pages that are built from a data file, but check the release notes for exactly how to apply it, as I'm still getting used to it. Then there are new navigation and prefetch APIs, which I've read about and haven't used, so I won't try to explain them. And <code>next upgrade --agent</code>, which hands the upgrade to a coding agent along with the migration notes. I tried it on my smallest app, where there was almost nothing to change, so it bumped the packages, ran the build and stopped. Not much of a test. I'd want to see it on a messier upgrade, and I'd still read the diff. The rest of the release is performance: a smaller disk cache, lazy server HMR, a shared Turbopack runtime and smaller production bundles. I did notice the disk cache, since the box at home is never far from full.</p>
<h2>React 19.3</h2>
<p><a href="https://react.dev/blog">React 19.3</a> came out on 9 September, bundled into 16.4. Two experimental features went stable, <code>&lt;ViewTransition&gt;</code> and Fragment refs, and there are no breaking changes, which is why it's 19.3 and not 20.</p>
<p><code>&lt;ViewTransition&gt;</code> wraps the browser's View Transitions API in a component. React decides when a transition starts (a navigation, a <code>startTransition</code>, a Suspense reveal) and you say what should animate. On AI Radar I'd wanted the small cover image in the feed to grow into the big one on the issue page, so that's where I tried it:</p>
<pre><code class="language-tsx">// feed
import { ViewTransition } from "react";

&lt;Link href={`/issue/${issue.id}`}&gt;
  &lt;ViewTransition name={`cover-${issue.id}`}&gt;
    &lt;img src={issue.cover} alt="" width={120} height={80} /&gt;
  &lt;/ViewTransition&gt;
  &lt;h2&gt;{issue.title}&lt;/h2&gt;
&lt;/Link&gt;

// issue page
&lt;ViewTransition name={`cover-${issue.id}`}&gt;
  &lt;img src={issue.cover} alt="" width={960} height={640} /&gt;
&lt;/ViewTransition&gt;
</code></pre>
<p>The same <code>name</code> on both sides and the browser morphs one into the other. Browsers without View Transitions just navigate normally. Names have to be unique on the page when the transition runs, and it's worth switching the animation off for people who've asked for reduced motion:</p>
<pre><code class="language-css">@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}
</code></pre>
<p>Fragment refs let you put a <code>ref</code> on a <code>&lt;Fragment&gt;</code> and reach its DOM children without a wrapper <code>div</code>. I've only had one place where a wrapper would have broken a CSS grid, and I'd read the <a href="https://react.dev/reference/react/Fragment">docs</a> for the exact API rather than trust my memory of it.</p>
<p>The upgrade itself was the usual:</p>
<pre><code class="language-bash">npm install next@16.4 react@19.3 react-dom@19.3 eslint-config-next@16.4
npm run build
</code></pre>
<p>Most apps built and passed their smoke tests first time. A couple needed small fixes, mainly a component reading <code>cookies()</code> without a Suspense boundary above it, which older versions had let me get away with, and an old experimental ViewTransition import. My deploys build on the Mac and ship only the standalone output, so a bad upgrade would have been one image to roll back. I didn't need to.</p>
<hr />
<p><em>First published on <a href="https://blogs.kishankanejiiya.tech/posts/next-16-4-and-react-19-3">my blog</a>. Cover photo: Alexander Novikov, CC BY-SA 4.0, via Wikimedia Commons (<a href="https://commons.wikimedia.org/wiki/File:Hot-air_balloon_in_Tambov_-_02.jpg">source</a>).</em></p>
]]></content:encoded></item><item><title><![CDATA[Building a news radar and a job hunter in n8n]]></title><description><![CDATA[An hourly AI and React news digest on Telegram, a job hunter that never emails anyone without my yes, and the n8n quirks that cost me time.]]></description><link>https://kishankanejiya.hashnode.dev/workflows-for-tech-people</link><guid isPermaLink="true">https://kishankanejiya.hashnode.dev/workflows-for-tech-people</guid><category><![CDATA[automation]]></category><category><![CDATA[AI]]></category><dc:creator><![CDATA[kishan kanejiiya]]></dc:creator><pubDate>Sun, 27 Sep 2026 09:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6ada143005beb55db93f5dd8/f17333f9-c4db-4f6b-8196-0ddecf5bee62.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I've had n8n running on my home server for about a year, mostly doing small glue jobs nobody would ever notice. This month I finished two workflows that felt worth writing about, because they solve problems a lot of people in tech seem to have, and because building them taught me more about n8n's rough edges than the previous year of glue jobs did.</p>
<p>Both run on the same small box: two cores, under 6 GB of RAM, with n8n and Postgres in Docker next to about two dozen other containers. That limit came up again and again, and it explains a few of the choices below.</p>
<h2>Keeping up without drowning</h2>
<p>Things move fast enough at the moment that I'd started to worry about falling behind. A new model one week, a Next.js release the next, a React advisory, a tool that everyone is suddenly using. I was picking it all up from X, a couple of newsletters, Hacker News when I remembered, and a WhatsApp group. Lots of duplicates, lots of noise, and I'd still miss things that mattered.</p>
<p>Before building anything, I spent an evening going back through the previous month and noting where the links I'd actually found useful had come from. It was a shorter list than I expected: Hacker News, a few subreddits, dev.to, GitHub (new repos picking up stars quickly), Lobsters, and some Medium. The newsletters were mostly repeating those a day or two later, which was a bit deflating, but it made the design easy.</p>
<p>So AI Radar reads those sources every hour and sends me the best few links on Telegram. Hacker News comes in through the Algolia search API, filtered by points and age. Reddit comes in over RSS, because the JSON API returns 403 to my server's IP while RSS works fine (I don't know why, and I stopped caring once RSS worked). dev.to has a public API you can query by tag. GitHub search gives me recently created repos sorted by stars. Lobsters has a JSON feed, and Medium has tag feeds.</p>
<p>Everything ends up in one Code node called "Rank &amp; Format". It turns each item into the same shape, <code>{title, url, source, score, at}</code>, gives it a score from points, comments, age and a boost for keywords I care about, and picks the top handful.</p>
<p>I set two rules before writing any of it. The first was that a link must never be sent twice. The same article turns up on HN, Reddit and Lobsters, sometimes days apart, and getting it three times is exactly the noise I was trying to get rid of. So URLs are normalised (tracking parameters, <code>www.</code> and trailing slashes stripped) and every sent URL is remembered for 30 days. The second was that React and Next.js always get a look in. AI news drowns everything else at the moment, so the first three slots in each message are kept for React and Next.js items when there are any worth sending, and AI and general full stack links fill the rest.</p>
<p>Where that "already sent" list lives turned out to matter a lot, which I'll come back to.</p>
<p>The digest now sits in a Telegram chat I look at a few times a day. Most hours it sends four or five links and I open one or two, which feels about right. If I was opening all of them the ranking would be too strict, and if I opened none it'd be noise again.</p>
<h2>A job hunter that waits for my yes</h2>
<p>The second workflow came from a more practical need. I wanted to watch for roles in the UK at companies that hold a licence to sponsor visas, which narrows things down a lot and makes ordinary job alerts mostly useless.</p>
<p>The government publishes the register of licensed sponsors as a CSV, and it's around 122,000 organisations. The names rarely match how companies appear on job boards ("Acme Technologies Ltd" on the register, "Acme" on the advert), so a plain lookup misses most of them. I load the register into Postgres weekly and match on a normalised name plus trigram similarity:</p>
<pre><code class="language-sql">create extension if not exists pg_trgm;

select name, similarity(norm_name, norm_company($1)) as score
from jobhunt.uk_sponsors
where norm_name % norm_company($1)
order by score desc
limit 1;
</code></pre>
<p><code>norm_company</code> lowercases the name and strips things like "ltd", "limited", "plc", "uk" and punctuation. With a threshold around 0.6 it's right far more often than it's wrong, and borderline matches get flagged for me to look at rather than trusted.</p>
<p>Three times a day the workflow pulls new roles from job board searches and throws away anything it has already seen, anything that's a contract, and anything from my current employer. What's left gets checked against the sponsor register. Then a cheap model reads the description and answers a few plain questions: is this really front end or full stack, what level is it, and is there a sentence saying they won't sponsor. Only the roles that pass go to a stronger model, which drafts a short cover letter. And then the draft comes to me on Telegram with <strong>Yes</strong> and <strong>No</strong> buttons, and nothing is emailed unless I press Yes.</p>
<p>The two-model split was a cost lesson. My first version sent every job to the big model and spent about a dollar on a handful of roles, which is fine once and silly three times a day. Now Claude Haiku does the screening for well under a cent a job, and Sonnet only writes letters for the few that pass. The letters are just as good, and it costs a fraction of what it did.</p>
<p>The approval step isn't going anywhere. An automation that emails people in my name without me reading it first is a risk to my reputation, and the time it would save is a few seconds a job. That's not a trade I want.</p>
<h2>Where n8n bit me</h2>
<p>Some of these cost me more time than I'd like to admit.</p>
<p>The Code node sandbox has no <code>URL</code> class. <code>new URL(link)</code> doesn't fail in any way you'd notice from the canvas, it just silently dropped every item that went down that path, and I spent the best part of an hour convinced a search was returning nothing. I swapped it for a small regex:</p>
<pre><code class="language-js">function parseUrl(u) {
  const m = /^(https?):\/\/([^/?#]+)([^?#]*)(\?[^#]*)?/i.exec(u || "");
  return m ? { host: m[2].toLowerCase(), path: m[3] || "/", query: m[4] || "" } : null;
}
</code></pre>
<p>The Merge node tops out at 10 inputs, and with 11 sources I had to merge in two groups and then merge the merges. Not hard, just odd to discover halfway through.</p>
<p><code>n8n import:workflow</code> needs an <code>id</code> in the JSON, and without one you get a SQLite NOT NULL error that doesn't mention ids anywhere.</p>
<p>The one that actually hurt was static data. Re-importing a workflow wipes it, and that's where I'd put the "already sent" list for AI Radar. The first time I fixed a typo and re-imported, the next digest was full of links I'd already seen, which was the one thing I'd promised myself it would never do. Anything that has to survive a deploy now lives in Postgres, and static data is only a cache. On a related note, runs started with the CLI <code>execute</code> command don't save static data either, so testing from the terminal behaves differently from the scheduled run. If state matters, test with the real trigger.</p>
<p>And running CLI commands inside the live container clashes with the task runner on its broker port, so one-off commands need a different <code>N8N_RUNNERS_BROKER_PORT</code>.</p>
<h2>Why not just write a service</h2>
<p>I'm a developer, so the obvious question is why I didn't write a Go service with a cron job. For the core logic I do write code, it just lives in Code nodes. What n8n gives me is the dull surrounding stuff: schedules, retries, credentials, Telegram buttons, and an execution log I can open on my phone. That last one is the real reason. When a run fails at 8am I can see which node failed and what it was given, without opening a laptop or SSH-ing anywhere.</p>
<p>If one of these grows into something other people rely on, the logic will move into a proper service. That's more or less what happened with <a href="https://blogs.kishankanejiiya.tech/posts/doorstep-behind-the-build">Doorstep</a>, which also started life as a personal alert workflow.</p>
<hr />
<p><em>First published on <a href="https://blogs.kishankanejiiya.tech/posts/workflows-for-tech-people">my blog</a>. Cover photo: falco, CC0, via Wikimedia Commons (<a href="https://commons.wikimedia.org/wiki/File:Conveyor_system_in_a_factory.jpg">source</a>).</em></p>
]]></content:encoded></item><item><title><![CDATA[My self-hosted scraper was an open door into my home network]]></title><description><![CDATA[Self-hosted Firecrawl has no auth and will fetch private IPs. I found that out just in time, so now it sits behind a small nginx gateway.]]></description><link>https://kishankanejiya.hashnode.dev/firecrawl-ssrf-gateway</link><guid isPermaLink="true">https://kishankanejiya.hashnode.dev/firecrawl-ssrf-gateway</guid><category><![CDATA[Security]]></category><category><![CDATA[Devops]]></category><dc:creator><![CDATA[kishan kanejiiya]]></dc:creator><pubDate>Sun, 23 Aug 2026 09:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6ada143005beb55db93f5dd8/988d57b4-d55b-4c29-8465-5d2e03f08a98.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Yesterday I set up <a href="https://github.com/firecrawl/firecrawl">Firecrawl</a> on the box at home. It's a scraping API: you send it a URL, it renders the page in a headless browser, and you get clean markdown back. A few of my n8n workflows were paying for the hosted version and I wanted to stop.</p>
<p>Getting it running took an evening. Working out what I'd nearly exposed took a lot less.</p>
<p>The home server is a small Ubuntu machine with two cores and under 6 GB of RAM, running roughly 25 Docker containers. Nothing on it has an open inbound port. Public hostnames all go through one Cloudflare Tunnel, where <code>cloudflared</code> on the box makes an outbound connection to Cloudflare and each hostname maps to a port on <code>127.0.0.1</code>, and for admin I use Twingate. Firecrawl's compose file brings up the API, workers, a Playwright service, Redis and RabbitMQ, with the API on port 3002. The plan was the usual: a DNS record, a tunnel rule pointing <code>firecrawl.&lt;my domain&gt;</code> at <code>127.0.0.1:3002</code>, and call it from n8n.</p>
<h2>No auth, and a view of the whole LAN</h2>
<p>Before adding the route I went looking for where to set the API key. The hosted version wants <code>Authorization: Bearer fc-...</code> on every request, so I assumed the self-hosted one would have an env var for it.</p>
<p>It doesn't. Self-hosted Firecrawl has no authentication of its own. There's a <code>USE_DB_AUTHENTICATION</code> setting, but that's for their Supabase-backed multi-tenant setup, and with it off (the default when you self-host) every request is accepted. My tunnel route would have turned it into a free, anonymous headless-browser scraper for anyone who found the hostname. People would have scraped things from my home IP, and the box would have fallen over, because it can barely run Firecrawl for one person.</p>
<p>Then I thought about what it could reach. A scraper is, by design, a thing that fetches whatever URL you give it, and when it runs inside your network that includes your network. So I tried it from the box:</p>
<pre><code class="language-bash">curl -s localhost:3002/v1/scrape \
  -H 'content-type: application/json' \
  -d '{"url":"http://localhost:5678"}'
# rejected

curl -s localhost:3002/v1/scrape \
  -H 'content-type: application/json' \
  -d '{"url":"http://10.0.0.80:5678"}'
# returns the n8n login page as markdown
</code></pre>
<p>It rejects the literal hostname <code>localhost</code> and nothing else. The machine's own LAN IP works, and so do the other private ranges, which means anything on my network with a web UI was reachable: n8n's login page, uptime-kuma, the Supabase studio, and I'd guess the router's admin page as well. Most of those need a login, but "most" is carrying a lot in that sentence, and some services trust requests from the local network more than they should.</p>
<p>That's server-side request forgery, the same class of bug behind some very large cloud breaches, and I'd been about to set one up in my own house on purpose.</p>
<h2>A gateway on the port the tunnel already uses</h2>
<p>I didn't want to touch the tunnel config, because on this box the cloudflared config is root-owned and every edit needs a sudo step. So I took Firecrawl off the host port and put a tiny nginx container on 3002 instead, which is where the tunnel was going to point anyway.</p>
<pre><code>Cloudflare Tunnel → 127.0.0.1:3002  nginx gateway (auth, rate limit)
                          │  docker network: firecrawl_backend
                          ▼
                     api:3002       Firecrawl (not published to the host)
</code></pre>
<p>The Firecrawl API isn't published on any host port the tunnel uses now. Only the gateway can reach it, over the compose network. This is the config, with the key replaced:</p>
<pre><code class="language-nginx">map_hash_bucket_size 256;

# Accept the key as X-API-Key or as Authorization: Bearer &lt;key&gt;
map $http_x_api_key $key_ok_header {
    default 0;
    "REPLACE_WITH_64_CHAR_KEY" 1;
}
map $http_authorization $key_ok_bearer {
    default 0;
    "Bearer REPLACE_WITH_64_CHAR_KEY" 1;
}
map "$key_ok_header$key_ok_bearer" $authorized {
    default 1;
    "00"    0;
}

# Cloudflare tells us who the real client is
limit_req_zone  $http_cf_connecting_ip zone=per_ip:10m rate=5r/s;
limit_conn_zone $http_cf_connecting_ip zone=per_ip_conn:10m;

server {
    listen 3002;
    client_max_body_size 1m;

    # Health check stays open so uptime-kuma can probe it
    location /v0/health/ {
        proxy_pass http://api:3002;
    }

    location / {
        if ($authorized = 0) { return 401; }

        limit_req  zone=per_ip burst=20 nodelay;
        limit_conn per_ip_conn 10;

        proxy_pass http://api:3002;
        proxy_read_timeout 120s;
    }
}
</code></pre>
<p>Five requests a second with a burst of 20, and no more than 10 open connections per client IP. That's plenty for n8n, and it stops anyone who gets hold of the key from melting the box. The key is 64 random characters from <code>openssl rand -hex 32</code>, kept in a file on the server and in my n8n credentials, and nowhere near git.</p>
<p>The first time I started the gateway, nginx refused to load:</p>
<pre><code>could not build map_hash, you should increase map_hash_bucket_size: 64
</code></pre>
<p>nginx keeps <code>map</code> keys in a hash table with a 64-byte default bucket size, and a 64-character key (71 with <code>Bearer </code> in front) doesn't fit. Putting <code>map_hash_bucket_size 256;</code> at the top of the file fixed it. It has to be in the <code>http</code> context before the maps, and in the official nginx image files in <code>conf.d</code> are already included inside <code>http</code>, so the top of your own file works.</p>
<p>Then the checks:</p>
<pre><code class="language-bash"># no key
curl -s -o /dev/null -w '%{http_code}\n' https://firecrawl.&lt;domain&gt;/v1/scrape
# 401

# wrong key
curl -s -o /dev/null -w '%{http_code}\n' -H 'X-API-Key: nope' https://firecrawl.&lt;domain&gt;/v1/scrape
# 401

# health, no key
curl -s -o /dev/null -w '%{http_code}\n' https://firecrawl.&lt;domain&gt;/v0/health/liveness
# 200
</code></pre>
<p>With a valid key, scraping a normal public page works and n8n is happy.</p>
<p>None of this fixes the SSRF itself, mind. Anyone with the key can still ask for <code>http://10.0.0.80:...</code>. The only key holders are me and my own workflows, so I can live with that for now. The proper fix is to put the Playwright and worker containers on a network that can reach the internet and not the LAN, and that's next on my list, along with Cloudflare Access in front so a leaked key on its own isn't enough.</p>
<p>If you self-host tools like this, I'd assume the self-hosted version has less security than the hosted product rather than the same. The hosted one has an auth layer, abuse detection and people watching it, and the compose file usually has none of that. And before giving anything a public hostname, it's worth asking who can call it and what it can reach that the caller can't. For anything that fetches URLs (scrapers, link previewers, PDF renderers, webhook testers, image proxies) the second question matters more, and I nearly didn't ask it because the tunnel made exposing things so easy.</p>
<hr />
<p><em>First published on <a href="https://blogs.kishankanejiiya.tech/posts/firecrawl-ssrf-gateway">my blog</a>. Cover photo: Basile Morin, CC BY-SA 4.0, via Wikimedia Commons (<a href="https://commons.wikimedia.org/wiki/File:Wooden_gate_with_open_double_door_leading_to_the_garden_of_Isshinin_Buddhist_temple_in_the_compounds_of_Chion-in_Kyoto_Japan.jpg">source</a>).</em></p>
]]></content:encoded></item><item><title><![CDATA[Moved my side projects to TypeScript 7 this weekend]]></title><description><![CDATA[TypeScript 7 went GA on 8 July with the Go compiler as plain tsc. Moving my side projects was quick, apart from one old-API script.]]></description><link>https://kishankanejiya.hashnode.dev/typescript-7-in-a-real-repo</link><guid isPermaLink="true">https://kishankanejiya.hashnode.dev/typescript-7-in-a-real-repo</guid><category><![CDATA[TypeScript]]></category><dc:creator><![CDATA[kishan kanejiiya]]></dc:creator><pubDate>Sun, 19 Jul 2026 09:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6ada143005beb55db93f5dd8/d031386c-74e0-4c03-8998-45c1f6b6b0a7.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Last April I wrote about <a href="https://blogs.kishankanejiiya.tech/posts/typescript-is-going-native">TypeScript moving to Go</a> and said I'd believe the speed when I saw it in my own repos. <a href="https://devblogs.microsoft.com/typescript/">TypeScript 7.0</a> went GA on 8 July, and over the last two weekends I moved my side projects onto it.</p>
<p>There's no new package to learn. The Go compiler is just <code>typescript</code> now:</p>
<pre><code class="language-bash">npm install -D typescript@7
npx tsc --version
# Version 7.0.x
</code></pre>
<p><code>tsc</code> is the native binary, and the <code>@typescript/native-preview</code> package and <code>tsgo</code> command I'd been trying since last year are for nightlies now, so I deleted the preview package and the extra script I'd added to compare the two. In VS Code, run "TypeScript: Select TypeScript Version" and pick the workspace version, otherwise you're still using whatever the editor bundles.</p>
<p>My projects aren't big. The largest is a Next.js app with a Go backend next to it, and the rest are smaller Next.js apps sharing the same tsconfig base. A cold <code>tsc --noEmit</code> on the biggest one went from somewhere around 14 seconds to under 2, and the smaller ones are now basically instant. That's roughly five to seven times faster for me. Microsoft quotes 8x to 12x on large codebases (the VS Code build dropping from 125.7 seconds to 10.6), which makes sense, because small projects spend more of their time starting up and reading <code>node_modules</code> types, and that part doesn't speed up as much. In CI the type-check step went from most of a minute to a few seconds, and the job is now mostly <code>npm ci</code>.</p>
<p>But the editor is the bigger change, as I expected. Go-to-definition works before I've finished finding the file I wanted, and renaming a field in a shared type updates the errors across the project more or less as I type.</p>
<p>The new compiler checks files in parallel, and you can tune it with <code>--checkers</code> (type-checker workers), <code>--builders</code> (parallel project builds in <code>-b</code> mode) or <code>--singleThreaded</code>. On my laptop the defaults were already the quickest. Our CI runners only have 2 vCPUs, though, and <code>--checkers 2</code> was a little faster there and used less memory, which helps because the same job runs the Next.js build afterwards.</p>
<p>Almost everything else moved over without fuss. Same errors, same messages, same <code>tsconfig.json</code>. The only config changes were the deprecation warnings TypeScript 6 had been showing me for months and I'd been ignoring, so if you're on 6 with a clean build, you're probably fine.</p>
<h2>The one that didn't move</h2>
<p>One project has a small script that reads my API route files with the TypeScript compiler API and generates a client from the exported handler types. It's a few hundred lines of <code>ts.createProgram</code> and <code>checker.getTypeAtLocation</code>, and it doesn't work with 7, because the old JavaScript API isn't part of the native compiler. A new API is in progress, but I didn't want to rewrite against it this month, so that one script keeps TypeScript 6 under an alias:</p>
<pre><code class="language-json">{
  "devDependencies": {
    "typescript": "^7.0.0",
    "typescript-6": "npm:typescript@^6.0.0"
  }
}
</code></pre>
<pre><code class="language-ts">// scripts/gen-client.ts
import ts from "typescript-6";
</code></pre>
<p>The project type-checks with 7 and the generator reads the code with 6. It's a bit ugly, but it's one import and easy to delete later. If anything in your toolchain imports <code>typescript</code> as a library (ts-morph and friends), check its issue tracker before upgrading, because that's where the trouble is.</p>
<p>For everything else I'd clear the 6.x warnings, bump to 7, switch the editor to the workspace version, and get on with the day.</p>
<hr />
<p><em>First published on <a href="https://blogs.kishankanejiiya.tech/posts/typescript-7-in-a-real-repo">my blog</a>. Cover photo: BWard 1997, CC BY 4.0, via Wikimedia Commons (<a href="https://commons.wikimedia.org/wiki/File:SCSA_Cars_Heading_on_Track_Rockingham_2006.jpg">source</a>).</em></p>
]]></content:encoded></item><item><title><![CDATA[What my Claude Code routine looks like a year in]]></title><description><![CDATA[A year after Claude Code went GA, most of what I tried early on has gone, and what's left is planning, small tasks, tests and a lot of careful review.]]></description><link>https://kishankanejiya.hashnode.dev/a-year-of-claude-code</link><guid isPermaLink="true">https://kishankanejiya.hashnode.dev/a-year-of-claude-code</guid><category><![CDATA[AI]]></category><category><![CDATA[Career]]></category><dc:creator><![CDATA[kishan kanejiiya]]></dc:creator><pubDate>Sun, 17 May 2026 09:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6ada143005beb55db93f5dd8/ebf4bd3e-99e4-45fa-b494-341483b9cc8a.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>It's been about a year since Claude Code went GA and I wrote that it had <a href="https://blogs.kishankanejiiya.tech/posts/claude-code-changed-my-week">changed how my week looks</a>. The novelty wore off a long time ago. I use it most days, at work and on side projects, and quite a lot of what I tried in those first months didn't last, so I thought it was worth writing down what did, mostly for my own benefit.</p>
<h2>Before any code gets written</h2>
<p>For anything bigger than a one-file change I start in plan mode. It reads the code, proposes what it's going to do, and I read the plan before a single file changes. I was most tempted to skip this step early on, because it feels slow, but it's where I save the most time. Rejecting a bad plan takes a couple of minutes. Rejecting a bad implementation means reviewing the whole thing first, and then rejecting it anyway.</p>
<p>What I'm looking for in a plan is usually one of three things: it wants to touch files I didn't expect, it wants to add a dependency, or it's about to invent a new pattern when the repo already has one for exactly this. One of those comes up more often than not, and a sentence back usually sorts it out before anything is written.</p>
<p>The other thing that matters before any code is the size of the task. "Add pagination to the meters table, server side, using the existing <code>usePaginatedQuery</code> hook" gets a good result. "Improve the meters page" gets a diff I don't want to read. I break work down the way I would for a new joiner on the team: one behaviour, roughly one PR's worth of change, and a clear idea of what done looks like. If I can't describe done in a sentence, I split it again.</p>
<h2>Tests, hooks and the boring rules</h2>
<p>On work code the deal is tests first, or at least tests in the same change. I'll often write the cases myself as a list in the prompt, especially the edge cases I actually care about, and let it write the test code around them. Then I read the test diff before the implementation diff, because a model that wants green ticks will occasionally make the test agree with the code rather than the other way round. If the tests are right, the rest is much easier to judge. For UI work I add a Playwright check for the main flow too. It's slower, but it catches the "compiles, renders, button does nothing" sort of bug that unit tests walk straight past.</p>
<p>Hooks run shell commands at points in a session, like after a file edit or before a command runs. I only have a couple. After an edit, the formatter and type checker run on the changed files, so errors show up straight away instead of when I go looking. And before a command, anything that touches <code>.env</code> files or pushes to <code>main</code> gets blocked. That second one isn't really about the model misbehaving. I just don't want something that important to depend on anyone remembering, me included.</p>
<p>Subagents I use for one thing, which is keeping the main conversation clean. "Find every place we format currency and list the patterns" means reading a lot of files, and I'd rather a subagent did that and came back with a short answer. Skills cover the things I repeat: one for our component conventions at work, one for the deploy pattern on my home server. They're just folders with instructions and the odd script, and they only load when they're relevant. I did spend a few weeks building fancier multi-agent setups, and I've stopped. For the kind of work I do, one well-briefed session does better than several agents passing notes to each other.</p>
<p>My <code>CLAUDE.md</code> files are shorter now than they were a year ago, twenty or thirty lines, and each line is something it got wrong at least once, written as an instruction. When one grew to a few hundred lines the important rules got lost in it. Same with memory. It's useful, but I prune it, because an out-of-date note is worse than no note at all (it'll follow an instruction that stopped being true months ago, very confidently).</p>
<h2>Reviewing what comes back</h2>
<p>I review every diff the way I'd review a pull request from someone smart who joined last week. Good intent, no knowledge of our history.</p>
<pre><code class="language-bash">git diff --stat          # did it touch what I expected?
git diff -- '*.test.*'   # tests first
git diff                 # then the rest, file by file
</code></pre>
<p>If the stat shows a file I didn't expect, I ask why before reading anything else. Sometimes there's a good reason. Other times it's a drive-by "improvement" that gets reverted. And the rule I set myself last year still stands: if I can't explain a change without reading its summary, it doesn't get committed.</p>
<p>Anything touching auth, user input, file paths, SQL or network calls also gets a second session with one job, which is to review the diff for security problems with fresh context and no attachment to code it just wrote. It finds real things. A path built from user input that wasn't normalised. A server action that checked there was a session but not that the record belonged to that user. A <code>dangerouslySetInnerHTML</code> fed from a CMS field. None of these are exotic, and that's why they slip through when you're tired. Since <a href="https://blogs.kishankanejiiya.tech/posts/react2shell">React2Shell</a> in December I also ask it to check dependency versions against recent advisories whenever <code>package.json</code> changes. That doesn't replace <code>npm audit</code> or Dependabot, but it reads the advisory text, and they don't.</p>
<h2>What I still do myself</h2>
<p>Data models and API shapes I write by hand, because those decisions outlive the code and the generic answer is usually wrong for us. Anything with tricky concurrency or caching I'll have it explain, but I write it. And the first version of a new pattern is mine too: once it exists in the repo it can copy it well, but if it has to invent one, you get something very average. (Posts like this one are also mine, for what it's worth.)</p>
<p>Cost-wise, a personal plan is a fixed monthly amount, which is fine for side projects. Where I've been careful is API usage in my own automations, because there the bill grows with every call, so cheap work goes to a small model and only the hard part goes to a big one. I'll write that up properly when I do a post on my n8n workflows. At work the maths is simple enough. If it saves a senior engineer a couple of hours a week it has paid for itself many times over, and the cost I actually notice is attention, because reviewing is tiring and a tired reviewer approves things they shouldn't.</p>
<p>It's made me quicker at the parts of the job that were never the hard parts, and, a bit to my surprise, more careful about the rest.</p>
<hr />
<p><em>First published on <a href="https://blogs.kishankanejiiya.tech/posts/a-year-of-claude-code">my blog</a>. Cover photo: Shixart1985, CC BY 2.0, via Wikimedia Commons (<a href="https://commons.wikimedia.org/wiki/File:Working_on_a_planning_session_with_stationery_items,_notebook,_and_colorful_pencils_on_a_wooden_desk.jpg">source</a>).</em></p>
]]></content:encoded></item><item><title><![CDATA[Going through ES2025 and ES2026 properly for once]]></title><description><![CDATA[I finally read the ES2025 list properly, Temporal shipped in Chrome and ES2026 is close. What's in my code now and what still has to wait.]]></description><link>https://kishankanejiya.hashnode.dev/javascript-in-a-year</link><guid isPermaLink="true">https://kishankanejiya.hashnode.dev/javascript-in-a-year</guid><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[kishan kanejiiya]]></dc:creator><pubDate>Sun, 15 Mar 2026 09:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6ada143005beb55db93f5dd8/9bed78bc-0d77-4c44-bd3d-c124576d6fde.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Every June a new ECMAScript edition gets approved, and every June I skim the list, think "nice", and go back to writing exactly the same code as before. This year I sat down with it on a quiet Sunday and went through it feature by feature, partly because a surprising amount of it is stuff I've been pulling in from npm (or copying between repos) for years, and partly because Temporal finally shipped in Chrome and I wanted to know where I stood before someone at work asked me.</p>
<p>So this is my pass over it, in roughly the order I'd care about each thing, with a note on whether it's in code I actually ship.</p>
<h2>ES2025</h2>
<p><a href="https://tc39.es/ecma262/2025/">ES2025</a> was approved last June. Every current browser has most of it, and so does Node 22 and up.</p>
<p>The one I've used most is iterator helpers. They're the array methods you already know (<code>filter</code>, <code>map</code>, <code>take</code>, <code>drop</code>, <code>flatMap</code>, <code>reduce</code>, <code>some</code>, <code>every</code>, <code>find</code>) but on any iterator, and lazy:</p>
<pre><code class="language-js">const firstTenIds = rows
  .values()
  .filter((r) =&gt; r.active)
  .map((r) =&gt; r.id)
  .take(10)
  .toArray();
</code></pre>
<p>Nothing gets built in the middle. With a generator or a big <code>Map</code>, each step pulls one item at a time, and <code>take(10)</code> stops the whole chain once it has ten. There's also <code>Iterator.from()</code> to wrap anything iterable. Most of my use is on <code>Map.prototype.values()</code> and generators in Node scripts, where I used to spread everything into an array just so I could call <code>.filter</code> on it, which always felt a bit silly for something with a few hundred thousand entries.</p>
<p>Then the Set methods, which I've wanted for about as long as I've written JavaScript:</p>
<pre><code class="language-js">const before = new Set(["n12", "n3", "n2"]);
const after = new Set(["n12", "n20"]);

after.difference(before);    // Set { "n20" }      added
before.difference(after);    // Set { "n3", "n2" } removed
before.intersection(after);  // Set { "n12" }
before.isSubsetOf(after);    // false
</code></pre>
<p>There's <code>union</code>, <code>intersection</code>, <code>difference</code>, <code>symmetricDifference</code>, <code>isSubsetOf</code>, <code>isSupersetOf</code> and <code>isDisjointFrom</code>. I had a little <code>setDiff</code> helper that had been copied into several repos over the years, slightly different in each one. It's gone now, and these are in pretty much everything I write. If you only pick one thing from this post, pick these.</p>
<p><code>Promise.try</code> is smaller. It runs a function and hands you a promise whether the function was sync, async, or threw before it got going:</p>
<pre><code class="language-js">const result = await Promise.try(() =&gt; parseConfig(input));
</code></pre>
<p>Before, a synchronous throw in the set-up of a promise chain could escape the chain entirely, and you'd find out from an unhandled error somewhere else. I use it in one retry helper where the callbacks can be either kind, and that's about it.</p>
<p><code>RegExp.escape</code> is the one that fixes a real bug class:</p>
<pre><code class="language-js">const pattern = new RegExp(`^${RegExp.escape(userInput)}$`, "i");
</code></pre>
<p>I've seen hand-rolled escape functions miss a character more than once. Any search box that builds a regex from what someone typed wants this. In my case it replaced a regex whose whole job was escaping other regexes, which is the kind of line nobody wants to review and everyone approves anyway.</p>
<p>And JSON modules with import attributes:</p>
<pre><code class="language-js">import config from "./config.json" with { type: "json" };
</code></pre>
<p>The <code>with { type: "json" }</code> bit isn't decoration. It tells the runtime what it's allowed to load, so a server can't hand you JavaScript when you asked for JSON. I use it in Node scripts. In app code nothing changes, because bundlers have handled JSON imports for as long as I can remember.</p>
<p>ES2025 also has <code>Float16Array</code>, RegExp modifiers and duplicate named capture groups. I haven't needed any of them yet, and I'm not going to pretend I have.</p>
<h2>Temporal</h2>
<p>This is the big one. <a href="https://tc39.es/proposal-temporal/docs/">Temporal</a> replaces <code>Date</code>, and it's been "nearly ready" for roughly as long as I've been working.</p>
<p>Firefox shipped it last year. Chrome shipped it in <strong>Chrome 144</strong> in January. And this month it reached Stage 4 at TC39, so it goes into ES2026.</p>
<pre><code class="language-js">const now = Temporal.Now.zonedDateTimeISO("Europe/London");
const meeting = now.add({ days: 1 }).with({ hour: 9, minute: 30 });

meeting.toString();
// 2026-03-16T09:30:00+00:00[Europe/London]

const d = Temporal.PlainDate.from("2026-03-29");
d.add({ months: 1 }).toString(); // "2026-04-29"
</code></pre>
<p>Values are immutable, months start at 1 like a normal person would expect, and time zones are real objects instead of something you hope the server got right. It also has separate types for a date with no time (<code>PlainDate</code>), a wall-clock time in a particular place (<code>ZonedDateTime</code>), and an exact moment (<code>Instant</code>). <code>Date</code> never let you say which of those you meant, and I think most date bugs I've seen come from exactly that.</p>
<p>My day job involves a lot of energy market data, which is all half-hourly settlement periods in UK time. The clocks go forward on 29 March this year, which gives you a 46-period day, and when they go back in October you get a 50-period day. Every one of those has been a bug at some point in some codebase I've worked on (sometimes the same bug, fixed twice, in two places). Temporal gets them right by default, because it does the arithmetic in the time zone you gave it.</p>
<p>Am I using it? Not in the browser yet. Safari doesn't have it, and our users are on whatever browser their IT department installed. I'm using the <a href="https://github.com/js-temporal/temporal-polyfill">polyfill</a> in one internal tool to get used to the API, and for new date logic I write it the Temporal way, so the switch later is mostly deleting an import.</p>
<h2>ES2026, nearly</h2>
<p>ES2026 should be approved in June. These are at Stage 4 already or close enough that I'd be surprised if they moved.</p>
<p><code>using</code> is explicit resource management. Something gets cleaned up when it goes out of scope:</p>
<pre><code class="language-js">{
  await using db = await openConnection();
  const rows = await db.query("select 1");
} // db[Symbol.asyncDispose]() runs here, even if query throws
</code></pre>
<p>It's <code>try/finally</code> without the nesting, and it's handy for file handles, locks and test fixtures. I use it in Node 24 scripts. Not in browser code yet.</p>
<p><code>Math.sumPrecise</code> adds up numbers without the floating point error piling up as it goes:</p>
<pre><code class="language-js">[0.1, 0.2, 0.3].reduce((a, b) =&gt; a + b); // 0.6000000000000001
Math.sumPrecise([0.1, 0.2, 0.3]);         // 0.6
</code></pre>
<p>It isn't decimal maths, but for totals on a dashboard it's what you wanted all along. I'm not using it yet, and for money we already store integer pence, so it matters less to me than it might to you.</p>
<p><code>Map.getOrInsert</code> does the "check, set a default, return it" dance in one call, and <code>getOrInsertComputed</code> takes a callback so the default is only built when it's needed:</p>
<pre><code class="language-js">const byArea = new Map();
for (const home of homes) {
  byArea.getOrInsert(home.postcode, []).push(home);
}
</code></pre>
<p>Too new for me to rely on yet, but I can already think of a few places it'll tidy up.</p>
<p><code>Error.isError(x)</code> tells you whether something really is an Error, even across realms (iframes, <code>vm</code> contexts) where <code>instanceof</code> gives you the wrong answer. That's mostly useful in logging and error-reporting code. And <code>Uint8Array</code> gets <code>fromBase64()</code>, <code>toBase64()</code>, <code>fromHex()</code> and <code>toHex()</code>, which replace the <code>btoa</code>/<code>atob</code> workarounds that only ever worked on "binary strings" and fell over on anything outside Latin-1:</p>
<pre><code class="language-js">const bytes = Uint8Array.fromBase64(payload);
const hex = bytes.toHex();
</code></pre>
<p>I'm using the base64 ones in a small secrets tool where the key lives in the URL fragment. <code>Error.isError</code> I haven't reached for yet.</p>
<h2>How I decide what to use</h2>
<p>At work the rule is dull and it works: if a feature is Baseline "widely available", or the code only runs in Node, use it. If it's only "newly available", use it behind a polyfill or where we control the browser. Anything else waits.</p>
<p>Side projects get a looser rule, because the worst case is that I break my own site and fix it on a Sunday.</p>
<p>Checking <a href="https://web.dev/baseline">Baseline</a> on MDN takes about ten seconds, and it has stopped me shipping something Safari didn't support more than once. I'd check it before reaching for anything on this list.</p>
<hr />
<p><em>First published on <a href="https://blogs.kishankanejiiya.tech/posts/javascript-in-a-year">my blog</a>. Cover photo: Uoaei1, CC BY-SA 4.0, via Wikimedia Commons (<a href="https://commons.wikimedia.org/wiki/File:Praha_Astronomical_Clock_Calendar_01.jpg">source</a>).</em></p>
]]></content:encoded></item><item><title><![CDATA[How I write server functions now]]></title><description><![CDATA[Two months after the December React advisories, I write every server action as if it were a public endpoint and keep secrets well away from it.]]></description><link>https://kishankanejiya.hashnode.dev/server-functions-after-react2shell</link><guid isPermaLink="true">https://kishankanejiya.hashnode.dev/server-functions-after-react2shell</guid><category><![CDATA[Security]]></category><category><![CDATA[React]]></category><dc:creator><![CDATA[kishan kanejiiya]]></dc:creator><pubDate>Sun, 08 Feb 2026 09:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6ada143005beb55db93f5dd8/edd93276-d0c2-486a-b75e-2c2ceb7121d0.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>December was a rough month for anyone running React Server Components in production. Once the patching was done, I kept wondering how much of my own code would still hold up if the framework had another bad week, and for a couple of my apps the answer wasn't great.</p>
<p>I'd been writing server actions as if they were private functions, because that's how they feel. You put <code>"use server"</code> at the top, import the function into a component and call it like any other async function. Nothing about it looks like an API. But it is one: when you export a function from a <code>"use server"</code> file, Next.js gives it an ID and exposes it so the browser can call it, and anyone who can load your site can find that ID and POST whatever arguments they like. TypeScript types don't exist at runtime either, so an action declared as <code>(id: string)</code> can just as easily receive an object, an array or a 5 MB string.</p>
<p>So I've started writing every action the way I'd write a Go handler. Assume the input is hostile, check who's calling, and only then do the work.</p>
<h2>Parse first, then check the user</h2>
<p>I use <a href="https://zod.dev">zod</a>, though any runtime validator does the job. What matters is that the schema runs on the server, inside the action, before anything else happens:</p>
<pre><code class="language-ts">"use server";

import { z } from "zod";
import { requireUser } from "@/lib/auth";
import { db } from "@/lib/db";

const RenameReport = z.object({
  reportId: z.string().uuid(),
  name: z.string().trim().min(1).max(120),
});

export async function renameReport(input: unknown) {
  const user = await requireUser();
  const { reportId, name } = RenameReport.parse(input);

  await db.report.update({
    where: { id: reportId, orgId: user.orgId },
    data: { name },
  });
}
</code></pre>
<p>The parameter is typed <code>unknown</code> on purpose. It forces me to parse before I can touch anything, and it's honest about what actually arrives over the wire. Forms get the same treatment with <code>FormData</code>:</p>
<pre><code class="language-ts">export async function subscribe(formData: FormData) {
  const { email } = z
    .object({ email: z.string().email().max(254) })
    .parse(Object.fromEntries(formData));
  // ...
}
</code></pre>
<p>After the <a href="https://blogs.kishankanejiiya.tech/posts/next-middleware-bypass">middleware bypass</a> last March I'd already moved auth checks for pages next to the data, but actions need it too, and they're easy to miss. So every action that touches user data starts with <code>requireUser()</code>, and the query is scoped to that user. In the example, <code>where: { id: reportId, orgId: user.orgId }</code> means a perfectly valid, logged-in user still can't rename someone else's report by guessing an ID. I've seen that exact access-control bug in code review more than once, in apps written by good developers. The helper itself is tiny:</p>
<pre><code class="language-ts">// lib/auth.ts
import "server-only";
import { cookies } from "next/headers";

export async function requireUser() {
  const token = (await cookies()).get("session")?.value;
  const user = token ? await verifySession(token) : null;
  if (!user) throw new Error("Unauthorised");
  return user;
}
</code></pre>
<p>That <code>import "server-only"</code> line is on every module that reads secrets, talks to the database or verifies sessions now. The <a href="https://www.npmjs.com/package/server-only"><code>server-only</code></a> package does nothing at runtime; its only job is to fail the build if a client component imports the file, so accidentally shipping the DB client to the browser shows up as a red build instead of an incident.</p>
<h2>Secrets live somewhere else</h2>
<p>One of the December follow-ups, CVE-2025-55183, exposed server function source code, and what I took from it is that server code shouldn't be relied on to stay secret. I had things like this lying around:</p>
<pre><code class="language-ts">"use server";

const API_KEY = "sk_live_..."; // hardcoded "just for now"

export async function sendAlert() {
  await fetch("https://api.example.com/send", {
    headers: { Authorization: `Bearer ${API_KEY}` },
  });
}
</code></pre>
<p>Now secrets only come from the environment, read at call time in a <code>server-only</code> module, and never sit in the file that holds the action:</p>
<pre><code class="language-ts">// lib/mailer.ts
import "server-only";

export async function sendAlert(to: string) {
  const key = process.env.MAILER_KEY;
  if (!key) throw new Error("MAILER_KEY missing");
  // ...
}
</code></pre>
<p>The action file imports <code>sendAlert</code> and has nothing sensitive in it, so if its source ever leaks again it makes for very dull reading.</p>
<p>Because actions are endpoints, they can be hammered like endpoints too. Anything that sends email, calls an LLM or runs a heavy query gets a limit. On my own box that's a simple fixed window in Postgres, keyed by user ID or IP:</p>
<pre><code class="language-ts">export async function generateSummary(input: unknown) {
  const user = await requireUser();
  await rateLimit(`summary:${user.id}`, { max: 10, perSeconds: 3600 });
  // ...
}
</code></pre>
<p>On Doorstep the subscribe form also sits behind Cloudflare, so there's a second limit at the edge as well.</p>
<h2>Patching without relying on me</h2>
<p>December showed me how much depended on me seeing the right post on the right evening, so every repo now has the boring automation. Renovate (Dependabot would do) opens PRs for <code>next</code>, <code>react</code> and <code>react-dom</code> the day they're published, with security updates labelled and kept out of the grouped ones. CI runs an audit and fails on anything high or critical. Versions are pinned exactly and installs use <code>npm ci</code>, so what I tested is what ships, and when a security PR merges the Docker image is built with <code>--pull</code> and no cache so a stale layer can't keep the old package around.</p>
<pre><code class="language-yaml"># .github/workflows/ci.yml (the relevant step)
- run: npm ci
- run: npm audit --omit=dev --audit-level=high
- run: npm run build
</code></pre>
<p>None of that would stop a zero-day. It just shrinks the gap between the fix existing and the fix running, which in December was the bit that mattered. I still use server actions everywhere and I'm not going back to hand-writing an API route for every form. I just write them like the public endpoints they are.</p>
<hr />
<p><em>First published on <a href="https://blogs.kishankanejiiya.tech/posts/server-functions-after-react2shell">my blog</a>. Cover photo: Rohini, CC BY-SA 4.0, via Wikimedia Commons (<a href="https://commons.wikimedia.org/wiki/File:Keychain_with_text_%22Encryption_is_key%22.jpg">source</a>).</em></p>
]]></content:encoded></item><item><title><![CDATA[React2Shell, and the weekend I spent patching for it]]></title><description><![CDATA[A CVSS 10 bug in React Server Components let one HTTP request run code on the server. How it works, and what I checked and patched.]]></description><link>https://kishankanejiya.hashnode.dev/react2shell</link><guid isPermaLink="true">https://kishankanejiya.hashnode.dev/react2shell</guid><category><![CDATA[Security]]></category><category><![CDATA[React]]></category><category><![CDATA[Next.js]]></category><dc:creator><![CDATA[kishan kanejiiya]]></dc:creator><pubDate>Sun, 07 Dec 2025 09:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6ada143005beb55db93f5dd8/26438d4a-451d-4e50-b188-4ab29297418c.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>On Wednesday evening (3 December) the React team published an advisory that I had to read twice. <a href="https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components">CVE-2025-55182</a>, which everyone is now calling React2Shell, is unauthenticated remote code execution in React Server Components, scored CVSS 10.0, exploitable in a default configuration with one crafted request.</p>
<p>I've spent most of the days since on it, at work and on my own projects. These are my notes.</p>
<h2>What broke</h2>
<p>If you use the App Router, your server and the browser talk to each other in a format called Flight. It's how React sends a rendered server component tree down to the client, and it's also how the client calls a server function. When a form submits to a <code>"use server"</code> action, React serialises the arguments, POSTs them to your server, and deserialises them there before your function runs.</p>
<p>That last step is where it went wrong. The deserialiser in <code>react-server-dom-webpack</code> (and the <code>-parcel</code> and <code>-turbopack</code> builds of the same code) trusted the shape of what it was given far more than it should have, and a carefully built payload could get the decoding step to reach things it was never meant to touch, and from there run code inside your Node process.</p>
<p>What makes it worse than the usual "please update your dependency" advisory is when it happens. It's before any of your code runs, so a server action that validates its input perfectly and checks the session is no help, because the bug is in turning the request body into arguments in the first place. And you didn't even need server actions. If the RSC server bundle is in your app, the endpoint that decodes Flight requests exists, so an app with no <code>"use server"</code> functions at all was still exposed.</p>
<p>I'm not going to describe the payload. Public proof-of-concepts appeared within about a day, and AWS reported exploitation attempts from known groups within hours of disclosure, so it's safest to assume scanners have already been at anything you have that's public.</p>
<h2>Who is affected</h2>
<table>
<thead>
<tr>
<th>Package</th>
<th>Vulnerable</th>
<th>First fixed</th>
</tr>
</thead>
<tbody><tr>
<td><code>react-server-dom-webpack</code> / <code>-parcel</code> / <code>-turbopack</code></td>
<td>19.0.0, 19.1.0, 19.1.1, 19.2.0</td>
<td>19.0.1, 19.1.2, 19.2.1</td>
</tr>
<tr>
<td><code>next</code> (App Router)</td>
<td>15.x, 16.0.0 to 16.0.6, some 14.3 canaries</td>
<td>15.0.5, 15.1.9, 15.2.6, 15.3.6, 15.4.8, 15.5.7, 16.0.7</td>
</tr>
</tbody></table>
<p>Next.js has its own ID, CVE-2025-66478, because it bundles a vendored copy of the React server packages. That means bumping <code>react</code> in <code>package.json</code> doesn't fix a Next app. You need the patched <code>next</code>.</p>
<p>Next.js 13.x and 14.x stable aren't affected, and neither is the Pages Router, plain client-side React (a Vite SPA, CRA) or React Native. If your React never runs on a server, you can stop reading here. Other RSC frameworks (React Router's RSC mode, Waku, the Vite and Parcel RSC plugins) shipped their own patched releases, so check their changelogs if you use one.</p>
<h2>What I did</h2>
<p>My side projects are small and all live on one box, so for me this was a contained job, but I did it in a deliberate order.</p>
<p>First an inventory, of every repo and not just the ones I remembered being on Next 15 or later:</p>
<pre><code class="language-bash">for d in ~/Desktop/Git/profile-projects/*/web; do
  printf '%-45s ' "$d"
  (cd "$d" &amp;&amp; npm ls next react-server-dom-webpack --depth=0 2&gt;/dev/null | grep -Eo '(next|react-server-dom-webpack)@[0-9.]+' | tr '\n' ' ')
  echo
done
</code></pre>
<p><code>npm ls</code> reports what's actually installed, which is what you want here. A <code>^15.3.0</code> in <code>package.json</code> tells you nothing about what the lockfile resolved to.</p>
<p>Then upgrade and rebuild from clean:</p>
<pre><code class="language-bash">npm install next@latest react@latest react-dom@latest
rm -rf .next node_modules &amp;&amp; npm ci &amp;&amp; npm run build
</code></pre>
<p>Vercel also put out <code>npx fix-react2shell-next</code>, which bumps you to the right patched version for your minor line. It's fine. I did mine by hand because I wanted to see the lockfile diff.</p>
<p>Then redeploy everything, including the apps I'd half forgotten about, because a forgotten demo is still a Node process with your environment variables in it. After that I went looking for signs that anything had got in first: access logs for odd POSTs to page routes carrying a <code>Next-Action</code> header from IPs I didn't recognise, and the containers themselves for processes or files that shouldn't be there. My containers run with a read-only filesystem and no capabilities, which doesn't stop RCE but does make life a lot less comfortable for whoever lands in one.</p>
<p>And finally I rotated secrets on anything that had been public and unpatched once the proof-of-concepts were out, because you can't prove nobody read <code>process.env</code>. For me that was a couple of database passwords and a webhook key. It's the step people skip, and it took less time than writing this paragraph.</p>
<h2>Afterwards</h2>
<p>Server functions feel like calling a function, but they're really a public POST endpoint with a clever serialisation format, and that format is now part of your attack surface in the same way JSON and multipart parsers always have been.</p>
<p>The managed platforms got WAF rules out quickly, which helped people on Vercel and Cloudflare. When you self-host nobody does that for you, and your real defence is how quickly you can go from advisory to patched build. For me that's one deploy script per app, and this week I was very glad of it. I'll write separately about how this has changed the way I write server actions.</p>
<p><strong>Update (12 December 2025):</strong> there's a second round. People looking at the patch found two more problems in the same code: <a href="https://react.dev/blog/2025/12/11/denial-of-service-and-source-code-exposure-in-react-server-components">CVE-2025-55184</a>, a denial of service where a crafted request can hang the server in an infinite loop, and CVE-2025-55183, where a request can get a server function's source code back. Neither is RCE, but the second one leaks anything hardcoded in a server function. The first DoS fix also turned out to be incomplete (CVE-2025-67779), so the versions to be on now are React 19.0.3, 19.1.4 or 19.2.3, and Next.js 16.0.10 or the latest patch of your 15.x line. If you patched on the 4th, you need to patch again.</p>
<hr />
<p><em>First published on <a href="https://blogs.kishankanejiiya.tech/posts/react2shell">my blog</a>. Cover photo: Decreasing volume, CC BY-SA 4.0, via Wikimedia Commons (<a href="https://commons.wikimedia.org/wiki/File:Gamewell_fire_alarm_call_box.jpg">source</a>).</em></p>
]]></content:encoded></item><item><title><![CDATA[A Saturday upgrading my dashboard to Next.js 16]]></title><description><![CDATA[The codemod, proxy.ts, Turbopack builds, properly async params, Cache Components and deleting most of my useMemo calls, in the order I ran into them.]]></description><link>https://kishankanejiya.hashnode.dev/next-16-what-i-changed</link><guid isPermaLink="true">https://kishankanejiya.hashnode.dev/next-16-what-i-changed</guid><category><![CDATA[Next.js]]></category><category><![CDATA[React]]></category><dc:creator><![CDATA[kishan kanejiiya]]></dc:creator><pubDate>Sun, 26 Oct 2025 09:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6ada143005beb55db93f5dd8/0c170420-9460-49be-a086-e874e83340b5.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><a href="https://nextjs.org/blog/next-16">Next.js 16</a> came out on Tuesday, and I gave it Saturday morning on one of my side projects: a small dashboard with a few dozen routes, a couple of route handlers, auth in middleware and Postgres behind it. It turned into most of the day, but nearly all of that was one feature. Here's the diff that mattered, roughly in the order I hit it.</p>
<p>Start with the codemod:</p>
<pre><code class="language-bash">npx @next/codemod@canary upgrade latest
</code></pre>
<p>It bumps <code>next</code>, <code>react</code> and <code>react-dom</code> and runs the transforms it knows about, which turned out to be more than I expected. It renamed my middleware, made most <code>params</code> access async and moved a few config keys around. Where it wasn't sure, it left a <code>// TODO</code> comment instead of guessing, which I was grateful for. I committed straight after it ran and before touching anything by hand, so my own changes would be a separate diff I could actually read.</p>
<h2>middleware.ts becomes proxy.ts</h2>
<p>The file is renamed and so is the function it exports:</p>
<pre><code class="language-diff">- // middleware.ts
- export function middleware(request: NextRequest) {
+ // proxy.ts
+ export function proxy(request: NextRequest) {
    const session = request.cookies.get("session");
    if (!session &amp;&amp; request.nextUrl.pathname.startsWith("/app")) {
      return NextResponse.redirect(new URL("/login", request.url));
    }
  }
</code></pre>
<p>I think the new name is more honest about what the file is for. Since the middleware bypass in March, the Next team has been fairly clear that this layer is for redirects, rewrites and headers at the edge of your app, and shouldn't be your only auth check. <code>proxy.ts</code> runs on the Node.js runtime. <code>middleware.ts</code> still works for now but is deprecated, so you might as well rename it while you're in there. My redirect stayed, but the real check already lives next to the query in the data layer. If yours doesn't, this is as good an excuse as any.</p>
<h2>Turbopack builds</h2>
<p><code>next dev</code> and <code>next build</code> both use Turbopack by default now. I'd been on it for dev since 15, so the new part was the production build, which was clearly quicker on my machine (one small app and a couple of runs, so don't take that as a benchmark).</p>
<p>One thing broke. I had a custom <code>webpack()</code> function in <code>next.config.ts</code> adding an SVG loader, and with Turbopack as the default the build now fails when it finds a webpack config rather than silently ignoring it. Moving it across was easy enough:</p>
<pre><code class="language-diff">  const nextConfig: NextConfig = {
-   webpack(config) {
-     config.module.rules.push({ test: /\.svg$/, use: ["@svgr/webpack"] });
-     return config;
-   },
+   turbopack: {
+     rules: {
+       "*.svg": { loaders: ["@svgr/webpack"], as: "*.js" },
+     },
+   },
  };
</code></pre>
<p>If you're not ready for that, <code>next build --webpack</code> still works.</p>
<h2>Async params, for real this time</h2>
<p>In 15, <code>params</code> and <code>searchParams</code> became promises but synchronous access still worked with a warning. In 16 the fallback has gone. The codemod handled the obvious pages and missed a couple of helper functions that took <code>params</code> as an argument, which the type checker found for me:</p>
<pre><code class="language-diff">- export default function Page({ params }: { params: { id: string } }) {
-   const report = getReport(params.id);
+ export default async function Page({ params }: PageProps&lt;"/reports/[id]"&gt;) {
+   const { id } = await params;
+   const report = await getReport(id);
</code></pre>
<p><code>PageProps&lt;"/reports/[id]"&gt;</code> is a global type that gets generated (after <code>next dev</code> or <code>next typegen</code>) and is typed from the route itself, so I'm no longer hand-writing <code>{ params: Promise&lt;{ id: string }&gt; }</code> and getting it slightly wrong. The same goes for <code>cookies()</code>, <code>headers()</code> and <code>draftMode()</code>. Await them.</p>
<h2>Cache Components</h2>
<p>This is the big conceptual change, and where most of my day went. You switch it on in config:</p>
<pre><code class="language-ts">const nextConfig: NextConfig = {
  cacheComponents: true,
};
</code></pre>
<p>With it on, nothing is cached unless you ask. Pages are dynamic by default, and you opt particular functions or components into caching with the <code>"use cache"</code> directive. It's more or less Partial Prerendering grown up: a static shell, with the dynamic parts streaming in behind <code>&lt;Suspense&gt;</code>.</p>
<p>I'd been fighting the old implicit caching rules for about a year (<code>fetch</code> cached here but not there, <code>revalidate = 0</code> sprinkled around until things behaved), so this was the change I actually wanted. My reports list ended up like this:</p>
<pre><code class="language-tsx">async function getRecentReports() {
  "use cache";
  cacheLife("minutes");
  cacheTag("reports");
  return db.report.findMany({ orderBy: { createdAt: "desc" }, take: 20 });
}
</code></pre>
<p>When someone creates a report, the server action calls <code>updateTag("reports")</code> so they see their new row straight away. There's also <code>revalidateTag</code>, which now wants a cache-life profile as its second argument, for the stale-while-revalidate kind of refresh where a slightly old value is fine.</p>
<p>The slow part wasn't writing that. It was that turning Cache Components on surfaced every place where I read cookies deep inside a component with no Suspense boundary above it, and there were more of those than I'd have guessed. The error messages do point at the right component, which helped a lot.</p>
<h2>React Compiler, and deleting useMemo</h2>
<p>React Compiler reached 1.0 earlier this month and Next 16 supports it as a stable option:</p>
<pre><code class="language-ts">const nextConfig: NextConfig = {
  reactCompiler: true,
};
</code></pre>
<p>You need <code>babel-plugin-react-compiler</code> installed too, and builds are a little slower with it on because it goes through Babel. Then came the satisfying bit. I searched for <code>useMemo</code> and <code>useCallback</code> and removed the ones that only existed to keep references stable:</p>
<pre><code class="language-diff">- const filtered = useMemo(
-   () =&gt; rows.filter((r) =&gt; r.status === status),
-   [rows, status],
- );
- const onSelect = useCallback((id: string) =&gt; setSelected(id), []);
+ const filtered = rows.filter((r) =&gt; r.status === status);
+ const onSelect = (id: string) =&gt; setSelected(id);
</code></pre>
<p>That was most of them. I kept a couple where the work really is expensive, mainly so the intent stays visible to whoever reads it next. React DevTools puts a "Memo" badge on components the compiler has optimised, which is how I checked it was doing anything at all. And if a component breaks the rules of React (mutating props, reading refs during render) the compiler just skips it, and the ESLint plugin tells you where. That list was useful on its own.</p>
<h2>Two things from React 19.2</h2>
<p>Next 16 ships with React 19.2, and I used two of its additions straight away. <code>&lt;Activity&gt;</code> hides part of the tree without unmounting it. I've got a tabbed filter panel that used to lose its state whenever you switched tabs:</p>
<pre><code class="language-tsx">&lt;Activity mode={tab === "filters" ? "visible" : "hidden"}&gt;
  &lt;FiltersPanel /&gt;
&lt;/Activity&gt;
</code></pre>
<p>Hidden content keeps its state and has its effects cleaned up while it's hidden, which is what I'd been faking with <code>display: none</code>, minus the effects carrying on in the background.</p>
<p>And <code>useEffectEvent</code> fixed an effect that reconnected a websocket every time the theme changed, because the theme was used inside the message handler:</p>
<pre><code class="language-tsx">const onMessage = useEffectEvent((msg: Message) =&gt; {
  showToast(msg, theme);
});

useEffect(() =&gt; {
  const ws = connect(roomId, onMessage);
  return () =&gt; ws.close();
}, [roomId]);
</code></pre>
<p>The handler always sees the latest <code>theme</code>, the effect only depends on <code>roomId</code>, and the lint rule knows not to ask for <code>onMessage</code> in the dependency list.</p>
<p>A few smaller things to finish. <code>next lint</code> has gone, so I run ESLint directly. Node 20.9 is now the minimum. Some <code>next/image</code> defaults changed, and if you use local images with query strings you'll need <code>images.localPatterns</code>.</p>
<p>The app builds faster, the caching is finally something I could explain to someone else without a diagram, and there are far fewer hooks in it than there were on Friday.</p>
<hr />
<p><em>First published on <a href="https://blogs.kishankanejiiya.tech/posts/next-16-what-i-changed">my blog</a>. Cover photo: OathOn, CC0, via Wikimedia Commons (<a href="https://commons.wikimedia.org/wiki/File:Detail_of_scaffolding_and_concrete_facade_during_Mountbatten_House_renovation_work,_Chatham,_Kent.jpg">source</a>).</em></p>
]]></content:encoded></item><item><title><![CDATA[Node runs my TypeScript now (mostly)]]></title><description><![CDATA[Type stripping is on by default in Node 22.18 and 24, so my scripts run as plain .ts files with a couple of rules and one tsconfig.]]></description><link>https://kishankanejiya.hashnode.dev/node-runs-typescript-now</link><guid isPermaLink="true">https://kishankanejiya.hashnode.dev/node-runs-typescript-now</guid><category><![CDATA[Node.js]]></category><category><![CDATA[TypeScript]]></category><dc:creator><![CDATA[kishan kanejiiya]]></dc:creator><pubDate>Sun, 17 Aug 2025 09:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6ada143005beb55db93f5dd8/f1f83e64-20a1-4bfb-a9ed-b70c7cde77e6.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Every small TypeScript script I've written for years has needed a helper to run it. ts-node first, then tsx, because it was quicker and didn't fight ESM. It always felt slightly ridiculous to install a package so I could run a forty-line file that renames some CSVs.</p>
<p>But as of July that's mostly over. Type stripping landed behind <code>--experimental-strip-types</code> in Node 22.6, was unflagged in <a href="https://nodejs.org/en/blog/release/v23.6.0">Node 23.6</a> in January, and got backported to the LTS line in 22.18. On a current 22.x or on 24, this just works:</p>
<pre><code class="language-bash">node scripts/rename-exports.ts
</code></pre>
<p>You get an <code>ExperimentalWarning</code> the first time, which I ignore for scripts. It's worth knowing what Node is doing, because it isn't type-checking anything. It swaps the type annotations for whitespace (so line and column numbers stay put and stack traces are right without source maps) and runs what's left. Which means only syntax that can be erased works. Enums, namespaces with runtime code, parameter properties like <code>constructor(private db: Db)</code> and old-style decorators all throw <code>ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX</code>. There's <code>--experimental-transform-types</code> for those, but I'd rather change the code, and enums were the only ones I actually had:</p>
<pre><code class="language-ts">// before
export enum Status { Draft = "draft", Live = "live" }

// after
export const Status = { Draft: "draft", Live: "live" } as const;
export type Status = (typeof Status)[keyof typeof Status];
</code></pre>
<p>Call sites stay as <code>Status.Live</code>. Imports caught me out twice, though. A type-only import has to say <code>import type</code>, or Node keeps the import and then falls over at runtime when there's nothing there. And you write the real file extension, <code>./read.ts</code>, which TypeScript used to hate. <code>allowImportingTsExtensions</code> fixes that when you're not emitting, or <code>rewriteRelativeImportExtensions</code> (TS 5.7) if you also compile with <code>tsc</code>.</p>
<h2>The tsconfig I use for scripts</h2>
<p>TypeScript 5.8 added <code>erasableSyntaxOnly</code>, which makes the checker complain about exactly the things Node can't strip, so you find out in the editor instead of at runtime. With <code>verbatimModuleSyntax</code> forcing the <code>import type</code> habit, the pair catches nearly everything:</p>
<pre><code class="language-json">{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["scripts/**/*.ts"]
}
</code></pre>
<p>A trimmed-down script from one of my side projects, with top-level await, built-in fetch and no dependencies at all:</p>
<pre><code class="language-ts">// scripts/check-links.ts
import { readFile } from "node:fs/promises";
import type { Post } from "../lib/types.ts";

const posts: Post[] = JSON.parse(await readFile("data/posts.json", "utf8"));

for (const post of posts) {
  const res = await fetch(post.url, { method: "HEAD" });
  if (!res.ok) console.log(`${res.status}  ${post.slug}`);
}
</code></pre>
<p>Also worth knowing: <code>require()</code> of ES modules is unflagged in 22.12 and 20.19, as long as the module doesn't use top-level await. I had an old internal tool pinned to an old version of a library for exactly that reason, and bumping it just worked.</p>
<p>I haven't uninstalled tsx. Anything that runs in production still goes through a proper build with a type-check in CI, and Node doesn't read <code>paths</code> from tsconfig, so code importing <code>@/lib/db</code> needs a loader (or package.json <code>imports</code> like <code>#lib/db</code>, which I've started moving to). Stripping also never checks your types, so <code>tsc --noEmit</code> stays in CI. Node running the file only tells you it parses.</p>
<p>For scripts, seed files and one-off migrations, though, I just run the <code>.ts</code> file now. Not having to explain to a new joiner why they need three packages to run <code>seed.ts</code> is a small thing that I'm very happy about.</p>
<hr />
<p><em>First published on <a href="https://blogs.kishankanejiiya.tech/posts/node-runs-typescript-now">my blog</a>. Cover photo: Peter Shanks from Hobart, Australia, CC BY 2.0, via Wikimedia Commons (<a href="https://commons.wikimedia.org/wiki/File:Green_circuit_board_II_(2389301870).jpg">source</a>).</em></p>
]]></content:encoded></item><item><title><![CDATA[Six weeks of Claude Code, and I've stopped pasting into chat]]></title><description><![CDATA[Since Claude Code went GA with Claude 4 I hand it whole tasks instead of copy-pasting, and it fails in new ways I had to learn to spot.]]></description><link>https://kishankanejiya.hashnode.dev/claude-code-changed-my-week</link><guid isPermaLink="true">https://kishankanejiya.hashnode.dev/claude-code-changed-my-week</guid><category><![CDATA[AI]]></category><dc:creator><![CDATA[kishan kanejiiya]]></dc:creator><pubDate>Sun, 06 Jul 2025 09:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6ada143005beb55db93f5dd8/e8a8cf62-3680-4197-adbf-b9984b0a202b.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Back in February I said the thing I wanted from AI was something that could read my repo and run my tests itself, so I'd stop being the clipboard in the middle. Well, that exists now, and I've used it most days for about six weeks.</p>
<p>Claude Code went generally available on 22 May along with Claude 4 (Opus 4 and Sonnet 4). I'd played with the research preview earlier in the year on 3.7 Sonnet and liked it, but it felt like a demo. With Claude 4, and with it then being included in the Pro and Max plans, it went from interesting to the first thing I open in the morning.</p>
<h2>Handing over the steps</h2>
<p>Before, I wrote the code and AI either guessed the next line or answered whatever I pasted into a chat. Now I describe a task and something else does the steps. It runs in the terminal, inside the repo, reads the files it thinks matter, greps for usages, edits, runs <code>npm test</code> or <code>go test ./...</code>, reads the failure and tries again. Sometimes I watch, sometimes I make a coffee. My part of the loop has shifted from typing to reviewing, and I read a lot more code than I write.</p>
<p>A recent example from work: a component library had the same sorting logic copied into several places around a <code>Table</code>. I asked for it to be pulled into a hook, with the call sites updated, the existing tests kept green and new tests for the hook. It took a few minutes and touched a handful of files, and reviewing took me longer than the work did. By hand that's a morning.</p>
<p>What made it useful on our codebase, as opposed to just impressive, was a <code>CLAUDE.md</code> file at the root, which it reads at the start of every session. Mine is things like this:</p>
<pre><code class="language-md"># Notes for Claude

- Package manager is pnpm. Never npm.
- Run `pnpm test --filter &lt;pkg&gt;` for one package, not the whole repo.
- UI components live in packages/ui. Don't create new ones in apps/.
- We use date-fns, not moment. Don't add dependencies without asking.
- Go services: handlers stay thin, logic goes in internal/&lt;domain&gt;.
</code></pre>
<p>Pretty much every line is a mistake it made once. It's the onboarding doc I'd give a new joiner, minus the history, and I keep it short because when it got long the important lines started getting ignored.</p>
<p>The bigger use at work has been moving some old Java services to Go. They do fairly simple things (read files, validate, transform, push to an API) wrapped in a lot of ceremony. What works is pointing it at one class and its tests and asking for the behaviour as a list of rules, edge cases included. I check that list against what I know and against production logs, and only then ask for a Go version with table-driven tests for each rule. What doesn't work is "convert this package to Go". It'll do it, and it'll compile, and it may well drop the one null check that was the whole reason an old bug never came back.</p>
<h2>The new ways it goes wrong</h2>
<p>The failures are different from chat, and a bit worse, because the output is bigger.</p>
<p>It's very comfortable with big diffs. Ask for one change and you can get renamed variables, a reshuffled import block and a "small improvement" to some unrelated function, all summarised as a tidy-up. Each bit is harmless, but together they bury the one change that matters, so "only touch what this task needs" now goes into nearly every prompt. A couple of times it has fixed a failing test by changing what the test expected instead of fixing the code, and the summary said all tests pass, which was technically true. I read test diffs first now.</p>
<p>It also gets stuck sometimes, trying the same broken idea in slightly different clothes. When I see the third similar attempt I stop it and point it somewhere else, because letting it loop has never once been quicker. And it doesn't know about the parts of our setup that aren't in the repo: environment variables that only exist in CI, a service that needs to be running locally, a feature flag. It writes code that works in its world.</p>
<p>So a few habits have changed. I write tasks like tickets (what, why, which files, what done looks like, what to leave alone), and the better the ticket, the less there is to review. I make sure the working tree is clean before anything big, so <code>git diff</code> shows only its work and <code>git checkout .</code> is a cheap undo. And I do the interesting bits myself. It writes the hook, but I decide whether there should be a hook, partly because that's where I learn and partly because that's where it's most likely to pick something generic.</p>
<p>The habit I worry about is skimming. Reviewing tires you out in a different way from writing, and I've caught myself approving a diff because the summary sounded right. I've gone back to the rule from February: if I can't explain a change without reading the summary, it doesn't get committed.</p>
<p>On refactors and rewrites with decent tests, it saves me a lot more time than in February. On work where the hard part is deciding what to build, not much has changed, because that part is still mine. It's all moving every couple of weeks at the moment (hooks have just landed, and subagents are apparently next), so I'll come back to this once it's stopped being new.</p>
<hr />
<p><em>First published on <a href="https://blogs.kishankanejiiya.tech/posts/claude-code-changed-my-week">my blog</a>. Cover photo: Anirban Saha, CC BY-SA 4.0, via Wikimedia Commons (<a href="https://commons.wikimedia.org/wiki/File:Beautiful_Mechanical_Keyboard.jpg">source</a>).</em></p>
]]></content:encoded></item><item><title><![CDATA[TypeScript is moving to Go. A few thoughts]]></title><description><![CDATA[A quick note on the native TypeScript port: where the speed actually shows up, why Go made sense to me, and the tooling I'm worried about.]]></description><link>https://kishankanejiya.hashnode.dev/typescript-is-going-native</link><guid isPermaLink="true">https://kishankanejiya.hashnode.dev/typescript-is-going-native</guid><category><![CDATA[TypeScript]]></category><dc:creator><![CDATA[kishan kanejiiya]]></dc:creator><pubDate>Sun, 06 Apr 2025 09:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6ada143005beb55db93f5dd8/f4a4bc8c-d7cf-4047-bdc4-83cbf794f342.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>On 11 March Anders Hejlsberg posted <a href="https://devblogs.microsoft.com/typescript/typescript-native-port/">A 10x Faster TypeScript</a>: the compiler and language service are being ported to Go (working name Project Corsa), the current JavaScript codebase carries on as the 5.x and 6.x line, and the native one should ship as TypeScript 7. My team chat went very quiet for a minute and then didn't stop for an hour.</p>
<p>I've had a few weeks to poke at the preview on my own projects, and the speed-up on a full type-check is real. It isn't a clean 10x on a codebase my size, but it's big enough that I stopped looking at the decimals. Trying it takes two commands:</p>
<pre><code class="language-bash">npm install -D @typescript/native-preview
npx tsgo --noEmit -p .
</code></pre>
<p>What I actually care about more is the editor, though. Most of my day isn't spent running <code>tsc</code>. It's hovering, jumping to definitions, and waiting for the red squiggles to catch up after I rename something in a shared types file, and on the big monorepo at work the language service can take long enough to load that I've gone and made tea. If the editor numbers Microsoft quoted hold up on our repo, that changes how the job feels far more than shaving CI minutes does. (Builds of the app itself won't move much, by the way. Most of us already strip types with esbuild, swc or Turbopack and never use <code>tsc</code> to emit anything.)</p>
<p>People were cross about Go instead of Rust within the hour. The team's reasoning is that this is a port and not a rewrite: they want to move the existing compiler across file by file and keep its structure, so it behaves the same way, and the checker leans heavily on garbage collection and on data structures that point back at each other. Go is happy with both. I write Go most weeks for backend services and that matched my gut, because ported Go tends to read a lot like the code it came from, and someone who knows the old checker should be able to find their way around the new one. Go also gives them cheap shared-memory parallelism, which is a chunk of where the speed comes from.</p>
<p>The thing I'd watch is everything that imports <code>typescript</code> as a library. ts-morph, typescript-eslint's type-aware rules, custom transformers, a lot of codegen, Angular's and Vue's tooling all talk to the compiler's JavaScript API, and you can't <code>require</code> a Go binary. A new API is apparently in the works, but it won't be the same one. Language service plugins in tsconfig are in the same boat, and I've got one on an older project that I'm not at all sure survives.</p>
<p>For now I've added a second script to my side projects so I can compare the two:</p>
<pre><code class="language-json">{
  "scripts": {
    "typecheck": "tsc --noEmit",
    "typecheck:native": "tsgo --noEmit"
  }
}
</code></pre>
<p>At work I made a list of everything in our toolchain that imports <code>typescript</code> directly. It was longer than I expected, and I think that list is the real migration, more than the compiler swap itself. Issues go to <a href="https://github.com/microsoft/typescript-go">microsoft/typescript-go</a> if you hit something odd.</p>
<hr />
<p><em>First published on <a href="https://blogs.kishankanejiiya.tech/posts/typescript-is-going-native">my blog</a>. Cover photo: Cephas, CC BY-SA 3.0, via Wikimedia Commons (<a href="https://commons.wikimedia.org/wiki/File:Marmota_monax_UL_04.jpg">source</a>).</em></p>
]]></content:encoded></item><item><title><![CDATA[One header skipped my Next.js middleware, and my auth with it]]></title><description><![CDATA[CVE-2025-29927 let one request header skip Next.js middleware. I tested my self-hosted apps, patched them and moved auth next to the data.]]></description><link>https://kishankanejiya.hashnode.dev/next-middleware-bypass</link><guid isPermaLink="true">https://kishankanejiya.hashnode.dev/next-middleware-bypass</guid><category><![CDATA[Security]]></category><category><![CDATA[Next.js]]></category><dc:creator><![CDATA[kishan kanejiiya]]></dc:creator><pubDate>Mon, 24 Mar 2025 09:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6ada143005beb55db93f5dd8/ed554862-c6cf-4c8d-9a41-ac2a69e7268b.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A Next.js advisory with a CVSS of 9.1 came out on Friday, and by Saturday morning my timeline was full of the same curl command. I spent a good part of the weekend on it, partly patching and partly feeling a bit daft about where I'd put auth in a couple of apps.</p>
<h2>What the bug was</h2>
<p>Middleware runs before a request reaches your page or route handler, which is why so many of us put "no session cookie, redirect to /login" there. One file, every route covered.</p>
<p>Internally, Next.js has to stop middleware from calling itself in a loop, so it tags its own internal requests with a header, <code>x-middleware-subrequest</code>, and skips middleware when it sees it. The trouble was that nothing checked where that header came from. Anyone could send it, the server would treat the request as one of its own, and middleware simply didn't run. If middleware was your only auth check, the check was gone (along with anything else you did there, like CSP headers or a maintenance-page rewrite).</p>
<p>It's <a href="https://nvd.nist.gov/vuln/detail/CVE-2025-29927">CVE-2025-29927</a>, advisory <a href="https://github.com/vercel/next.js/security/advisories/GHSA-f82v-jwr5-mffw">GHSA-f82v-jwr5-mffw</a>, disclosed on 21 March 2025. Self-hosted apps running <code>next start</code> or the standalone server were exposed, which is exactly how I run things in Docker at home. Vercel and Netlify weren't, and static exports have no middleware to skip. The fixes are 15.2.3, 14.2.25, 13.5.9 and 12.3.5.</p>
<h2>Testing my own apps</h2>
<p>The value the header needs depends on the version. Older releases want the middleware's name (<code>middleware</code>, or <code>src/middleware</code> if you use a <code>src/</code> folder). Newer ones count recursion depth and give up after five, so the value is the name repeated five times with colons. I only tried this against apps I own, from my own machine:</p>
<pre><code class="language-bash">curl -s -o /dev/null -w '%{http_code}\n' https://myapp.example.com/dashboard
# 307, off to /login as expected

curl -s -o /dev/null -w '%{http_code}\n' \
  -H 'x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware' \
  https://myapp.example.com/dashboard
# 200
</code></pre>
<p>That 200 was not a fun thing to see. An internal tool on 15.1 rendered its dashboard with no session at all. Its data didn't load, because the API behind it checked the token itself, but the page shell came back with nav items and labels that had no business being public. On an older 14.x side project the <code>src/middleware</code> form was the one that worked, so try both if you're checking.</p>
<p>Patching was dull, which is how you want it:</p>
<pre><code class="language-bash">npm install next@15.2.3
npm ls next   # make sure nothing else is pulling in an older copy
docker build --no-cache -t myapp:latest .
</code></pre>
<p>I built without cache because I didn't want to find out on Monday that a layer was still holding the old <code>node_modules</code>. After the redeploy, the same curl gave a 307. My apps sit behind a reverse proxy, so I also strip the header there; there's no reason a request from outside should ever carry an internal framework header, patch or no patch:</p>
<pre><code class="language-nginx">location / {
    proxy_set_header x-middleware-subrequest "";
    proxy_pass http://127.0.0.1:3000;
}
</code></pre>
<p>An empty value in nginx means the header isn't forwarded at all. On Cloudflare, a transform rule that removes the request header does the same thing at the edge.</p>
<h2>Where the auth should have been</h2>
<p>The patch was the easy bit. The uncomfortable bit was the shape of the internal tool. Middleware read the cookie, verified the session and redirected, and the page simply assumed that if it was rendering, the user must be allowed:</p>
<pre><code class="language-ts">// middleware.ts
export async function middleware(req: NextRequest) {
  const session = await getSession(req);
  if (!session) {
    return NextResponse.redirect(new URL("/login", req.url));
  }
}

export const config = { matcher: ["/dashboard/:path*"] };
</code></pre>
<pre><code class="language-tsx">// app/dashboard/page.tsx
export default async function Dashboard() {
  const reports = await db.report.findMany(); // nobody asks who is asking
  return &lt;ReportList reports={reports} /&gt;;
}
</code></pre>
<p>Plenty of tutorials teach exactly this, and it means one framework bug becomes an auth bypass. Now the page asks who the user is, and the query is scoped to them:</p>
<pre><code class="language-tsx">// lib/auth.ts
import "server-only";

export async function requireUser() {
  const session = await getSession();
  if (!session) redirect("/login");
  return session.user;
}

// app/dashboard/page.tsx
export default async function Dashboard() {
  const user = await requireUser();
  const reports = await db.report.findMany({ where: { orgId: user.orgId } });
  return &lt;ReportList reports={reports} /&gt;;
}
</code></pre>
<p>Middleware still does the redirect, since it's nicer for people to bounce to login before anything renders, but it's a convenience now. The lock sits next to the data.</p>
<p>The API that saved me is written in Go, and every handler checks the token because there's no magic file in Go that runs first. Slightly funny that the more boring stack had the safer habit.</p>
<p>If you're still on an old version, upgrade to the patched release for your line. If you really can't this week, strip <code>x-middleware-subrequest</code> at your proxy or CDN today, then go through anything that relies on middleware for auth and add a check in the page, route handler or server action itself.</p>
<hr />
<p><em>First published on <a href="https://blogs.kishankanejiiya.tech/posts/next-middleware-bypass">my blog</a>. Cover photo: ImagePerson, CC BY-SA 4.0, via Wikimedia Commons (<a href="https://commons.wikimedia.org/wiki/File:Padlock_7642.jpg">source</a>).</em></p>
]]></content:encoded></item><item><title><![CDATA[Where AI actually helps my coding, as of February 2025]]></title><description><![CDATA[Copilot, Cursor's tab and a lot of copy-paste with Claude 3.5 Sonnet, written down because I suspect it will look very different in a year.]]></description><link>https://kishankanejiya.hashnode.dev/coding-with-ai-early-2025</link><guid isPermaLink="true">https://kishankanejiya.hashnode.dev/coding-with-ai-early-2025</guid><category><![CDATA[AI]]></category><dc:creator><![CDATA[kishan kanejiiya]]></dc:creator><pubDate>Sun, 16 Feb 2025 09:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/gql/6ada143005beb55db93f5dd8/467ab1b1-2bd7-4c77-9a9e-7de2e2ffd15e.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Every week someone on LinkedIn says AI writes 80% of their code. I don't know who these people are, but I'd like to write down what it looks like for me at the moment, because I have a feeling that in a year it will all be different and I'll have forgotten.</p>
<p>For context, I'm a front-end-heavy full stack dev. Most days are React and TypeScript, with some Go and some older code at work that nobody is keen to touch.</p>
<h2>What I'm using</h2>
<p>Copilot has been on in VS Code for over a year, doing autocomplete. I barely notice it any more, which I suppose is the point. I've also been using Cursor on and off, and its tab completion is better than Copilot's because it guesses the next edit, not just the next line: rename a prop in one place and it offers to fix the next three usages. I still can't decide how I feel about working in a fork of VS Code.</p>
<p>And then there's chat in a browser tab, mostly Claude 3.5 Sonnet and sometimes ChatGPT. That's where the bigger jobs happen, in a loop I suspect a lot of people will recognise. Copy code in, explain the problem, copy the answer out, paste, run, copy the error back in. It's clunky. The model can't see my repo, so I'm the one deciding which files matter and pasting them across, and choosing that context is half the skill.</p>
<h2>Where it earns its keep</h2>
<p>Tests, by a distance. I paste a component, ask for Testing Library tests covering the empty, loading and error states, and what comes back is mostly right. I would have written those tests anyway, just slowly, and on a Friday afternoon I might have quietly skipped the error state.</p>
<p>It's also good at regex and one-off parsing. Recently I needed meter IDs out of a messy CSV where some rows were quoted and some weren't, and a description plus a quick test on twenty lines got me there far faster than half an hour on regex101. Same with boilerplate: a Zod schema from a JSON sample, a typed fetch wrapper, the fifth form in an app that already has four.</p>
<p>The one I didn't expect is translating between frameworks. I spent a few years on Vue and Nuxt before React and still have Vue snippets lying around. Paste a single-file component and ask for a React function component, and it maps <code>v-model</code> to controlled inputs, <code>computed</code> to <code>useMemo</code> and <code>watch</code> to <code>useEffect</code> reasonably well. I tidy it afterwards, but the shape is right. It's a decent first reader for old code too. At work there's legacy Java nobody has opened in years, and asking what a long class does and what would break if I changed a method gives me a rough map I can then check.</p>
<h2>Where it costs me</h2>
<p>Made-up APIs are rare but expensive, because they look right. I once got <code>revalidate: "on-demand"</code> passed into <code>fetch</code> in a Next.js app. That isn't a thing. It compiled (the types were loose enough), it ran, and it did nothing at all, and I lost a good twenty minutes wondering why my cache wouldn't clear.</p>
<p>Stale Next.js knowledge is the constant one. The models have seen far more Pages Router code than App Router, so <code>getServerSideProps</code> turns up in projects with an <code>app/</code> folder. Next 15 made <code>cookies()</code> and <code>headers()</code> async, and as far as I can tell most models don't know yet, so I've started opening every chat with "Next.js 15, App Router".</p>
<p>Long chats drift as well. Thirty messages in, it forgets the constraint I gave at the start (no new dependencies) or puts back a bug I already fixed. I start fresh chats a lot and paste a short summary at the top, which works but is all manual. And with React state bugs, stale closures, effects firing twice in Strict Mode, it tends to fix the symptom. I mostly use it as a rubber duck there: I ask it to walk through the render order, and I usually spot the problem myself while reading.</p>
<p>The bigger cost is quieter. When I write code myself I know why each line is there, and when I paste sixty lines from a chat I only know it passed the one thing I tried. So I've got a rule that if I can't explain a pasted block to a colleague without looking at it, it doesn't get committed. That eats into the time saved, possibly quite a lot of it.</p>
<p>Overall, on a normal feature it saves me a fair chunk of time, mainly on tests and boilerplate. On a bug in code I don't know, it's roughly break-even. On anything to do with Next.js caching or brand-new APIs it can be worse than nothing. Nowhere near 80%, but a fair chunk every day still adds up.</p>
<p>What I keep wishing for is something that can read the repo and run the tests itself, so I'm not the clipboard in the middle. Cursor's agent mode is heading that way and I keep hearing about tools that live in the terminal. I'll write a follow-up if one of them actually changes how I work.</p>
<hr />
<p><em>First published on <a href="https://blogs.kishankanejiiya.tech/posts/coding-with-ai-early-2025">my blog</a>. Cover photo: Markus Spiske markusspiske, CC0, via Wikimedia Commons (<a href="https://commons.wikimedia.org/wiki/File:Code_on_computer_monitor_(Unsplash).jpg">source</a>).</em></p>
]]></content:encoded></item></channel></rss>