<?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[RagedPanda]]></title><description><![CDATA[RagedPanda]]></description><link>https://ragedpanda.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Mon, 02 Nov 2026 15:59:24 GMT</lastBuildDate><atom:link href="https://ragedpanda.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Adding TOTP 2FA to your app the RFC way (Node.js and Python, no libraries)]]></title><description><![CDATA[I needed authenticator-app 2FA for a project and kept running into the same problem: most tutorials stop at "install a library, call verify()". The parts…]]></description><link>https://ragedpanda.hashnode.dev/adding-totp-2fa-to-your-app-the-rfc-way-node-js-and-python-no-libraries</link><guid isPermaLink="true">https://ragedpanda.hashnode.dev/adding-totp-2fa-to-your-app-the-rfc-way-node-js-and-python-no-libraries</guid><category><![CDATA[Security]]></category><category><![CDATA[Node.js]]></category><category><![CDATA[Python]]></category><category><![CDATA[2FA]]></category><dc:creator><![CDATA[Ankur Dubey]]></dc:creator><pubDate>Tue, 27 Oct 2026 06:53:32 GMT</pubDate><content:encoded><![CDATA[<p>I needed authenticator-app 2FA for a project and kept running into the same problem: most tutorials stop at "install a library, call <code>verify()</code>". The parts that actually decide whether your 2FA is secure — the verification window, rejecting reused codes, rate limiting, recovery — get a sentence or nothing.</p>
<p>So I wrote down how to do it properly, following RFC 4226 (HOTP) and RFC 6238 (TOTP), with complete Node.js and Python code that uses only the standard library and passes the RFC test vectors.</p>
<p>While building it I also made a small browser tool, <a href="https://ragedpanda.github.io/otp-qr/">OTP QR Studio</a> (<a href="https://github.com/RagedPanda/otp-qr">source</a>), that turns a secret into an <code>otpauth://</code> QR code and then checks the code your authenticator app shows. It's handy for checking your server's codes against a real app. It runs entirely in the browser and makes no network requests.</p>
<h2>How it works</h2>
<p>The server and the user's app share one secret key. Neither side sends the code anywhere except when the user types it in.</p>
<pre><code>HOTP(K, C) = Truncate(HMAC-SHA1(K, C)) mod 10^digits      RFC 4226 section 5.3
TOTP(K)    = HOTP(K, floor(unix_time / 30))                RFC 6238 section 4
</code></pre>
<ol>
<li><strong>HMAC</strong> the counter <code>C</code>, written as 8 bytes big-endian, with the key <code>K</code>.</li>
<li><strong>Dynamic truncation</strong>: take the low 4 bits of the last HMAC byte as an offset, read 4 bytes from that offset as a big-endian integer, and clear the top bit.</li>
<li><strong>Reduce</strong> it modulo 10^6 and left-pad with zeros to 6 digits.</li>
</ol>
<p>For TOTP the counter is the number of 30-second steps since the Unix epoch (RFC 6238 calls them <code>X = 30</code> and <code>T0 = 0</code>). Both sides compute the same number as long as their clocks roughly agree.</p>
<h2>The settings to use</h2>
<table>
<thead>
<tr>
<th>Setting</th>
<th>Use</th>
<th>Why</th>
</tr>
</thead>
<tbody><tr>
<td>Type</td>
<td>TOTP</td>
<td>Codes expire on their own, and there's no counter to keep in sync.</td>
</tr>
<tr>
<td>Algorithm</td>
<td>SHA1</td>
<td>Google Authenticator and several other apps ignore the <code>algorithm</code> parameter and always use SHA1. HMAC-SHA1 is still sound for this use; the known SHA1 collision attacks don't apply to HMAC.</td>
</tr>
<tr>
<td>Digits</td>
<td>6</td>
<td>The value every app supports.</td>
</tr>
<tr>
<td>Period</td>
<td>30 seconds</td>
<td>RFC 6238's recommended default, and the only value some apps support.</td>
</tr>
<tr>
<td>Secret</td>
<td>20 random bytes (160 bits)</td>
<td>RFC 4226 section 4 requires at least 128 bits and recommends 160.</td>
</tr>
</tbody></table>
<p>Only change these if you control the app as well. If you do use SHA256 or SHA512, use a secret as long as the hash output (32 or 64 bytes).</p>
<h2>Enrolment</h2>
<pre><code>User                         Your server                           Database
 |  "Turn on 2FA"  -------&gt;  generate 20-byte secret (CSPRNG)
 |                           store it as *pending*  -------------&gt; otp_secret_pending
 |  &lt;-- QR + secret text --  build otpauth:// URI, render QR
 |  scans, types code ----&gt;  verify code against the pending secret
 |                           on success: move pending -&gt; active --&gt; otp_secret, otp_enabled_at
 |  &lt;-- recovery codes ----  generate, store hashed  ------------&gt; recovery_codes
</code></pre>
<ol>
<li><strong>Generate the secret</strong> on the server with a cryptographically secure random generator (<code>crypto.randomBytes</code>, <code>secrets.token_bytes</code>, <code>SecureRandom</code>, <code>random_bytes</code>), never <code>Math.random</code> or <code>random.random</code>. Base32-encode it for the user.</li>
<li><strong>Store it encrypted.</strong> The server needs the secret itself to compute codes, so it can't be hashed like a password. Encrypt it with a key kept outside the database (a KMS, or an environment secret), so a database dump alone doesn't hand out every user's second factor. Keep it as <em>pending</em> until step 5.</li>
<li><strong>Build the <code>otpauth://</code> URI</strong> and show it as a QR code:<pre><code>otpauth://totp/Acme%20Inc:alice%40example.com?secret=JBSWY3DPEHPK3PXP&amp;issuer=Acme%20Inc
</code></pre>
<ul>
<li>The label is <code>issuer:account</code>, each part percent-encoded. The colon between them stays as is.</li>
<li>Always set the <code>issuer</code> parameter as well as the label prefix. Apps use it to group and name the entry.</li>
<li>Leave out <code>algorithm</code>, <code>digits</code> and <code>period</code> when they are the defaults (SHA1, 6, 30). Some apps reject URIs that set them even to the default values.</li>
<li>Render the QR on the server or in the page's own code. Don't send the URI to a third-party QR image service: the secret is in it.</li>
</ul>
</li>
<li><strong>Show the secret as text too</strong>, grouped in fours (<code>JBSW Y3DP EHPK 3PXP</code>), for users who can't scan.</li>
<li><strong>Confirm before enabling.</strong> Ask for the current code and verify it against the pending secret. Only then make it the active secret. This catches a failed scan, an app that ignored a setting, or a phone with the wrong time, before the user is locked out.</li>
<li><strong>Issue recovery codes</strong> (see Recovery and account management, below).</li>
<li>Send the user an email saying 2FA was turned on.</li>
</ol>
<p>Never show the QR or secret again after enrolment. To move to a new phone, the user re-enrols, which creates a new secret.</p>
<h2>Login</h2>
<ol>
<li>Check the password first, as usual. Don't say whether the password was right until the second factor is done, or say it the same way either way, so an attacker can't use the 2FA prompt to confirm a password.</li>
<li><strong>Rate-limit</strong> the code check per account, not only per IP address (RFC 4226 section 7.3). A 6-digit code has a million values and the window below accepts three of them, so an unthrottled attacker gets in after about 330,000 guesses on average. Five failures, then a delay or a lock that grows with each further failure, is typical.</li>
<li><strong>Accept a small window.</strong> Allow the previous and the next time step as well as the current one, to cover clock drift and the time the user takes to type (RFC 6238 section 5.2). One step either side, ±30 seconds, is the usual value. Don't go wider without a reason: every extra step is another valid code for an attacker to guess.</li>
<li><strong>Reject reused codes.</strong> RFC 6238 section 5.2 says the verifier must not accept the second attempt of a code that was already accepted. Store the time step of the last code that succeeded and only accept a step greater than it. Do the check and the update atomically (one <code>UPDATE ... WHERE last_used_step &lt; ?</code> or a row lock), so two simultaneous requests with the same code can't both pass.</li>
<li><strong>Compare in constant time</strong> (<code>crypto.timingSafeEqual</code>, <code>hmac.compare_digest</code>) so response timing doesn't leak how many leading digits were right. The loop in the examples stops at the first step that matches, so timing can still hint at <em>which</em> step matched. That only tells an attacker about a code that was already correct, so it gives nothing away, but you can check every step before returning if you'd rather not have the difference.</li>
<li>Treat the code as a string. Strip spaces, and keep leading zeros: <code>012345</code> is not <code>12345</code>.</li>
</ol>
<p>If the matched step is the previous or next one, the user's clock is off. You can store the offset and centre the next check on it (RFC 6238 section 6), but with NTP-synced phones the ±1 window is usually enough. Keep your own servers on NTP too: a server clock that drifts locks everyone out.</p>
<h2>Code: Node.js</h2>
<p><code>otp.mjs</code>, using only <code>node:crypto</code>:</p>
<pre><code class="language-js">import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';

const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

export function base32Encode(bytes) {
  let bits = 0;
  let value = 0;
  let output = '';
  for (const byte of bytes) {
    value = (value &lt;&lt; 8) | byte;
    bits += 8;
    while (bits &gt;= 5) {
      output += ALPHABET[(value &gt;&gt;&gt; (bits - 5)) &amp; 31];
      bits -= 5;
    }
  }
  if (bits &gt; 0) output += ALPHABET[(value &lt;&lt; (5 - bits)) &amp; 31];
  return output;
}

export function base32Decode(text) {
  const bytes = [];
  let bits = 0;
  let value = 0;
  for (const char of text.replace(/[\s=-]/g, '').toUpperCase()) {
    const index = ALPHABET.indexOf(char);
    if (index === -1) throw new Error('Invalid Base32 secret');
    value = ((value &lt;&lt; 5) | index) &amp; 0xffff;
    bits += 5;
    if (bits &gt;= 8) {
      bytes.push((value &gt;&gt;&gt; (bits - 8)) &amp; 0xff);
      bits -= 8;
    }
  }
  return Buffer.from(bytes);
}

// RFC 4226 section 4: 160-bit secret from a CSPRNG.
export const generateSecret = () =&gt; base32Encode(randomBytes(20));

// RFC 4226 section 5.3: HMAC the 8-byte counter, then dynamic truncation.
export function hotp(key, counter, digits = 6, algorithm = 'sha1') {
  const message = Buffer.alloc(8);
  message.writeBigUInt64BE(BigInt(counter));
  const hmac = createHmac(algorithm, key).update(message).digest();
  const offset = hmac[hmac.length - 1] &amp; 0x0f;
  const binary = hmac.readUInt32BE(offset) &amp; 0x7fffffff;
  return String(binary % 10 ** digits).padStart(digits, '0');
}

// RFC 6238 section 4: the counter is the number of 30-second steps since the Unix epoch.
export const timeStep = (nowMs = Date.now(), period = 30) =&gt; Math.floor(nowMs / 1000 / period);

const sameCode = (a, b) =&gt; a.length === b.length &amp;&amp; timingSafeEqual(Buffer.from(a), Buffer.from(b));

/**
 * Returns the matched time step, or null. Store the step and pass it back as lastUsedStep
 * so the same code can't be used twice (RFC 6238 section 5.2).
 */
export function verifyTotp(key, code, lastUsedStep, { nowMs = Date.now(), period = 30, window = 1 } = {}) {
  const current = timeStep(nowMs, period);
  for (let step = current - window; step &lt;= current + window; step++) {
    if (step &gt; lastUsedStep &amp;&amp; sameCode(hotp(key, step), code)) return step;
  }
  return null;
}

export function otpauthUri({ issuer, account, secret }) {
  const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(account)}`;
  // Percent-encode with encodeURIComponent, not URLSearchParams: the Key URI Format wants
  // spaces as %20, and URLSearchParams writes them as +, which some apps show literally.
  return `otpauth://totp/${label}?secret=${secret}&amp;issuer=${encodeURIComponent(issuer)}`;
}
</code></pre>
<p>Using it:</p>
<pre><code class="language-js">import { base32Decode, generateSecret, otpauthUri, verifyTotp } from './otp.mjs';

// Enrolment
const secret = generateSecret();                       // store encrypted, as pending
const uri = otpauthUri({ issuer: 'Acme Inc', account: user.email, secret });
// render `uri` as a QR code, show `secret` as text, then verify a first code

// Login, after the password check and the rate limit
const step = verifyTotp(base32Decode(user.otpSecret), submittedCode.replace(/\s/g, ''), user.lastUsedStep);
if (step === null) return reject();
await db.query(
  'UPDATE users SET last_used_step = $1 WHERE id = $2 AND last_used_step &lt; $1',
  [step, user.id],
); // zero rows updated means another request used this code first: reject
</code></pre>
<p>Start <code>lastUsedStep</code> at <code>-1</code> for a newly enrolled user, or at the step of the enrolment code so that code can't be used to log in.</p>
<h2>Code: Python</h2>
<p><code>otp.py</code>, standard library only. It needs Python 3.10 or later for the <code>int | None</code> type hints.</p>
<pre><code class="language-python">import base64
import hashlib
import hmac
import secrets
import struct
import time
from urllib.parse import quote


def generate_secret(num_bytes: int = 20) -&gt; str:
    """RFC 4226 section 4: 160-bit secret from a CSPRNG, Base32 without padding."""
    return base64.b32encode(secrets.token_bytes(num_bytes)).decode().rstrip("=")


def decode_secret(secret: str) -&gt; bytes:
    cleaned = "".join(secret.split()).replace("-", "").rstrip("=").upper()
    return base64.b32decode(cleaned + "=" * (-len(cleaned) % 8))


def hotp(key: bytes, counter: int, digits: int = 6, digest=hashlib.sha1) -&gt; str:
    """RFC 4226 section 5.3: HMAC the 8-byte counter, then dynamic truncation."""
    mac = hmac.new(key, struct.pack("&gt;Q", counter), digest).digest()
    offset = mac[-1] &amp; 0x0F
    binary = struct.unpack("&gt;I", mac[offset:offset + 4])[0] &amp; 0x7FFFFFFF
    return str(binary % 10**digits).zfill(digits)


def time_step(now: float | None = None, period: int = 30) -&gt; int:
    """RFC 6238 section 4: number of 30-second steps since the Unix epoch."""
    return int((time.time() if now is None else now) // period)


def verify_totp(key: bytes, code: str, last_used_step: int, now: float | None = None,
                period: int = 30, window: int = 1) -&gt; int | None:
    """Return the matched time step, or None. Store it as last_used_step to block replays."""
    current = time_step(now, period)
    for step in range(current - window, current + window + 1):
        if step &gt; last_used_step and hmac.compare_digest(hotp(key, step), code):
            return step
    return None


def otpauth_uri(issuer: str, account: str, secret: str) -&gt; str:
    label = f"{quote(issuer)}:{quote(account)}"
    return f"otpauth://totp/{label}?secret={secret}&amp;issuer={quote(issuer)}"
</code></pre>
<p><code>hmac.compare_digest</code> is the constant-time comparison. <code>secrets</code> is the CSPRNG; don't use <code>random</code>.</p>
<h2>HOTP (counter-based)</h2>
<p>Use HOTP only when the device has no clock, such as some hardware tokens. Each press of the button on the token moves its counter forward, and your server has to keep up:</p>
<ul>
<li><strong>Look ahead.</strong> Users press the button without logging in, so the token gets ahead of the server. Check the next few counter values (RFC 4226 section 7.4 calls this the look-ahead window <code>s</code>). 10 is a common value.</li>
<li><strong>Resync.</strong> When a code matches at counter <code>c</code>, store <code>c + 1</code> as the next counter. That also stops replay, because no older counter is ever checked again.</li>
<li><strong>Don't look behind.</strong> Codes for counters below the stored one have either been used or skipped, and accepting them would allow replay.</li>
<li>The look-ahead window adds valid codes the same way the TOTP window does, so keep it small and keep the rate limit.</li>
</ul>
<pre><code class="language-js">// Returns the next counter to store, or null.
function verifyHotp(key, code, counter, lookAhead = 10) {
  for (let candidate = counter; candidate &lt;= counter + lookAhead; candidate++) {
    if (sameCode(hotp(key, candidate), code)) return candidate + 1;
  }
  return null;
}
</code></pre>
<p>If the token gets further ahead than the window, RFC 4226 section 7.4 suggests asking for two or three consecutive codes and searching a wider range for that sequence.</p>
<p>The URI uses <code>hotp</code> and must include the starting counter: <code>otpauth://hotp/Acme:alice?secret=...&amp;issuer=Acme&amp;counter=0</code>.</p>
<h2>Recovery and account management</h2>
<p>Phones get lost. Without a recovery path, 2FA becomes a support queue or a permanent lockout.</p>
<ul>
<li><strong>Recovery codes.</strong> At enrolment, generate about 10 single-use codes, each with at least 50 bits of randomness (for example 10 characters from a 32-character alphabet, shown as <code>k7mq-x2fp-9d</code>). Show them once, tell the user to save them, and store only their hashes, like passwords. Accept one in place of a TOTP code, mark it used, and count it against the same rate limit. Let the user regenerate the set, which invalidates the old one.</li>
<li><strong>Re-authenticate for changes.</strong> Ask for the password and a current code (or a recovery code) before turning 2FA off, re-enrolling, or regenerating recovery codes.</li>
<li><strong>Notify.</strong> Email the user whenever 2FA is turned on or off, re-enrolled, or a recovery code is used.</li>
<li><strong>Support resets</strong> are the weakest point of the whole scheme. Decide in advance what proof you need before support removes a user's 2FA, and log every reset.</li>
<li><strong>Remember this device</strong>, if you offer it, should set a signed, expiring, per-device token. Don't skip 2FA based on IP address.</li>
</ul>
<h2>Security checklist</h2>
<ul>
<li><input disabled type="checkbox" /> Secret from a CSPRNG, at least 128 bits, 160 recommended (RFC 4226 section 4)</li>
<li><input disabled type="checkbox" /> Secret encrypted at rest, with the key outside the database</li>
<li><input disabled type="checkbox" /> QR code rendered on your own server or page, never by a third-party service</li>
<li><input disabled type="checkbox" /> 2FA only enabled after the user enters a correct code</li>
<li><input disabled type="checkbox" /> Window of ±1 time step, no wider (RFC 6238 section 5.2)</li>
<li><input disabled type="checkbox" /> Each code accepted at most once, with an atomic check and update (RFC 6238 section 5.2)</li>
<li><input disabled type="checkbox" /> Rate limit and lockout per account (RFC 4226 section 7.3)</li>
<li><input disabled type="checkbox" /> Constant-time comparison</li>
<li><input disabled type="checkbox" /> Codes handled as strings, leading zeros kept</li>
<li><input disabled type="checkbox" /> Server clock synced with NTP</li>
<li><input disabled type="checkbox" /> Hashed, single-use recovery codes</li>
<li><input disabled type="checkbox" /> Re-authentication and email notice for every 2FA change</li>
<li><input disabled type="checkbox" /> Secrets and codes kept out of logs, error reports and analytics</li>
</ul>
<h2>Testing your implementation</h2>
<p><strong>RFC test vectors.</strong> The secret is the ASCII string <code>12345678901234567890</code> (Base32 <code>GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ</code>).</p>
<p>HOTP, 6 digits, from RFC 4226 appendix D:</p>
<table>
<thead>
<tr>
<th>Counter</th>
<th>Code</th>
</tr>
</thead>
<tbody><tr>
<td>0</td>
<td>755224</td>
</tr>
<tr>
<td>1</td>
<td>287082</td>
</tr>
<tr>
<td>2</td>
<td>359152</td>
</tr>
<tr>
<td>3</td>
<td>969429</td>
</tr>
<tr>
<td>4</td>
<td>338314</td>
</tr>
<tr>
<td>5</td>
<td>254676</td>
</tr>
<tr>
<td>6</td>
<td>287922</td>
</tr>
<tr>
<td>7</td>
<td>162583</td>
</tr>
<tr>
<td>8</td>
<td>399871</td>
</tr>
<tr>
<td>9</td>
<td>520489</td>
</tr>
</tbody></table>
<p>TOTP, SHA1, 8 digits, 30-second period, from RFC 6238 appendix B:</p>
<table>
<thead>
<tr>
<th>Unix time</th>
<th>Code</th>
</tr>
</thead>
<tbody><tr>
<td>59</td>
<td>94287082</td>
</tr>
<tr>
<td>1111111109</td>
<td>07081804</td>
</tr>
<tr>
<td>1111111111</td>
<td>14050471</td>
</tr>
<tr>
<td>1234567890</td>
<td>89005924</td>
</tr>
<tr>
<td>2000000000</td>
<td>69279037</td>
</tr>
<tr>
<td>20000000000</td>
<td>65353130</td>
</tr>
</tbody></table>
<p>The SHA256 and SHA512 vectors use longer keys: the same digits repeated to 32 and 64 bytes. At time 59 they give <code>46119246</code> and <code>90693936</code>. The last row (year 2603) checks that your counter doesn't overflow 32 bits.</p>
<p><strong>Against a real app.</strong> Generate a secret on your server, paste it into <a href="https://ragedpanda.github.io/otp-qr/">OTP QR Studio</a> and scan the QR with the authenticator app you expect users to use. The code your server accepts, the app's code and the page's expected code should all agree. If the app's code differs from both, the app ignored one of your settings (see The settings to use, above).</p>
<p><strong>Tests to write:</strong></p>
<ul>
<li>The current, previous and next step are accepted; two steps away is rejected.</li>
<li>The same code is rejected the second time, including two requests at the same moment.</li>
<li>A code with a leading zero is accepted.</li>
<li>After five wrong codes, even the correct code is refused until the lock ends.</li>
<li>A used recovery code can't be used again.</li>
</ul>
<hr />
<p><em>This guide also lives in the repo at <a href="https://github.com/RagedPanda/otp-qr/blob/main/docs/2fa-integration-guide.md">docs/2fa-integration-guide.md</a>. Corrections and issues welcome there.</em></p>
]]></content:encoded></item><item><title><![CDATA[Stop fixing GitHub issues someone already fixed]]></title><description><![CDATA[You find an issue that looks open. No assignee, no linked PR, a clear bug report. You spend an evening on a fix, open a PR, and then learn that someone…]]></description><link>https://ragedpanda.hashnode.dev/stop-fixing-github-issues-someone-already-fixed</link><guid isPermaLink="true">https://ragedpanda.hashnode.dev/stop-fixing-github-issues-someone-already-fixed</guid><category><![CDATA[Open Source]]></category><category><![CDATA[GitHub]]></category><category><![CDATA[golang]]></category><category><![CDATA[cli]]></category><dc:creator><![CDATA[Ankur Dubey]]></dc:creator><pubDate>Thu, 22 Oct 2026 19:45:10 GMT</pubDate><content:encoded><![CDATA[<p>You find an issue that looks open. No assignee, no linked PR, a clear bug report. You spend an evening on a fix, open a PR, and then learn that someone else already has one waiting in review.</p>
<p>This happens a lot, and it isn't carelessness. A GitHub issue page doesn't reliably tell you whether anyone is working on the issue. It misses that work in at least five ways.</p>
<h3>1. The PR never links to the issue</h3>
<p>An issue page lists a PR only if the PR says "Fixes #80" or the author links it by hand. Many PRs do neither. The issue looks untouched while a fix sits in review.</p>
<h3>2. The project moved</h3>
<p>Libraries often move into a monorepo or a new org. The old issue stays open, the fixes are opened in the new repository, and the old issue page can't see them.</p>
<h3>3. Claims live in the comments</h3>
<p>People write "I'd like to work on this" or <code>/assign</code> without ever being assigned. You only find out by reading the whole thread.</p>
<h3>4. The maintainers already said no</h3>
<p>Somewhere in the thread there's "won't fix", "duplicate of #…", or a label like <code>needs discussion</code> that blocks outside work for now.</p>
<h3>5. The project doesn't take outside PRs</h3>
<p>The issue is free, but nobody outside the core team has had a PR merged in a year. Your fix will sit there.</p>
<h3>A real example</h3>
<p><a href="https://github.com/date-fns/tz/issues/80">date-fns/tz#80</a>: <code>tzOffset</code> gets the sign wrong for offsets between -01:00 and 00:00. On the issue page it looks open and free. In fact:</p>
<ul>
<li>the project has moved to <code>date-fns/date-fns</code>;</li>
<li>two open PRs there already fix it (<a href="https://github.com/date-fns/date-fns/pull/4314">#4314</a> and <a href="https://github.com/date-fns/date-fns/pull/4279">#4279</a>), and neither mentions issue #80;</li>
<li>the last outside PR was merged 14 months ago.</li>
</ul>
<p>None of that shows up on the issue page. Checking by hand means searching PRs in two repositories, reading every comment and looking at the merge history, for every issue you're considering. Most people skip it.</p>
<h3>Automating the check</h3>
<p>I wrote <a href="https://github.com/RagedPanda/gh-claimcheck">gh-claimcheck</a>, a GitHub CLI extension that runs these checks and gives a verdict: <strong>GO</strong>, <strong>CAUTION</strong> or <strong>SKIP</strong>.</p>
<pre><code>$ gh claimcheck date-fns/tz#80
! An open PR looks like the same fix
  date-fns/date-fns#4314: Fix tzOffset returning the wrong sign for sub-hour western offsets
! An open PR looks like the same fix
  date-fns/date-fns#4279: fix(tz): correct tzOffset sign for negative offsets of less than an...
! Project has moved to date-fns/date-fns
! The last outside PR was merged 14 months ago
✓ Issue is open
✓ Nobody is assigned

CAUTION An open PR looks like the same fix
</code></pre>
<p>It looks at linked and referencing PRs, PRs with similar titles (ranked by how close they are), "moved to" notices in the README, claims in comments, maintainer pushback, blocking and welcoming labels, who wrote the last 40 merged PRs, and the project's contributing guide and AI policy. Your own claims and PRs never count against you.</p>
<p>It's read-only: it never comments, assigns or opens anything, and it uses your existing <code>gh</code> login.</p>
<h3>Finding issues, not just checking them</h3>
<p>Two commands go the other way and look for issues that pass:</p>
<pre><code># Beginner-friendly Go issues that come out GO
gh claimcheck find --good-first-issue --lang go --go-only

# Open, unclaimed issues in the libraries your project already uses
gh claimcheck deps --check
</code></pre>
<p><code>deps</code> reads your manifests and lock files (npm, PyPI, Go modules, Cargo, Maven/Gradle, RubyGems, Composer, pub, Hex, NuGet, Swift, GitHub Actions, Docker and more), finds each library's GitHub repository, and searches them. This is often the best place to contribute: you already know the library, and you can test the fix in your own app.</p>
<h3>Limits</h3>
<p>Claim and pushback detection read plain-English comments, and title matching is fuzzy. A CAUTION means "go and look", not "don't". When in doubt, ask on the issue; maintainers almost always prefer that.</p>
<h3>Try it</h3>
<pre><code>gh extension install RagedPanda/gh-claimcheck
gh claimcheck &lt;issue-url&gt;
</code></pre>
<p>It's MIT licensed, written in Go, with prebuilt binaries for macOS, Linux and Windows. Issues and PRs are welcome, and false positives are especially useful to report.</p>
]]></content:encoded></item></channel></rss>