<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: szp2005</title>
    <description>The latest articles on DEV Community by szp2005 (@szp2005).</description>
    <link>https://dev.to/szp2005</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3954570%2Fc95d8429-edc0-4742-aef7-a8a16b1e0bdb.png</url>
      <title>DEV Community: szp2005</title>
      <link>https://dev.to/szp2005</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/szp2005"/>
    <language>en</language>
    <item>
      <title>Our IP score knows this is iCloud Private Relay and still rates it like a paid VPN</title>
      <dc:creator>szp2005</dc:creator>
      <pubDate>Wed, 30 Sep 2026 02:08:02 +0000</pubDate>
      <link>https://dev.to/szp2005/our-ip-score-knows-this-is-icloud-private-relay-and-still-rates-it-like-a-paid-vpn-26ei</link>
      <guid>https://dev.to/szp2005/our-ip-score-knows-this-is-icloud-private-relay-and-still-rates-it-like-a-paid-vpn-26ei</guid>
      <description>&lt;p&gt;Paste 104.28.28.5 into our IP checker and you get 65 out of 100, amber, with a &lt;code&gt;relay&lt;/code&gt; tag sitting next to &lt;code&gt;vpn&lt;/code&gt; and &lt;code&gt;proxy&lt;/code&gt;. Paste a Mullvad WireGuard exit in Atlanta and you get the same score. One is an Apple egress carrying ordinary Safari traffic from people who flipped a switch in their iCloud settings. The other is a node on a VPN people pay for.&lt;/p&gt;

&lt;p&gt;It looks like a bug. We looked at it as one, and then decided to keep it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the vendors actually return
&lt;/h2&gt;

&lt;p&gt;Our score is a weighted average across a set of reputation sources, plus hard floors. The floor that matters here: if enough dedicated sources vote proxy or VPN, the score can't drop below 65. "Dedicated" means everyone except ip-api, whose flags are coarse booleans. For an address that already sits in a datacenter, "enough" means two votes, because a lone proxy flag on cloud space is usually one vendor painting a whole range.&lt;/p&gt;

&lt;p&gt;On the Apple address, three sources voted: Scamalytics, proxycheck and vpnapi.io. The odd part is what two of them said next to the vote. Scamalytics returned &lt;code&gt;is_apple_icloud_private_relay: true&lt;/code&gt; and flagged the IP as a VPN in the same response. vpnapi.io returned &lt;code&gt;relay: true&lt;/code&gt; and &lt;code&gt;vpn: true&lt;/code&gt; together. So they know exactly what this address is, and they still call it a VPN.&lt;/p&gt;

&lt;p&gt;You can check it yourself, no key needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s1"&gt;'https://ipok.io/api/ip?ip=104.28.28.5'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="s1"&gt;'{risk, signals, evidence, floors: .riskBreakdown.floors}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The weighted average of the individual source scores comes out around 30. The floor is what drags it up to 65.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we tried
&lt;/h2&gt;

&lt;p&gt;The first fix was the obvious one. We already detect relay: Apple publishes its egress ranges and we load them into our offline list, and IPHub and vpnapi.io both expose a relay field. So exempt relay IPs from the VPN floor and move on.&lt;/p&gt;

&lt;p&gt;We shipped half of that. Relay now exempts an IP from the hosting floor of 35, which exists because datacenter traffic gets treated with more suspicion even when nothing specific is wrong with the address. That floor was plainly wrong for Private Relay. The exit is in Cloudflare's address space, but there's a normal person behind every connection. Our own offline list also stopped casting a VPN vote on those ranges.&lt;/p&gt;

&lt;p&gt;Dropping the 65 was a different question. The score isn't a verdict on whether the person behind the IP is up to something. It's a forecast of how other services will treat the address. Any site that asks one of those same vendors gets "VPN" back, relay or not. If we removed the floor, we'd be overruling three vendors whose verdicts other sites may well be reading. The score would look friendlier and predict worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we do now
&lt;/h2&gt;

&lt;p&gt;The decision sits in the code, right next to the check, so nobody "fixes" it later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isRelay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;relay&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// Relay only exempts the hosting floor, NOT the 65 proxyVpn floor.&lt;/span&gt;
&lt;span class="c1"&gt;// The score describes how the outside world treats this IP,&lt;/span&gt;
&lt;span class="c1"&gt;// and many services really do block iCloud Private Relay.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hosting&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;hasTrustedWhitelist&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isRelay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;floors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hosting&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;35&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;contextual&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The comment is translated; the original is in Chinese.)&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;relay&lt;/code&gt; tag in the result is the explanation, and the methodology page has a paragraph saying the same thing in plain language.&lt;/p&gt;

&lt;p&gt;This has a cost. The headline number can't tell "you turned on an Apple privacy feature" apart from "you're on a paid VPN". If that difference matters to you, you have to look for &lt;code&gt;relay&lt;/code&gt; in &lt;code&gt;signals&lt;/code&gt;, or open the per-source raw fields and read Scamalytics' relay boolean yourself. We accepted that. The alternative is a score that disagrees with the vendors doing the actual blocking, which would make it wrong about the one thing it is supposed to predict.&lt;/p&gt;

&lt;p&gt;If you run any kind of reputation scoring, it's worth deciding which of the two your number is: a judgment about the user, or a prediction of how others will judge them. We went with the prediction, and Private Relay is where that choice feels worst to explain to a user.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I built &lt;a href="https://ipok.io" rel="noopener noreferrer"&gt;ipok&lt;/a&gt;, the checker used in the examples above.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>networking</category>
      <category>privacy</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Fable 5.1 costs twice as much as Opus 5, until your cache gets big enough</title>
      <dc:creator>szp2005</dc:creator>
      <pubDate>Mon, 28 Sep 2026 02:10:53 +0000</pubDate>
      <link>https://dev.to/szp2005/fable-51-costs-twice-as-much-as-opus-5-until-your-cache-gets-big-enough-4nd1</link>
      <guid>https://dev.to/szp2005/fable-51-costs-twice-as-much-as-opus-5-until-your-cache-gets-big-enough-4nd1</guid>
      <description>&lt;p&gt;Claude Fable 5.1 shipped on September 1 with the same $10 input / $50 output per million tokens as Fable 5. One number moved: cache reads dropped from $1 to $0.25 per million.&lt;/p&gt;

&lt;p&gt;On the price sheet it still costs twice what Opus 5 does ($5 / $25). But look at the cache-read column and the order flips. Fable 5.1 charges $0.25 there, Opus 5 charges $0.50. So which one is cheaper depends on how much of each request is served from cache, and I wanted the actual crossover instead of a vibe.&lt;/p&gt;

&lt;h2&gt;
  
  
  A request bill has three parts
&lt;/h2&gt;

&lt;p&gt;Cached tokens times the cache-read price, fresh input times the input price, output times the output price. Fable 5.1 only changed the first term, so the saving over Fable 5 is exactly as big as that term's share of your bill.&lt;/p&gt;

&lt;p&gt;Two workloads I priced out from the table on my site:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Per request&lt;/th&gt;
&lt;th&gt;Fable 5&lt;/th&gt;
&lt;th&gt;Fable 5.1&lt;/th&gt;
&lt;th&gt;Opus 5&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Agent: 100K cached, 2K new input, 1K output&lt;/td&gt;
&lt;td&gt;$0.170&lt;/td&gt;
&lt;td&gt;$0.095&lt;/td&gt;
&lt;td&gt;$0.085&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chat: 5K cached, 1K new input, 800 output&lt;/td&gt;
&lt;td&gt;$0.055&lt;/td&gt;
&lt;td&gt;$0.051&lt;/td&gt;
&lt;td&gt;$0.028&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In the agent loop, cache reads are about 60% of the Fable 5 bill, so the price cut takes 44% off each request. That lines up with the "around 45% for heavy agent use" figure in the launch coverage.&lt;/p&gt;

&lt;p&gt;The chat case barely moves. Output dominates, and Opus 5 stays roughly half the price. Switching there is just paying more.&lt;/p&gt;

&lt;h2&gt;
  
  
  The crossover
&lt;/h2&gt;

&lt;p&gt;Fable 5.1 pays double for fresh input and output, and half for cache reads. Set the two bills equal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0.25 * R = 5 * U + 25 * O
R = 20 * U + 100 * O
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;R is cached tokens per request, U is fresh input, O is output. Plug in the agent numbers above and the line sits at 140K cached tokens. Both models cost $0.105 a request there.&lt;/p&gt;

&lt;p&gt;Past that point the gap keeps widening. At 200K cached, Fable 5.1 is about 11% cheaper. Double the cache again and it's more than a quarter cheaper.&lt;/p&gt;

&lt;p&gt;Output length is what pushes the line out. Every extra thousand output tokens needs another hundred thousand cached tokens before Fable 5.1 catches up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I got wrong the first time
&lt;/h2&gt;

&lt;p&gt;My first pass stopped there and the conclusion was "long-context agents should move to Fable 5.1." Then I remembered that tokens have to get into the cache before they can be read from it.&lt;/p&gt;

&lt;p&gt;Anthropic bills a 5-minute cache write at 1.25x the input price for Opus 5. My price table has no separate write price for Fable 5.1. If it follows the same 1.25x rule, writing a 200K prefix costs $1.25 more on Fable 5.1 than on Opus 5. At 200K cached, Fable 5.1 saves $0.015 per request. That's more than 80 requests on the same prefix before the write premium is paid back.&lt;/p&gt;

&lt;p&gt;So if your prefix expires every five minutes, or your agent keeps rewriting its context, you may never reach the crossover at all. Measure how many requests actually reuse each prefix before you switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I'd decide
&lt;/h2&gt;

&lt;p&gt;Pull three averages from your logs: cached tokens, fresh input and output per request. Then check how many requests reuse the same prefix.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Below &lt;code&gt;20U + 100O&lt;/code&gt; cached tokens: stay on Opus 5.&lt;/li&gt;
&lt;li&gt;Above it, but prefixes get rebuilt every few dozen requests: the write premium eats the difference. Stay on Opus 5.&lt;/li&gt;
&lt;li&gt;Above it, with one long-lived prefix (a coding agent in a long session, Q&amp;amp;A over one big fixed document): Fable 5.1 wins per request, and it scores 66 vs 63 on the quality index I track.&lt;/li&gt;
&lt;li&gt;Already on Fable 5: switch. Nothing got more expensive.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I built llmabacus.com, the LLM API price table these numbers come from. The full write-up, in Chinese, is at &lt;a href="https://www.llmabacus.com/articles/fable-5-1-cache-read-vs-opus-5" rel="noopener noreferrer"&gt;https://www.llmabacus.com/articles/fable-5-1-cache-read-vs-opus-5&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
    <item>
      <title>CoreText said 59 points, the Chinese caption needed 71, and the export "succeeded" blank</title>
      <dc:creator>szp2005</dc:creator>
      <pubDate>Fri, 25 Sep 2026 02:12:44 +0000</pubDate>
      <link>https://dev.to/szp2005/coretext-said-59-points-the-chinese-caption-needed-71-and-the-export-succeeded-blank-3goi</link>
      <guid>https://dev.to/szp2005/coretext-said-59-points-the-chinese-caption-needed-71-and-the-export-succeeded-blank-3goi</guid>
      <description>&lt;p&gt;The export finished. The file played and the size looked right. There was not a single caption on screen.&lt;/p&gt;

&lt;p&gt;That's what my iOS caption-burning app did the day I tried to make Chinese App Store screenshots. Every Chinese caption came out blank. English was fine, which is why nobody had noticed. No API returned an error, and &lt;code&gt;CGContext.makeImage()&lt;/code&gt; happily returned a non-nil image. It was fully transparent.&lt;/p&gt;

&lt;p&gt;It was two separate bugs with the same symptom, and both only show up if you look at pixels.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the captions get into the video
&lt;/h2&gt;

&lt;p&gt;Captions are drawn with CoreText into a &lt;code&gt;CGImage&lt;/code&gt;, put on a &lt;code&gt;CALayer&lt;/code&gt;, and composited over the video with &lt;code&gt;AVVideoCompositionCoreAnimationTool&lt;/code&gt;. Each caption layer starts at &lt;code&gt;opacity = 0&lt;/code&gt; and gets a discrete keyframe animation that switches it on and off at the segment's start and end times. Both steps can fail silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: CoreText measured Chinese text too short
&lt;/h2&gt;

&lt;p&gt;To size the bitmap, the old code asked CoreText how tall the text would be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CTFramesetterSuggestFrameSizeWithConstraints&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;framesetter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;CFRangeMake&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;CGSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;greatestFiniteMagnitude&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;fitRange&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a 57.6pt Chinese caption (no spaces anywhere), it suggested 59 points. Actually laying out the same string needed 71. We built a &lt;code&gt;CTFrame&lt;/code&gt; with a 61pt path, and &lt;code&gt;CTFrameGetLines&lt;/code&gt; returned zero lines. Zero lines means nothing gets drawn, and every API involved treats the transparent result as a successful render.&lt;/p&gt;

&lt;p&gt;We'd been bitten by this class of bug before. &lt;code&gt;boundingRect&lt;/code&gt; once measured 68.0 where CoreText wanted 68.4, and half a point was enough to drop the only line. Our takeaway then was "use the CoreText API". It should have been "measure with the exact code path you draw with".&lt;/p&gt;

&lt;p&gt;The current version lays the text out for real inside a path too tall to constrain anything, then sums the actual line metrics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;probePath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CGPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;CGRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100_000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;frame&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CTFramesetterCreateFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;framesetter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;CFRangeMake&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;probePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="kt"&gt;CTFrameGetLines&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;CTLine&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;ascent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;CGFloat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;descent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;CGFloat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;leading&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;CGFloat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="kt"&gt;CTLineGetTypographicBounds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;ascent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;descent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;leading&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;ascent&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;descent&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;leading&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I didn't trust that alone. A second step builds the frame at the measured height and checks that &lt;code&gt;CTFrameGetVisibleStringRange(frame).length&lt;/code&gt; covers the whole string. If not, it adds height and retries, up to five times. It's cheap, and it's the only code in the pipeline that can see this failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: a caption covering the whole clip never appears
&lt;/h2&gt;

&lt;p&gt;This one showed up on a 4-second screenshot clip with one caption running from 0 to the end. Our timing code turned that into &lt;code&gt;keyTimes = [0, 1]&lt;/code&gt;, &lt;code&gt;values = [1]&lt;/code&gt;. That's a legal shape for a discrete animation (one more key time than values), but a one-value discrete animation changes nothing, and the layer sat at opacity 0 for the whole export.&lt;/p&gt;

&lt;p&gt;Same sentence, measured both ways: spanning the whole clip, 0 caption pixels. Moved to 0.5s through 3.5s, 14,100.&lt;/p&gt;

&lt;p&gt;The fix is boring on purpose. When the track has one value, skip the animation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;track&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;track&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;always&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;never&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and &lt;code&gt;.always&lt;/code&gt; just sets &lt;code&gt;container.opacity = 1&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Counting pixels instead of watching videos
&lt;/h2&gt;

&lt;p&gt;Neither bug throws or logs. A test asserting "export returned a URL" passes on both. Watching output by hand doesn't scale across 26 caption presets.&lt;/p&gt;

&lt;p&gt;The simulator was no help either. CoreAnimation offline rendering crashes there in &lt;code&gt;IOSurfaceCreate&lt;/code&gt;, and a round trip on a real device took about five minutes. So we added a thin shim (&lt;code&gt;PlatformFont&lt;/code&gt;, &lt;code&gt;PlatformColor&lt;/code&gt;) that lets the production &lt;code&gt;ExportService.swift&lt;/code&gt; compile unchanged on macOS, and wrote a command-line harness, &lt;code&gt;Tests/LocalHarness/burnin-main.swift&lt;/code&gt;, that links the real source files rather than a copy.&lt;/p&gt;

&lt;p&gt;The harness:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Synthesizes a solid-color 6-second source video with a 90° rotation transform.&lt;/li&gt;
&lt;li&gt;Runs the real &lt;code&gt;ExportService.renderBurnIn&lt;/code&gt; on it.&lt;/li&gt;
&lt;li&gt;Grabs frames at exact timestamps with &lt;code&gt;AVAssetImageGenerator&lt;/code&gt;, zero tolerance on both sides.&lt;/li&gt;
&lt;li&gt;Samples the background color near the top of the frame and counts pixels in the caption band that differ from it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When a caption should be visible, it expects more than 500 foreign pixels. In a gap, exactly zero, which also catches captions lingering past their end time.&lt;/p&gt;

&lt;p&gt;Both bugs are now permanent sections. Section 5 renders three space-free Chinese strings, one long enough to wrap. Section 6 renders a caption from 0 to 6 seconds and checks frames at 0.5s, 3.0s and 5.5s. Any ❌ exits non-zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this costs
&lt;/h2&gt;

&lt;p&gt;"Pixels that aren't background" tells you something was drawn. It can't tell you it's the right text, font or position. We have a few narrower checks (color-specific pixel counts for custom styles, alpha on rounded corners), but the core test is deliberately dumb, and because the source is a solid color it won't notice problems that only appear over busy footage.&lt;/p&gt;

&lt;p&gt;I'll take that. In a caption app, the failure I worry about most is a successful export with nothing in it, and this is the check that catches it.&lt;/p&gt;

&lt;p&gt;I built this for CapScribe, an iOS app that transcribes on device with Apple's &lt;code&gt;SpeechAnalyzer&lt;/code&gt; and burns captions into the video file. &lt;code&gt;grep -rn URLSession Sources/&lt;/code&gt; in the repo returns nothing. &lt;a href="https://apps.apple.com/app/id6801041856" rel="noopener noreferrer"&gt;https://apps.apple.com/app/id6801041856&lt;/a&gt;&lt;/p&gt;

</description>
      <category>swift</category>
      <category>ios</category>
      <category>debugging</category>
      <category>testing</category>
    </item>
    <item>
      <title>One vendor was deciding our residential proxy verdicts, and I couldn't grade it against itself</title>
      <dc:creator>szp2005</dc:creator>
      <pubDate>Wed, 23 Sep 2026 02:07:54 +0000</pubDate>
      <link>https://dev.to/szp2005/one-vendor-was-deciding-our-residential-proxy-verdicts-and-i-couldnt-grade-it-against-itself-25nh</link>
      <guid>https://dev.to/szp2005/one-vendor-was-deciding-our-residential-proxy-verdicts-and-i-couldnt-grade-it-against-itself-25nh</guid>
      <description>&lt;p&gt;My IP reputation checker has a rule that moves scores more than the weighted average ever does. When enough sources say "this address is a proxy or VPN", the score gets a hard floor of 65, which puts it in the caution band. How many sources count as "enough" depends on the address type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;need&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;isDatacenter&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// dedicated votes, ip-api excluded&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;realProxy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dedicatedVotes&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;need&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;knownAnonAsn&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;realProxy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;floors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;proxyVpn&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;65&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Datacenter ranges need two independent vendors, because a couple of feeds like to tag whole cloud blocks as proxies. Residential addresses need one, on the theory that residential proxies are rare but real when you do see them. I picked both numbers in August and never measured either.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the logs said
&lt;/h2&gt;

&lt;p&gt;Last week I pulled the per-lookup vote signatures we already keep. They record which source said what and the final verdict, with no IPs, ASNs or countries. Over a 15-day window the residential one-vote row was the uncomfortable one: 1,256 verdicts pushed into the caution band by a single vote, and in 96% of them that vote came from the same vendor.&lt;/p&gt;

&lt;p&gt;So "one dedicated vote" really meant one vendor deciding, alone, for people on home connections. Nothing in the pipeline pushed back, because almost nothing else was voting at the time. One source sat behind a quota gate, another was dormant, and our own offline list only votes when an address is on it.&lt;/p&gt;

&lt;p&gt;The datacenter side looked fine. The two-vote rule was absorbing that vendor's habit of flagging entire cloud ranges, which is what it was written for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The yardstick I almost used
&lt;/h2&gt;

&lt;p&gt;The obvious check is to compare the single vote against our final verdict. On a residential address with one vote, the final verdict is that vote, so the comparison returns 100% and tells you nothing.&lt;/p&gt;

&lt;p&gt;Calibration needs a field the thing being graded can't write to. We had just wired in two more vendors, IPHub and vpnapi.io, which are unrelated to each other and to the one under test. The check became: on lookups where only that vendor said "proxy", what did the other two say?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SUBJECT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;ANON&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// only its solo proxy calls&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;votes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;YARDSTICKS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;votes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ANON&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;confirmed&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;votes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CLEAN&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refuted&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;absent&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                            &lt;span class="c1"&gt;// no opinion: not in the denominator&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;confirmed&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;confirmed&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refuted&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I care most about the &lt;code&gt;absent&lt;/code&gt; bucket. If both yardsticks time out, or only report Tor or abuse history, they said nothing about proxies. Counting those rows as refuted would drag the rate down for no reason.&lt;/p&gt;

&lt;p&gt;The decision rule is coarse on purpose. For residential, 70% confirmation or better keeps one vote, under 40% tightens it to two, and anything in between holds the current value. The datacenter rule can only get stricter, from two votes to three.&lt;/p&gt;

&lt;h2&gt;
  
  
  Letting a script touch a scoring rule
&lt;/h2&gt;

&lt;p&gt;A weekly job rewriting a threshold that decides user-facing verdicts is a risk of its own, so three guardrails live in code. Each threshold has hard bounds (residential 1 or 2, datacenter 2 or 3). Nothing moves until confirmed plus refuted reaches 500 samples. One run can shift a threshold by one step at most.&lt;/p&gt;

&lt;p&gt;My first version still got it wrong. The weekly build wrote the new JSON and deployed it, then the next ordinary push redeployed whatever file sat in the repo. Production would have flipped between the two, and nobody could have said which threshold was live on a given day.&lt;/p&gt;

&lt;p&gt;Now the job only proposes. It writes the result to a temp file, and if a threshold would move, CI opens an issue with the full JSON and the evidence. Production reads the committed file and nothing else. I either paste the proposal into the repo or close the issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stands, and what it costs
&lt;/h2&gt;

&lt;p&gt;The first run changed nothing. The 500-sample gate held both thresholds, because the two yardstick vendors went live the same day the job did. The published calibration file still shows zero samples and the August values, and until data piles up, residential proxy verdicts remain one vendor's call.&lt;/p&gt;

&lt;p&gt;The yardstick has a blind spot I can't measure yet. "Confirmed" means two other commercial feeds agreed. It doesn't mean anyone saw a real proxy, and if all three vendors buy from the same upstream list, agreement costs nothing.&lt;/p&gt;

&lt;p&gt;What I actually got out of this week is a smaller thing than a better threshold: I know which number in the scoring code carries the most weight, and I know it had never been checked against anything.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://ipok.io" rel="noopener noreferrer"&gt;ipok.io&lt;/a&gt;. The current calibration file is public at &lt;code&gt;ipok.io/api/data/calibration&lt;/code&gt; if you want to watch the rate fill in.&lt;/p&gt;

</description>
      <category>security</category>
      <category>networking</category>
      <category>devops</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Our 3 a.m. batch job was running at Beijing's afternoon peak rate</title>
      <dc:creator>szp2005</dc:creator>
      <pubDate>Mon, 21 Sep 2026 02:20:23 +0000</pubDate>
      <link>https://dev.to/szp2005/our-3-am-batch-job-was-running-at-beijings-afternoon-peak-rate-4l57</link>
      <guid>https://dev.to/szp2005/our-3-am-batch-job-was-running-at-beijings-afternoon-peak-rate-4l57</guid>
      <description>&lt;p&gt;DeepSeek's API charges two different prices for the same tokens. Which one you pay depends on what time it is in Beijing when the request lands.&lt;/p&gt;

&lt;p&gt;That is the whole bug. It took me an embarrassingly long time to find, because nothing in our code was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;Beijing time, Monday through Friday, 09:00-12:00 and 14:00-18:00 are peak hours. Everything else is off-peak: nights, weekends, Chinese public holidays. Off-peak is exactly half of peak.&lt;/p&gt;

&lt;p&gt;For V4 Pro, per million tokens, in CNY:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Off-peak&lt;/th&gt;
&lt;th&gt;Peak&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Input, cache miss&lt;/td&gt;
&lt;td&gt;¥4.5&lt;/td&gt;
&lt;td&gt;¥9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Input, cache hit&lt;/td&gt;
&lt;td&gt;¥0.15&lt;/td&gt;
&lt;td&gt;¥0.30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output&lt;/td&gt;
&lt;td&gt;¥13.5&lt;/td&gt;
&lt;td&gt;¥27&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Peak is seven hours a day on weekdays, about a fifth of the week. So most of the time you are already in the cheap band by accident, which is why nobody notices the rule until they land in the other fifth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it goes wrong
&lt;/h2&gt;

&lt;p&gt;Our reindex job ran at 3 a.m. Eastern. That felt like the safe, nobody-is-awake slot.&lt;/p&gt;

&lt;p&gt;3 a.m. EDT is 07:00 UTC. That is 15:00 in Beijing, the dead center of the second peak window.&lt;/p&gt;

&lt;p&gt;Converted out of Beijing time, the two peak blocks land here:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Zone&lt;/th&gt;
&lt;th&gt;Block 1&lt;/th&gt;
&lt;th&gt;Block 2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Beijing (UTC+8)&lt;/td&gt;
&lt;td&gt;09:00-12:00&lt;/td&gt;
&lt;td&gt;14:00-18:00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UTC&lt;/td&gt;
&lt;td&gt;01:00-04:00&lt;/td&gt;
&lt;td&gt;06:00-10:00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;US Eastern (EDT)&lt;/td&gt;
&lt;td&gt;21:00-24:00 prev day&lt;/td&gt;
&lt;td&gt;02:00-06:00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the last row again if you schedule on a US clock. The classic overnight maintenance window, roughly 2 a.m. to 6 a.m. Eastern, is peak from end to end.&lt;/p&gt;

&lt;p&gt;Ordinary East Coast business hours, 6 a.m. through 9 p.m., are all off-peak. So the cheap slot is the middle of your working day and the expensive one is the middle of your night.&lt;/p&gt;

&lt;p&gt;There is a day-boundary version of the same trap. Beijing Monday morning is Sunday evening Eastern, so a "start of week" full recompute kicked off Sunday night bills at peak from its first request.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;One line at the top of the crontab:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;TZ&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Asia/Shanghai
0 12 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; 1-5  /usr/local/bin/reindex
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Write the schedule in the timezone of whoever is billing you. For systemd the equivalent is an explicit timezone on the timer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight systemd"&gt;&lt;code&gt;&lt;span class="k"&gt;[Timer]&lt;/span&gt;
&lt;span class="nt"&gt;OnCalendar&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;Mon-Fri 12:00
&lt;span class="nt"&gt;Timezone&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;Asia/Shanghai
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not hardcode the converted UTC times instead. US daylight saving shifts twice a year, so a hand-converted "02:00 UTC" quietly becomes wrong every spring and autumn. Pinning the timezone makes the conversion someone else's problem.&lt;/p&gt;

&lt;p&gt;Two things I had to fix after the cron itself.&lt;/p&gt;

&lt;p&gt;Long jobs straddle the boundary. A job starting at 11:00 Beijing and running two hours pays off-peak for one hour and peak for the next. Starting it an hour later puts the whole run in the cheap band.&lt;/p&gt;

&lt;p&gt;Retry queues quietly move work into peak. Failures pile up overnight and get drained when someone comes in and restarts the consumer, which is exactly the expensive window. That cost never surfaces in a dashboard grouped by model, because the model did not change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caching matters more than scheduling
&lt;/h2&gt;

&lt;p&gt;Moving a job out of peak saves half. Caching is the bigger multiplier: an off-peak cache hit is ¥0.15 per million input tokens against ¥9 for a peak cache miss, a spread of 60x once both axes stack.&lt;/p&gt;

&lt;p&gt;The two multiply, so you do not have to choose. But if you only have time for one, stabilize your prompt prefix first and move the cron second.&lt;/p&gt;

&lt;p&gt;This only applies where the vendor actually prices by time of day. Most APIs charge the same around the clock, so check before you build scheduling logic around it.&lt;/p&gt;

&lt;p&gt;Prices here are from DeepSeek's official pricing page, checked 21 September 2026. The longer Chinese version, with the full timezone table, is on my own price-comparison site: &lt;a href="https://www.llmabacus.com/articles/off-peak-pricing-timezone-trap" rel="noopener noreferrer"&gt;https://www.llmabacus.com/articles/off-peak-pricing-timezone-trap&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I built that site because price tables go stale without ever throwing an error.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>ai</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Cache API drops the fragment, so every chunk I stored was the same entry</title>
      <dc:creator>szp2005</dc:creator>
      <pubDate>Fri, 18 Sep 2026 02:13:44 +0000</pubDate>
      <link>https://dev.to/szp2005/cache-api-drops-the-fragment-so-every-chunk-i-stored-was-the-same-entry-2i1c</link>
      <guid>https://dev.to/szp2005/cache-api-drops-the-fragment-so-every-chunk-i-stored-was-the-same-entry-2i1c</guid>
      <description>&lt;p&gt;Our highest-quality model tier never once worked in production. From the day it shipped, every attempt died with the same line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR_CODE: 7, Failed to load model because protobuf parsing failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I read that as an out-of-memory symptom and went hunting for memory. The error had already ruled that out and I wasn't listening.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why memory was a believable story
&lt;/h2&gt;

&lt;p&gt;The tier is genuinely big: 388 MB gzipped, 445 MB after decompression. We had been burned once already by holding the compressed buffer and the decompressed result at the same time, which peaked near 830 MB and killed the tab every time. So when a second failure turned up on the same tier, "too big for the device" fit the shape of what I thought I knew. A previous bug that looked similar makes the new one feel explained before you've looked at it.&lt;/p&gt;

&lt;p&gt;What should have stopped me was the clock. Loading and running this thing takes tens of seconds. The failures came back in two or three. A job dying far faster than it could possibly finish is not running out of anything, because it never got started.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually happening
&lt;/h2&gt;

&lt;p&gt;We cache the model in chunks of 32 MB, then write a small manifest last, so that seeing a manifest means the chunks are all there. The keys looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chunkKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;#&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// key#0, key#1, ... key#meta&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Cache API strips the URL fragment. It does it on &lt;code&gt;put&lt;/code&gt; and on &lt;code&gt;match&lt;/code&gt;, so &lt;code&gt;key#0&lt;/code&gt;, &lt;code&gt;key#7&lt;/code&gt; and &lt;code&gt;key#meta&lt;/code&gt; are one entry, not three. Every chunk write overwrote the one before it. The manifest goes in last, so what survived was a JSON blob of under thirty bytes.&lt;/p&gt;

&lt;p&gt;Then the top of our load path does a plain lookup with no fragment at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which hits, because there was only ever one entry. So we handed ONNX Runtime a manifest, and it told us, accurately, that those bytes were not a protobuf.&lt;/p&gt;

&lt;p&gt;Every layer here behaved as documented and nothing threw. The writes reported success and the read reported a hit. The parser was the first thing in the whole chain with an opinion about what the bytes actually contained.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and the gate behind it
&lt;/h2&gt;

&lt;p&gt;Path segments instead of fragments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chunkKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/part/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That fixes this bug. It doesn't fix the class of bug, because the cache can still hand you truncated or evicted bytes through no fault of your keys. So there's a second check before anything reaches the parser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;looksLikeOnnx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byteLength&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mh"&gt;0x08&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ONNX is protobuf, and its first field is &lt;code&gt;ir_version&lt;/code&gt;, a field-1 varint, so the first byte has to be &lt;code&gt;0x08&lt;/code&gt;. Add a size floor and a tiny manifest can never masquerade as a model again. When the check fails we delete the entry and treat it as a miss, which costs a re-download. I'll take the re-download over feeding questionable bytes to a parser and then reading its error message as gospel about my hardware.&lt;/p&gt;

&lt;p&gt;If you cache anything in chunks, it's worth checking your own code for the same shape. Keep &lt;code&gt;#&lt;/code&gt; out of cache keys entirely. After writing, read back and count the entries you expect, since overwrites are silent and successful. And validate a magic byte plus a size floor before cached bytes reach a parser.&lt;/p&gt;

&lt;p&gt;The part I keep thinking about isn't the fragment rule. That one is in the spec and I could have looked it up any time. It's that I spent weeks treating a symptom as a resource problem when the duration of the failure ruled that out on the first day. A fast failure and a slow failure tell you different things about where the work stopped, and I only knew how to read one of them.&lt;/p&gt;

&lt;p&gt;I build &lt;a href="https://koutuxia.com" rel="noopener noreferrer"&gt;koutuxia&lt;/a&gt;, a background remover that runs the model in your browser, which is how I met this one.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>debugging</category>
      <category>browser</category>
    </item>
    <item>
      <title>A /24 where every address is flagged is weaker evidence, not stronger</title>
      <dc:creator>szp2005</dc:creator>
      <pubDate>Wed, 16 Sep 2026 02:10:01 +0000</pubDate>
      <link>https://dev.to/szp2005/a-24-where-every-address-is-flagged-is-weaker-evidence-not-stronger-48</link>
      <guid>https://dev.to/szp2005/a-24-where-every-address-is-flagged-is-weaker-evidence-not-stronger-48</guid>
      <description>&lt;p&gt;A few months ago I wrote here about merging eight IP reputation feeds into one score, and I ended by asking what people do with the /24 neighbor signal. Nobody had a clean answer, so I built it. Then it lied to me, and the way it lied was more interesting than the feature.&lt;/p&gt;

&lt;p&gt;The idea seems sound. If the address you're checking sits in a /24 where most of the other addresses are known VPN exits or sit on a spam blocklist, that range is probably being handled as a range by whoever is filtering. Signup forms and fraud rules do CIDR bans constantly. So the neighbor picture is real context even when the address itself looks clean.&lt;/p&gt;

&lt;p&gt;I implemented it as a ratio: scan all 256 addresses in the /24 against a local offline list, count how many carry a flag, raise a floor on the score when that share crosses a threshold. Below a quarter it does nothing, past a quarter the floor is 40, past half it's 50. I capped it there on purpose, because a bad neighborhood is not a bad address.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it broke
&lt;/h2&gt;

&lt;p&gt;The first version scored &lt;code&gt;1.19.0.5&lt;/code&gt; and reported 256 of 256 neighbors flagged, verdict toxic. Maximum contamination, perfect signal.&lt;/p&gt;

&lt;p&gt;Except there are no 256 neighbors. Spamhaus DROP lists &lt;code&gt;1.19.0.0/16&lt;/code&gt; as one hijacked network, in one line. My scan expanded that line into thousands of addresses and then dutifully reported that every one of them was independently suspicious. The address already carried its own abuse flag from that same list entry. So one fact got counted as a direct hit on the address, and then again as 256 corroborating witnesses.&lt;/p&gt;

&lt;p&gt;That isn't evidence stacking, it's one witness wearing 256 hats.&lt;/p&gt;

&lt;p&gt;The tell is that the signal runs backwards. A /24 where 60 addresses are flagged by 60 separate entries really is 60 independent observations. A /24 where all 256 are flagged is almost always one observation about the whole range, and the fuller the coverage, the likelier that's what happened.&lt;/p&gt;

&lt;p&gt;There was a second, dumber version of the same mistake. I had host-level evidence (an address appearing on three independent blocklists, or answering as an open proxy) counted into the neighbor numerator. That's evidence about one machine misbehaving, dragged in to convict the people next door. It also made the UI argue with itself: the score up top said high risk while the map below drew that address's own cell bright green.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix and what it costs
&lt;/h2&gt;

&lt;p&gt;The check turned out to be one function. Is this /24 fully covered by a &lt;em&gt;single&lt;/em&gt; entry in the list?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** Is [a,b] fully contained in ONE range? A /24 painted by one wide CIDR is a
 *  range-level label, not 256 independent bad neighbors. */&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fullyCovered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ranges&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;][],&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ranges&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ranges&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mid&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mid&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// the matched entry swallows the whole block&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;net&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ipInt&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ipInt&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blanket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fullyCovered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;VPN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;net&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;net&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;fullyCovered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ABUSE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;net&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;net&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;blanket&lt;/code&gt; is true the neighbor floor is skipped. The address keeps whatever flag it earned on its own and gets nothing extra for the company it keeps. Host-level evidence moved into its own counter that the neighbor ratio never reads.&lt;/p&gt;

&lt;p&gt;You can watch both halves from outside without logging in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s1"&gt;'https://ipok.io/api/segment?ip=1.19.0.5'&lt;/span&gt; | jq .summary
&lt;span class="c"&gt;# flagged 256 of 256, verdict "toxic"&lt;/span&gt;

curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s1"&gt;'https://ipok.io/api/ip?ip=1.19.0.5'&lt;/span&gt; | jq &lt;span class="s1"&gt;'.riskBreakdown.floors'&lt;/span&gt;
&lt;span class="c"&gt;# only the address's own abuser floor. No neighbor floor at all.&lt;/span&gt;

curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s1"&gt;'https://ipok.io/api/ip?ip=89.149.52.9'&lt;/span&gt; | jq &lt;span class="s1"&gt;'.riskBreakdown.floors'&lt;/span&gt;
&lt;span class="c"&gt;# block floor 40, bucket "contextual". 116 flagged, from many separate entries&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cost is that &lt;code&gt;fullyCovered&lt;/code&gt; trusts the list to arrive merged and sorted. If a source emits a /24 as two adjacent halves, the block reads as non-blanket and the double count comes back for that range. Merging at build time covers the sources I pull, but it's an assumption rather than a guarantee, and I'd rather say that than pretend the rule is airtight.&lt;/p&gt;

&lt;p&gt;The other thing I didn't solve is recency. A neighbor flagged last week and one flagged last spring count the same. Weighting by age is the obvious next move, I don't have a decay curve I can defend, so the flat count stays.&lt;/p&gt;

&lt;p&gt;If you run range reputation anywhere, I'd like to know whether you dedupe at ingest instead. Handling it at scoring time still feels like the wrong layer to me.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://ipok.io" rel="noopener noreferrer"&gt;ipok.io&lt;/a&gt;, a free IP reputation checker that shows this breakdown instead of one number.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>networking</category>
      <category>security</category>
      <category>typescript</category>
      <category>devops</category>
    </item>
    <item>
      <title>A stale price never throws, so we made freshness a build failure</title>
      <dc:creator>szp2005</dc:creator>
      <pubDate>Mon, 14 Sep 2026 15:27:06 +0000</pubDate>
      <link>https://dev.to/szp2005/a-stale-price-never-throws-so-we-made-freshness-a-build-failure-6gm</link>
      <guid>https://dev.to/szp2005/a-stale-price-never-throws-so-we-made-freshness-a-build-failure-6gm</guid>
      <description>&lt;p&gt;A hardcoded unit price never throws. It won't turn a test red. It just quietly becomes false on some Tuesday, and you hear about it from a customer.&lt;/p&gt;

&lt;p&gt;I keep a price comparison table for LLM APIs: 57 models across 16 vendors, in one JSON file. The change log next to it is the part worth looking at. It holds 28 records so far this year, and once you drop the launches and the deprecations, 14 of them actually moved a unit price.&lt;/p&gt;

&lt;p&gt;The magnitude is what got my attention. For the entries where I could compute the input-price delta, the median change was 60%, and two came in at 200%. At that size, calling the table "a bit out of date" is just a politer way of saying it's wrong. A monthly estimate built on last quarter's numbers isn't off by a rounding error.&lt;/p&gt;

&lt;h2&gt;
  
  
  The field that made it visible
&lt;/h2&gt;

&lt;p&gt;Every vendor entry carries two extra fields: &lt;code&gt;lastVerified&lt;/code&gt;, the date someone last read that vendor's official pricing page, and &lt;code&gt;verifyMethod&lt;/code&gt;, how they read it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Baidu"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"lastVerified"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-06-05"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"verifyMethod"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"monitored"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sort the 16 by date and a correlation falls out. The seven on &lt;code&gt;auto_official&lt;/code&gt;, meaning a scraper reads the vendor's own page, all sit within 24 days. The nine on &lt;code&gt;monitored&lt;/code&gt;, meaning a human is supposed to remember, run back to 2026-06-05. That is 101 days. It isn't a forgotten week, it's a standing condition, and no amount of good intentions has ever fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Report the floor, not the max
&lt;/h2&gt;

&lt;p&gt;The tempting way to show freshness is &lt;code&gt;max(lastVerified)&lt;/code&gt;. Today that renders as this morning's date and implies the whole table was checked today, while two vendors sit three months back.&lt;/p&gt;

&lt;p&gt;So the public number is the oldest date instead, with coverage reported separately against a two-week window:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;freshnessWindowDays&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;freshVendorCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;cutoff&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 8 of 16&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fifty percent. It is an unflattering number to publish, and it's the only version that makes anyone go move those two vendors onto the scraper. If a freshness metric takes its reading from whichever record you happened to touch most recently, it will flatter you every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the build fail
&lt;/h2&gt;

&lt;p&gt;Timestamps don't help prose. Once the price sync started running daily, sentences in the comparison pages reading "Model X (¥a in / ¥b out)" kept their old numbers while the JSON moved underneath them. &lt;code&gt;tsc&lt;/code&gt; cannot see that. Neither can review-by-reading.&lt;/p&gt;

&lt;p&gt;Two scripts now run in &lt;code&gt;prebuild&lt;/code&gt;. One diffs every "model ¥in/¥out" phrase in the copy against the JSON and reports drift. The other rejects any hardcoded &lt;code&gt;¥1.00&lt;/code&gt;-style literal on the pricing page outright, so the figure has to be derived from the data.&lt;/p&gt;

&lt;p&gt;I earned that second gate the embarrassing way. Our trust page twice shipped "verbatim" quotes of vendor privacy policies that were not in the linked source. The word &lt;code&gt;verbatim&lt;/code&gt; is now a banned regex in that file, and the page may link to a policy but may not characterize what it says.&lt;/p&gt;

&lt;p&gt;The gate then caught the article I wrote about the gate. I had typed "13 models" into a sentence with no date anywhere in it, and the check refused to build until I anchored it, on the grounds that a sentence about history should keep its historical number while a sentence about today has to match the table. Fair.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four steps, no dependencies
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Give every record a &lt;code&gt;lastVerified&lt;/code&gt; and a source URL. A number missing either one doesn't get committed.&lt;/li&gt;
&lt;li&gt;Aggregate to the floor. Publish the oldest date, plus coverage inside a window you pick on purpose.&lt;/li&gt;
&lt;li&gt;Derive prose numbers from the data. Where you can't, regex-scan the copy for literals in &lt;code&gt;prebuild&lt;/code&gt; and exit non-zero on a hit.&lt;/li&gt;
&lt;li&gt;Timestamp the derived inputs separately. 40 of the 57 models carry their own cached-input price, and the USD vendors get converted at an FX rate (6.7156 today) that moves even when the vendor's dollar price doesn't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The table still goes stale. It just says so now.&lt;/p&gt;

&lt;p&gt;I build &lt;a href="https://www.llmabacus.com/articles/price-table-staleness-gate" rel="noopener noreferrer"&gt;llmabacus.com&lt;/a&gt;, where this table is the product; the Chinese version of this writeup has the full per-vendor breakdown.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>devops</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Twelve of thirteen stale docs were kept alive only by each other</title>
      <dc:creator>szp2005</dc:creator>
      <pubDate>Fri, 11 Sep 2026 02:12:31 +0000</pubDate>
      <link>https://dev.to/szp2005/twelve-of-thirteen-stale-docs-were-kept-alive-only-by-each-other-2mk8</link>
      <guid>https://dev.to/szp2005/twelve-of-thirteen-stale-docs-were-kept-alive-only-by-each-other-2mk8</guid>
      <description>&lt;p&gt;My coding agents are prolific note-takers. Every non-trivial session leaves a PLAN.md, a SUMMARY.md, a HANDOFF.md, sometimes a FINAL_REPORT_V2.md. Across 36 repos on this machine there are about 1,200 markdown files, and I could not tell you which ones anybody still opens.&lt;/p&gt;

&lt;p&gt;The obvious cleanup is a glob and a date filter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'PLAN*.md'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'SUMMARY*.md'&lt;/span&gt; &lt;span class="nt"&gt;-mtime&lt;/span&gt; +30 &lt;span class="nt"&gt;-delete&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I did not run that, because I already knew what it would hit. A PLAN.md from May can still be the file a README points at when it explains why the approach changed. Age alone says nothing about whether a file is load-bearing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that looked right
&lt;/h2&gt;

&lt;p&gt;So the next rule: keep anything that something else links to. Count inbound references, and if the count is above zero, the file is still wired into the repo. Old and unreferenced means safe to move.&lt;/p&gt;

&lt;p&gt;That rule survived about a day. Here is the scan that killed it, from a trading repo with a &lt;code&gt;reports/&lt;/code&gt; folder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;reports/E_F_G_RESULTS.md              116d  stale  refs=1
reports/FINAL_HONEST_PLAN.md          116d  stale  refs=1
reports/FUNDING_HARVEST_REPORT.md     116d  stale  refs=2
reports/HONEST_FINDINGS.md            116d  stale  refs=1
reports/J_SUMMARY.md                  114d  stale  refs=1
reports/FINAL_REPORT.md               114d  stale  refs=6
... 13 files, all 114-116 days old, every one with refs &amp;gt; 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every file passed the "someone links to it" test. Then I traced where the links came from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rl&lt;/span&gt; &lt;span class="s2"&gt;"J_SUMMARY&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;md"&lt;/span&gt; &lt;span class="nt"&gt;--exclude-dir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;.git &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="c"&gt;# reports/L1_AUDIT_REPORT.md&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;L1_AUDIT_REPORT.md&lt;/code&gt; is in the same folder and is just as dead. I walked all thirteen. Twelve of them had every single inbound link coming from another file inside &lt;code&gt;reports/&lt;/code&gt;. The folder was citing itself in a circle. Only &lt;code&gt;FINAL_REPORT.md&lt;/code&gt; had a reference from outside: &lt;code&gt;mql5/README.md&lt;/code&gt;, and a Python script that actually runs.&lt;/p&gt;

&lt;p&gt;A reference count of 1 was not evidence that anyone used the file. It was evidence that the agent wrote two files in the same session and made one mention the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three grades, and only one of them moves
&lt;/h2&gt;

&lt;p&gt;I stopped trying to find a rule that decides. The scanner now sorts into three buckets and hands the ambiguous one back to me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;grade&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ageDays&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;days&lt;/span&gt;   &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;   &lt;span class="c1"&gt;// touched recently, leave it&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refs&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;          &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stale&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;    &lt;span class="c1"&gt;// old but linked, I read these by hand&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt;                       &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;orphan&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// old and unlinked, movable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Across those 36 repos: 687 active, 172 stale, 176 orphan. The 172 in the middle are exactly the files the glob would have eaten, and the ones the reference rule would have blessed. They are not a category the tool can resolve, so it doesn't pretend to.&lt;/p&gt;

&lt;p&gt;Orphans get moved rather than deleted. &lt;code&gt;fs.renameSync&lt;/code&gt; puts them in &lt;code&gt;.mdsweep/trash/&amp;lt;timestamp&amp;gt;/&lt;/code&gt; next to a manifest that records every original path. &lt;code&gt;mdsweep undo&lt;/code&gt; renames them back. Nothing in your tree gets unlinked; the only &lt;code&gt;rmSync&lt;/code&gt; in the codebase targets the trash directory itself after a full restore.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would not put on a slide
&lt;/h2&gt;

&lt;p&gt;The same scan flags 1,035 of 1,199 files, which is 86%, and that number is almost meaningless. One of the three detection signals is "untracked by git," so a repo with a messy working tree lights up wholesale. The flag rate measures my hygiene, not the tool's precision. The grading is the part that earns its keep.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;stale&lt;/code&gt; bucket is also a genuine dead end, not a staging area. The reports folder above proves the tool cannot distinguish a live citation cluster from a dead one, and I do not think heuristics will get there. Thirteen files is a two-minute read for a human. Thirteen hundred would not be, and I have no answer for that yet.&lt;/p&gt;

&lt;p&gt;It is a single &lt;code&gt;.mjs&lt;/code&gt; file, no dependencies, Node 18+. It reads your repo and prints a table; you have to pass &lt;code&gt;--apply&lt;/code&gt; before it touches anything.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/szp2005/mdsweep
node mdsweep/bin/mdsweep.mjs scan ~/code/your-repo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I built it (&lt;a href="https://github.com/szp2005/mdsweep" rel="noopener noreferrer"&gt;szp2005/mdsweep&lt;/a&gt;) for my own repos, and the reports folder above is why the middle bucket exists at all. If your agents also write more markdown than you read, the scan is read-only, so the worst case is that you find out your repos were fine.&lt;/p&gt;

</description>
      <category>cli</category>
      <category>node</category>
      <category>devtools</category>
      <category>opensource</category>
    </item>
    <item>
      <title>A bot fleet rewrote its headers three times in 48 hours, so I stopped matching signatures</title>
      <dc:creator>szp2005</dc:creator>
      <pubDate>Wed, 09 Sep 2026 02:16:38 +0000</pubDate>
      <link>https://dev.to/szp2005/a-bot-fleet-rewrote-its-headers-three-times-in-48-hours-so-i-stopped-matching-signatures-40fj</link>
      <guid>https://dev.to/szp2005/a-bot-fleet-rewrote-its-headers-three-times-in-48-hours-so-i-stopped-matching-signatures-40fj</guid>
      <description>&lt;p&gt;Early September, the explicit-lookup endpoint on my IP-reputation site started seeing tens of thousands of distinct source IPs a day, and most of them queried exactly once. Every request asked about its own address. The sources were residential and mobile ISPs across Europe: BT, Vodafone Italy, Orange, Charter, Telenor.&lt;/p&gt;

&lt;p&gt;That shape is somebody validating a residential proxy pool against my endpoint, one exit at a time. And it walks straight past per-IP daily quotas, because a quota caps depth and this has no depth. Thirty thousand exits at one query each sits under any per-IP limit you'd want to set, while spending a full upstream fan-out per request. AbuseIPDB's daily allowance was gone in three hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Matching the signature buys you about a day and a half
&lt;/h2&gt;

&lt;p&gt;First attempt: block the signature. &lt;code&gt;wrangler tail&lt;/code&gt; showed the fleet's requests were internally impossible under Fetch Metadata. They announced &lt;code&gt;sec-fetch-mode: navigate&lt;/code&gt; with &lt;code&gt;dest: document&lt;/code&gt; and &lt;code&gt;site: none&lt;/code&gt;, which is what an address-bar navigation looks like, while carrying &lt;code&gt;accept: application/json, text/plain, */*&lt;/code&gt;, which is axios's default. They carried an &lt;code&gt;Origin&lt;/code&gt;, which a GET navigation never has. And a &lt;code&gt;Referer&lt;/code&gt;, while claiming &lt;code&gt;site: none&lt;/code&gt;, meaning no initiator at all.&lt;/p&gt;

&lt;p&gt;I shipped a rule for exactly that. Thirty-six hours later the fleet came back as &lt;code&gt;mode: cors&lt;/code&gt;, &lt;code&gt;site: same-origin&lt;/code&gt;, still axios's Accept, with a Referer copied from a real page request. A clean impersonation of my own page's fetch, and it went through to full upstream. A day after that, version three: a complete top-level navigation, &lt;code&gt;text/html&lt;/code&gt;, no Origin, no Referer.&lt;/p&gt;

&lt;p&gt;Signature matching was always going to lose this race. Every rule I ship works as a free oracle for them: flip one header, retry, and the response says which header it was.&lt;/p&gt;

&lt;p&gt;What failed worse was my own shortlist of obvious tells. I had three candidates out of the tail logs: &lt;code&gt;connection: Keep-Alive&lt;/code&gt; on HTTP/2, which is illegal; &lt;code&gt;x-real-ip&lt;/code&gt; and &lt;code&gt;x-forwarded-proto&lt;/code&gt;; and a missing &lt;code&gt;priority&lt;/code&gt; header. All three turned out to be present or absent on real human traffic in the same minute. Cloudflare and my adapter layer put them there. Ship those and I'd have been 403ing real people without ever finding out. A detection rule has to be falsified against known-good samples before it goes anywhere near a block.&lt;/p&gt;

&lt;h2&gt;
  
  
  Contradictions instead of signatures
&lt;/h2&gt;

&lt;p&gt;The checks that survived all name a pair of headers the browser generates from one source, which therefore cannot disagree.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sec-fetch-mode&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;site&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sec-fetch-site&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;accept&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;accept&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ua&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user-agent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// 1. Claims a top-level navigation, carries things only XHR/axios sends.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;forgedNavigation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;navigate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;origin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;site&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;referer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// 2. Claims a page fetch, carries navigation-only headers.&lt;/span&gt;
&lt;span class="c1"&gt;//    Browsers never set either one on fetch()/XHR.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;forgedFetch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cors&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no-cors&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;same-origin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sec-fetch-user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;upgrade-insecure-requests&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// 3. The UA's OS disagrees with the client hint. Windows and Mac only:&lt;/span&gt;
&lt;span class="c1"&gt;//    Android desktop-mode reports Linux, iOS WebKit sends no hints at all.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;platform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sec-ch-ua-platform&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/"/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;platformMismatch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;platform&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;ua&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Windows NT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;platform&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;windows&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ua&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Macintosh&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;platform&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;macos&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third one caught version three. Its UA said &lt;code&gt;Windows NT 10.0&lt;/code&gt; and its &lt;code&gt;sec-ch-ua-platform&lt;/code&gt; said &lt;code&gt;"macOS"&lt;/code&gt;. Real Chrome derives both from the same place; UA reduction froze the version numbers, it didn't touch the OS. Android and iOS stay out of the check rather than getting guessed at. Eighteen tests cover this file and about half are negative controls taken from real user agents.&lt;/p&gt;

&lt;p&gt;The second change mattered more. I had been treating this as one trust bit, first-party or not, which means copying the first-party shape once wins you unlimited upstream, and everything else shares a single pool the fleet can drain in any disguise it likes. Now each client kind gets its own hourly upstream bucket: official app, browser extension, cross-site webapp, address-bar navigation, plain script, suspect. Impersonating the app starves the app pool and nothing else. Total upstream is bounded by the sum of the buckets no matter which costume turns up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;This is not a cryptographic boundary and I don't want it read as one. A script can produce headers that pass every check above; version two already did. What's gone is the free ride. Each rewrite now costs them a calibration pass against a real browser.&lt;/p&gt;

&lt;p&gt;Real users do get caught. Someone behind an HTTP/1.1 proxy that strips Fetch Metadata lands in the &lt;code&gt;suspect&lt;/code&gt; bucket. That's why suspect is a small pool rather than a 403: a self-contradiction is the only thing that earns a hard rejection, and a header that is merely absent never does. The page-proof path works the same way. A same-origin fetch that can't produce a Turnstile token isn't refused; it drops into an &lt;code&gt;unproved&lt;/code&gt; pool, because Turnstile fails to load for plenty of real people.&lt;/p&gt;

&lt;p&gt;The fleet's independent-IP count fell from around four thousand to roughly a hundred the next day, with no sign of it resurfacing in a new disguise. I read that as a retreat, not a win. Version four is coming, and the point of the buckets is that version four is capped before I've noticed it.&lt;/p&gt;

&lt;p&gt;I build &lt;a href="https://ipok.io" rel="noopener noreferrer"&gt;ipok.io&lt;/a&gt;, the IP checker all of this runs on.&lt;/p&gt;

</description>
      <category>security</category>
      <category>networking</category>
      <category>webdev</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Reconciling 8 IP-reputation feeds into one verdict: averaging is the wrong default</title>
      <dc:creator>szp2005</dc:creator>
      <pubDate>Fri, 19 Jun 2026 08:42:18 +0000</pubDate>
      <link>https://dev.to/szp2005/reconciling-8-ip-reputation-feeds-into-one-verdict-averaging-is-the-wrong-default-1258</link>
      <guid>https://dev.to/szp2005/reconciling-8-ip-reputation-feeds-into-one-verdict-averaging-is-the-wrong-default-1258</guid>
      <description>&lt;p&gt;Wire more than one IP-reputation source into a risk check and sooner or later they disagree. One feed says the IP is a residential ISP address. Another calls it a datacenter VPN. A blocklist says it relayed spam last week. A geolocation provider says it's clean and unremarkable.&lt;/p&gt;

&lt;p&gt;The naive move is to normalize everything to 0–100 and average it. I did that first. It produces a number that's wrong in specific, reproducible ways, and on top of that a number nobody can act on. The moment a verdict matters, someone asks "&lt;em&gt;why&lt;/em&gt; is this 0.62?" and the average has no answer.&lt;/p&gt;

&lt;p&gt;The version I landed on after the averaging one kept embarrassing me reads as a decision log. Every rule below is there because some real IP broke the version before it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why averaging fails: three concrete failure modes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Low-precision sources dominate the consensus.&lt;/strong&gt; Some feeds label entire datacenter /16 blocks as "proxy" or "VPN" wholesale. They're cheap and high-recall, so they're noisy. Average them in and a plain Hetzner or Linode box that two of these feeds tagged as "proxy" gets dragged up into mid-risk territory, even when every higher-precision source says it's just hosting. You've shipped a scorer that cries wolf on half of AWS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A single low-confidence report flips a binary feed.&lt;/strong&gt; Abuse-report databases are community-fed. If your rule is &lt;code&gt;flagged = (totalReports &amp;gt; 0)&lt;/code&gt;, one retaliatory or mistaken report marks an address as a known abuser. I watched &lt;code&gt;8.8.8.8&lt;/code&gt;, Google Public DNS, come back as "abuser" because somebody somewhere reported it once. Averaging doesn't save you. It buries the bad signal under the good ones for &lt;em&gt;most&lt;/em&gt; IPs and then surfaces it on the unlucky ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Averaging dilutes the one source that matters most.&lt;/strong&gt; A live spam-relay listing, or membership in a Tor exit-node list, sits close to ground truth. Seven geolocation feeds saying "nothing unusual" should not be allowed to wash that out. Risk signals aren't symmetric, and an average pretends they are.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model: visible per-source verdicts, asymmetric floors
&lt;/h2&gt;

&lt;p&gt;Two ideas did most of the work.&lt;/p&gt;

&lt;p&gt;The first: don't collapse to one opaque number. Keep every source's verdict and show it as its own line item. Which feed, what it claimed, what signal category it falls under (datacenter, residential proxy, Tor exit, active abuser, spam-list hit). Then whoever consumes the score decides whether a given flag matters for &lt;em&gt;their&lt;/em&gt; case. A Tor-exit listing is disqualifying for a signup flow and irrelevant for a geo-IP cache.&lt;/p&gt;

&lt;p&gt;The second: keep a weighted baseline, but let signal &lt;em&gt;type&lt;/em&gt; set a hard floor. The aggregate starts as a precision-weighted average, and then certain confirmed signals impose a minimum the average can't pull below.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Tor exit node (confirmed)      → floor 90
Dedicated proxy/VPN (consensus)→ floor 65
Confirmed abuser               → floor 55
Datacenter / hosting           → floor 35
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A floor says: if this signal is present, the score can't drop below X no matter how many geo feeds call the address clean. Swapping type-driven floors in for the pure average is the one change that got the output to line up with what an analyst would actually conclude.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules that keep the floors honest
&lt;/h2&gt;

&lt;p&gt;A floor is only as trustworthy as the boolean that trips it. Each of these earned its place by killing a specific false positive.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Proxy/VPN needs consensus from &lt;em&gt;dedicated&lt;/em&gt; sources.&lt;/strong&gt; The low-precision general feed never gets to establish a proxy verdict on its own. On datacenter ranges I require ≥2 dedicated (purpose-built proxy/VPN) sources to agree. On residential ranges ≥1 is enough, since a residential proxy is rarer and so means more when a specialized feed flags it. Hetzner and Linode fall back to "hosting 35" instead of a phantom "proxy 65," and a real consumer-ISP proxy still trips.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Tighten the noisy binary feed.&lt;/strong&gt; An abuse listing now requires &lt;code&gt;score ≥ 25 AND reports ≥ 3&lt;/code&gt; (or ≥2 distinct reporters), and the address can't be on the provider's own allowlist. &lt;code&gt;8.8.8.8&lt;/code&gt; stops being an abuser.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Whitelist known infrastructure ASNs.&lt;/strong&gt; Google, Cloudflare, and the like suppress the abuser and hosting floors. A CDN edge node isn't a threat, and you don't want your scorer picking fights with the backbone of the internet.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Treat ASN reputation as standalone evidence.&lt;/strong&gt; A small set of autonomous systems are VPN/proxy-only businesses: M247, Mullvad, Proton, a handful of others. For these, membership alone settles it, with no cross-source consensus needed, because the network operator's identity &lt;em&gt;is&lt;/em&gt; the signal. This recovers the case where one feed alone recognizes a niche VPN that the consensus rule above would otherwise suppress.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Add a hard, independent signal: DNSBL over DoH.&lt;/strong&gt; I query a handful of DNS blocklists, reversing the octets against each zone and going over DNS-over-HTTPS so it runs from an edge runtime. A hit there is close to ground truth and leans on nobody's opaque vendor score.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Short-circuit reserved and CGNAT ranges before scoring.&lt;/strong&gt; CGNAT (100.64.0.0/10), TEST-NET, benchmark, multicast, and the IPv6 equivalents get an explicit "reserved, here's the category" response rather than going through the pipeline to be mislabeled. It also keeps thousands of carrier-NAT users behind one exit from being scored as a shared proxy.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Make the verdict auditable, not just displayable
&lt;/h2&gt;

&lt;p&gt;If I had to press one point on anyone building this, it's this: emit the &lt;em&gt;breakdown&lt;/em&gt; as structured data, not just the final number. Every lookup returns each source's contribution, the weighted average before floors, which floors fired and why, and the final value. You get debuggability out of it. When a verdict looks wrong, the breakdown tells you at a glance whether it was a bad weight, a floor that shouldn't have fired, or thin data. You also let the user overrule you: the person reading the score can tell whether it rests on one thin signal or a five-way consensus, and judge for their own case. A black-box number forces all-or-nothing, trust it blind or throw it out.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I still haven't solved well
&lt;/h2&gt;

&lt;p&gt;A few open problems, since anyone who's done this for real will have opinions.&lt;/p&gt;

&lt;p&gt;CGNAT and mobile carriers are the worst of them. Shared-exit NAT and a residential proxy pool throw off the same surface signal: many users, one IP. Short-circuiting the reserved CGNAT block helps, but carriers also use public ranges that look identical to a proxy from the outside. I flag uncertainty rather than guess, and I still don't have a clean discriminator.&lt;/p&gt;

&lt;p&gt;Then there's absence of evidence versus evidence of absence. For smaller regional ISPs the databases run thin. "No source flagged it" reads as "clean" when it often just means "nobody has data." Right now I surface coverage, the count of how many sources had any opinion at all, next to the verdict. I'm not convinced that's enough.&lt;/p&gt;

&lt;p&gt;Last, the residential-versus-datacenter split. When two classifiers disagree on the same IP I show both labels and leave it unresolved. Whether a confidence-weighted merge beats preserving the raw disagreement, I genuinely don't know.&lt;/p&gt;

&lt;p&gt;If you've run reputation scoring at scale, I'd value your take on the /24 neighbor signal (contamination ratio weighted by flag recency?) and on the residential/datacenter conflict above.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The scorer described here runs behind &lt;a href="https://ipok.io" rel="noopener noreferrer"&gt;ipok.io&lt;/a&gt;, a free, no-login IP reputation checker that shows the per-source breakdown instead of a single number. The CLI is MIT on &lt;a href="https://github.com/szp2005/ipok-cli" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. Happy to go deeper on any of the data-source quirks in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>networking</category>
      <category>security</category>
      <category>devops</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Making "files never leave your browser" verifiable with DevTools and CSP</title>
      <dc:creator>szp2005</dc:creator>
      <pubDate>Mon, 15 Jun 2026 03:23:04 +0000</pubDate>
      <link>https://dev.to/szp2005/making-files-never-leave-your-browser-verifiable-with-devtools-and-csp-4n99</link>
      <guid>https://dev.to/szp2005/making-files-never-leave-your-browser-verifiable-with-devtools-and-csp-4n99</guid>
      <description>&lt;p&gt;"Files never leave your browser" is becoming standard copy for PDF tools, image editors, and document converters. But a trust claim and a verifiable fact are different things. Here's how to turn "zero upload" into something any user can audit in about two minutes, and how to enforce it at the browser level so it isn't just a promise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Read the Network panel
&lt;/h2&gt;

&lt;p&gt;Open DevTools → Network, enable "Disable cache", reload. While processing a file, filter by "Fetch/XHR" and "Doc". A genuinely client-side tool should show only HTML/CSS/JS/WASM asset loads — no POST requests, no GETs carrying file content in query parameters.&lt;/p&gt;

&lt;p&gt;The non-obvious trap: third-party analytics, Google Fonts, and CDNs all show up as outbound requests. If you claim zero uploads, those count too. The honest move is to self-host fonts and scripts and drop analytics entirely, so the request list is genuinely short enough to eyeball.&lt;/p&gt;

&lt;p&gt;The Network panel is the human-readable check. The next part is what actually makes it hold.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Enforce egress with CSP &lt;code&gt;connect-src&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This is the piece people get backwards, so it's worth stating precisely.&lt;/p&gt;

&lt;p&gt;CSP's &lt;code&gt;connect-src&lt;/code&gt; is an egress allowlist the browser enforces &lt;em&gt;before the request is sent&lt;/em&gt;. A &lt;code&gt;fetch&lt;/code&gt;/XHR to an origin that isn't on the list is blocked by the browser and never leaves the machine. You'll see it fail in the console as a CSP violation, with no entry in the Network tab going out to that origin.&lt;/p&gt;

&lt;p&gt;This includes &lt;code&gt;no-cors&lt;/code&gt; requests. &lt;code&gt;no-cors&lt;/code&gt; is sometimes assumed to be an escape hatch, but it isn't one for this purpose. All &lt;code&gt;no-cors&lt;/code&gt; does is let you &lt;em&gt;issue&lt;/em&gt; a cross-origin request while making the response opaque (you can't read the body). It does not bypass &lt;code&gt;connect-src&lt;/code&gt;: if the target origin isn't in your &lt;code&gt;connect-src&lt;/code&gt; allowlist, the &lt;code&gt;no-cors&lt;/code&gt; request is blocked exactly the same way — it never goes out. So you can't smuggle a file out to a third party with &lt;code&gt;no-cors&lt;/code&gt; under a tight CSP.&lt;/p&gt;

&lt;p&gt;That's what makes CSP the actual proof, not just documentation. Tighten &lt;code&gt;connect-src&lt;/code&gt; to &lt;code&gt;'self'&lt;/code&gt; (or an explicit list of the few endpoints you genuinely need), and any code path that tries to ship data to another origin — yours, a third party's, an injected script's — is stopped by the browser. A realistic policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;connect&lt;/span&gt;-&lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="s1"&gt;'self'&lt;/span&gt;;
&lt;span class="n"&gt;font&lt;/span&gt;-&lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="s1"&gt;'self'&lt;/span&gt;;
&lt;span class="n"&gt;script&lt;/span&gt;-&lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="s1"&gt;'self'&lt;/span&gt; &lt;span class="s1"&gt;'wasm-unsafe-eval'&lt;/span&gt;;
&lt;span class="n"&gt;img&lt;/span&gt;-&lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="s1"&gt;'self'&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;:;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note &lt;code&gt;'wasm-unsafe-eval'&lt;/code&gt; rather than the broader &lt;code&gt;'unsafe-eval'&lt;/code&gt; — modern browsers support the narrower directive for instantiating WASM, so there's no reason to grant full &lt;code&gt;eval&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;With that in place, the Network panel check from Step 1 stops being "trust me, the list is short" and becomes "the browser will refuse to send anything I didn't whitelist, and here's the empty list to confirm it."&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 (optional): Content-Length as a sanity check
&lt;/h2&gt;

&lt;p&gt;If you want a quick gut-check rather than reasoning about the allowlist, clear the Network panel before triggering processing, then sum the Size column afterward. If the total is nowhere near the original file size, no file content went out. This also catches chunked-transfer or WebSocket approaches that a naive "look for a POST" scan might miss. It's a weaker check than the CSP guarantee, but it's fast and visual.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Service Worker doesn't replace CSP
&lt;/h2&gt;

&lt;p&gt;A Service Worker can intercept fetches and is useful for offline caching, but it's not the egress boundary — it's first-party code that can be bypassed or simply not cover a code path, and it does nothing about requests that don't route through it. CSP &lt;code&gt;connect-src&lt;/code&gt; is enforced by the browser regardless of your application code. Use a Service Worker for caching if you want; rely on CSP for the "can't exfiltrate" guarantee.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this looks like in practice
&lt;/h2&gt;

&lt;p&gt;I built a PDF tool this way (moguanpdf.com, my own project — mentioning it only because it's a live example you can poke at). The classic tools (compress, merge, split, OCR, watermark, encrypt/decrypt, etc.) run entirely in-browser via WASM + pdf.js. Open DevTools → Network while processing a file and you'll see only &lt;code&gt;.wasm&lt;/code&gt;, &lt;code&gt;.js&lt;/code&gt;, and &lt;code&gt;.css&lt;/code&gt; loads, no POST, no analytics. The one server-side exception is the AI features (summarize/translate/Q&amp;amp;A), which send extracted text rather than the file, and the UI says so. I'd encourage auditing it the same way you'd audit anyone else's — that's the whole point.&lt;/p&gt;

&lt;h2&gt;
  
  
  The broader point
&lt;/h2&gt;

&lt;p&gt;If your users handle contracts, medical records, or financial documents, "open DevTools and follow these steps, and here's the CSP that guarantees it" is a stronger statement than any privacy policy. The Network panel shows users an empty list; &lt;code&gt;connect-src 'self'&lt;/code&gt; is the reason the list stays empty. A tool that can't survive that audit probably shouldn't be making the claim.&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>security</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
