<?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: harry</title>
    <description>The latest articles on DEV Community by harry (@harry_0b47bcff59).</description>
    <link>https://dev.to/harry_0b47bcff59</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%2F4110665%2Ffbce5315-9617-4264-a8e6-d643aaaf390e.jpg</url>
      <title>DEV Community: harry</title>
      <link>https://dev.to/harry_0b47bcff59</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/harry_0b47bcff59"/>
    <language>en</language>
    <item>
      <title>I built a message board for AI agents. Four bugs shipped past a 100% green test suite.</title>
      <dc:creator>harry</dc:creator>
      <pubDate>Sat, 05 Sep 2026 05:21:19 +0000</pubDate>
      <link>https://dev.to/harry_0b47bcff59/i-built-a-message-board-for-ai-agents-four-bugs-shipped-past-a-100-green-test-suite-3ilb</link>
      <guid>https://dev.to/harry_0b47bcff59/i-built-a-message-board-for-ai-agents-four-bugs-shipped-past-a-100-green-test-suite-3ilb</guid>
      <description>&lt;p&gt;I built &lt;a href="https://agora.tiiow.com" rel="noopener noreferrer"&gt;agora&lt;/a&gt;, a public message board that any AI agent can read and post to. No account, no signup, no API key — only &lt;code&gt;body&lt;/code&gt; is required. Agents leave notes, "here's what broke and here's what fixed it" field notes, or questions other agents can reply to.&lt;/p&gt;

&lt;p&gt;It's about 1,800 lines of PHP with a SQLite database, on a Debian VM behind a Cloudflare tunnel. No JavaScript anywhere, which means the CSP can honestly say &lt;code&gt;script-src 'none'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I had a test suite. It was passing 61 out of 61. Production was serving 403 on a documented API route at that exact moment.&lt;/p&gt;

&lt;p&gt;Here are the four bugs, in ascending order of how badly they'd have hurt.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. My own security rule 403'd my own API endpoint
&lt;/h2&gt;

&lt;p&gt;I added a deny-list to &lt;code&gt;.htaccess&lt;/code&gt; so stray backups and database files could never be served:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nl"&gt;FilesMatch&lt;/span&gt;&lt;span class="sr"&gt; "\.(db|sqlite3?|bak|log|ini|md)$"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;    &lt;span class="nc"&gt;Require&lt;/span&gt; &lt;span class="ss"&gt;all&lt;/span&gt; denied
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nl"&gt;FilesMatch&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reasonable-looking. It also killed &lt;code&gt;/feed.md&lt;/code&gt;, one of my documented API routes.&lt;/p&gt;

&lt;p&gt;There is no &lt;code&gt;feed.md&lt;/code&gt; file on disk. The route is generated by PHP through a front controller, with &lt;code&gt;.htaccess&lt;/code&gt; rewriting everything to &lt;code&gt;index.php&lt;/code&gt;. So why did a &lt;em&gt;file&lt;/em&gt; rule match it?&lt;/p&gt;

&lt;p&gt;Because &lt;strong&gt;&lt;code&gt;FilesMatch&lt;/code&gt; is evaluated against the request path before &lt;code&gt;mod_rewrite&lt;/code&gt; hands off to the front controller.&lt;/strong&gt; At that point Apache is still thinking about &lt;code&gt;/var/www/agora/public/feed.md&lt;/code&gt; as a filename. It matched the URL, not a file, and denied it.&lt;/p&gt;

&lt;p&gt;The error log said so plainly, once I bothered to read it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;AH01630: client denied by &lt;span class="ss"&gt;server&lt;/span&gt; configuration: /var/www/agora/public/feed.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is just "don't put a route's extension in that list." But there's a second, sneakier version of the same bug. My rule also had a clause to hide dotfiles:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nl"&gt;FilesMatch&lt;/span&gt;&lt;span class="sr"&gt; "\.(db|sqlite3?|bak|log|ini)$|(^|/)\."&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That 403'd &lt;code&gt;/.well-known/&lt;/code&gt;, which I was serving a machine-readable descriptor from. &lt;strong&gt;&lt;code&gt;FilesMatch&lt;/code&gt; is tested against every path component, not just the last one&lt;/strong&gt;, so a directory whose name starts with a dot gets caught by a rule you wrote to catch hidden &lt;em&gt;files&lt;/em&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="c"&gt;# The working version. Apache already blocks .ht* globally,&lt;/span&gt;
&lt;span class="c"&gt;# so narrowing this costs nothing.&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nl"&gt;FilesMatch&lt;/span&gt;&lt;span class="sr"&gt; "\.(db|sqlite3?|bak[-.0-9]*|sw[po]|log|ini)$|^\.(?!well-known)"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;    &lt;span class="nc"&gt;Require&lt;/span&gt; &lt;span class="ss"&gt;all&lt;/span&gt; denied
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nl"&gt;FilesMatch&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Every response carried two conflicting security policies
&lt;/h2&gt;

&lt;p&gt;The site was returning &lt;strong&gt;two &lt;code&gt;Content-Security-Policy&lt;/code&gt; headers&lt;/strong&gt;, and two different answers on &lt;code&gt;X-Frame-Options&lt;/code&gt; — &lt;code&gt;DENY&lt;/code&gt; from my vhost, &lt;code&gt;SAMEORIGIN&lt;/code&gt; from somewhere else.&lt;/p&gt;

&lt;p&gt;The somewhere else was &lt;code&gt;/etc/apache2/conf-enabled/security-headers.conf&lt;/code&gt;, a server-wide config written for the main site on that box. It applies to &lt;em&gt;every&lt;/em&gt; vhost, including new ones. So my strict, JavaScript-free board was also advertising the main site's much looser policy, which permits inline scripts.&lt;/p&gt;

&lt;p&gt;Browsers resolve multiple CSP headers by intersection, so this wasn't exploitable. It was just wrong, ambiguous, and would eventually bite someone.&lt;/p&gt;

&lt;p&gt;The fix has a wrinkle worth knowing. &lt;code&gt;mod_headers&lt;/code&gt; maintains &lt;strong&gt;two separate header tables&lt;/strong&gt; — one for normal responses, one for error responses (&lt;code&gt;always&lt;/code&gt;). &lt;code&gt;Header always set&lt;/code&gt; writes into the second table, and the two get merged, so it &lt;em&gt;appends a second copy&lt;/em&gt; rather than replacing the first. You have to unset both explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="nc"&gt;Header&lt;/span&gt; &lt;span class="ss"&gt;unset&lt;/span&gt; Content-Security-Policy
&lt;span class="nc"&gt;Header&lt;/span&gt; &lt;span class="ss"&gt;always&lt;/span&gt; &lt;span class="ss"&gt;unset&lt;/span&gt; Content-Security-Policy
&lt;span class="nc"&gt;Header&lt;/span&gt; &lt;span class="ss"&gt;always&lt;/span&gt; &lt;span class="ss"&gt;set&lt;/span&gt; Content-Security-Policy "default-src 'self'; script-src 'none'; ..."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Stripping invisible characters destroyed emoji
&lt;/h2&gt;

&lt;p&gt;I was sanitising post content by removing Unicode control and format characters, which sounds unambiguously correct:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Strips everything in the \p{C} category&lt;/span&gt;
&lt;span class="nv"&gt;$s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/[^\P{C}\x0A\x09]/u'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;\p{C}&lt;/code&gt; category includes &lt;code&gt;Cf&lt;/code&gt;, format characters. And &lt;code&gt;Cf&lt;/code&gt; includes &lt;strong&gt;U+200D, the zero-width joiner&lt;/strong&gt; — which is the glue holding emoji sequences together.&lt;/p&gt;

&lt;p&gt;A family emoji, 👨‍👩‍👧‍👦, is four people joined by ZWJs. Strip them and it silently becomes four separate people. Same class of breakage in Persian and Hindi, where ZWJ and ZWNJ control how letters connect.&lt;/p&gt;

&lt;p&gt;The fix keeps those two and nothing else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Keep ZWJ/ZWNJ — they are load-bearing, not decoration.&lt;/span&gt;
&lt;span class="c1"&gt;// Bidi overrides (U+202A-202E, U+2066-2069) stay stripped: those are a&lt;/span&gt;
&lt;span class="c1"&gt;// real text-spoofing vector and nothing legitimate here needs them.&lt;/span&gt;
&lt;span class="nv"&gt;$keep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$multiline&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'\x0A\x09'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'\x{200C}\x{200D}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/[^\P{C}'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$keep&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;']/u'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I only caught this because I wrote a test that round-tripped nine writing systems and asserted byte-equality. Plain emoji passed. The ZWJ case was the only failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Cloudflare was 403ing every AI crawler on my domain
&lt;/h2&gt;

&lt;p&gt;This is the one that would have quietly wasted the entire project.&lt;/p&gt;

&lt;p&gt;I'd built all the discovery machinery — an &lt;code&gt;llms.txt&lt;/code&gt;, a &lt;code&gt;robots.txt&lt;/code&gt; explicitly welcoming seventeen AI crawlers, a sitemap, an OpenAPI spec. All of it returned 200. Then I checked &lt;code&gt;robots.txt&lt;/code&gt; &lt;strong&gt;as served through Cloudflare&lt;/strong&gt; rather than at my origin, and found this sitting above my file:&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="c"&gt;# BEGIN Cloudflare Managed content
&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;-&lt;span class="n"&gt;agent&lt;/span&gt;: &lt;span class="n"&gt;ClaudeBot&lt;/span&gt;
&lt;span class="n"&gt;Disallow&lt;/span&gt;: /
&lt;span class="n"&gt;User&lt;/span&gt;-&lt;span class="n"&gt;agent&lt;/span&gt;: &lt;span class="n"&gt;GPTBot&lt;/span&gt;
&lt;span class="n"&gt;Disallow&lt;/span&gt;: /
...&lt;span class="n"&gt;ten&lt;/span&gt; &lt;span class="n"&gt;crawlers&lt;/span&gt;...
&lt;span class="n"&gt;User&lt;/span&gt;-&lt;span class="n"&gt;agent&lt;/span&gt;: *
&lt;span class="n"&gt;Content&lt;/span&gt;-&lt;span class="n"&gt;Signal&lt;/span&gt;: &lt;span class="n"&gt;search&lt;/span&gt;=&lt;span class="n"&gt;yes&lt;/span&gt;,&lt;span class="n"&gt;ai&lt;/span&gt;-&lt;span class="n"&gt;train&lt;/span&gt;=&lt;span class="n"&gt;no&lt;/span&gt;,&lt;span class="n"&gt;use&lt;/span&gt;=&lt;span class="n"&gt;reference&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My &lt;code&gt;Allow:&lt;/code&gt; groups came &lt;em&gt;after&lt;/em&gt;, so every crawler saw two contradictory groups for itself. Resolution there is implementation-defined — some take the least restrictive rule, some take the first matching group.&lt;/p&gt;

&lt;p&gt;But it wasn't advisory. The zone had AI bot blocking switched on, so those user agents were refused at the edge and never reached my server. Measured:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;User agent&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ClaudeBot, GPTBot, PerplexityBot, CCBot, Bytespider&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;403&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude-User, ChatGPT-User, Perplexity-User&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;403&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Googlebot, bingbot&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;curl, python-requests, node-fetch, empty UA&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Look at that last row.&lt;/strong&gt; Every smoke test, every monitoring check, every manual verification I'd done all afternoon — all of them used a client on the allowed list. The site looked completely healthy while being invisible to the only audience it was built for.&lt;/p&gt;

&lt;p&gt;And the second row is the one I'd have felt worst about. &lt;code&gt;Claude-User&lt;/code&gt; and &lt;code&gt;ChatGPT-User&lt;/code&gt; are the user agents used when a &lt;em&gt;person&lt;/em&gt; asks an assistant to go read a specific link. So "hey, check out this page" was broken too. Not just bulk crawling.&lt;/p&gt;

&lt;p&gt;One line to check your own site:&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;-A&lt;/span&gt; &lt;span class="s1"&gt;'ClaudeBot/1.0'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}\n'&lt;/span&gt; https://yoursite.com/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Settings live under &lt;strong&gt;Security → Settings → Bot traffic&lt;/strong&gt;, or via the API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /zones/&amp;lt;zone_id&amp;gt;/bot_management
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at &lt;code&gt;ai_bots_protection&lt;/code&gt;, &lt;code&gt;is_robots_txt_managed&lt;/code&gt; and &lt;code&gt;crawler_protection&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One constraint worth knowing before you plan around it: Cloudflare documents that Bot Fight Mode runs &lt;em&gt;outside&lt;/em&gt; the ruleset engine, so WAF &lt;code&gt;skip&lt;/code&gt; / &lt;code&gt;bypass&lt;/code&gt; / &lt;code&gt;allow&lt;/code&gt; rules have no effect on it. You can't carve out a single hostname while leaving the rest of the zone protected. It's a zone-wide decision.&lt;/p&gt;

&lt;p&gt;Whether you &lt;em&gt;want&lt;/em&gt; AI crawlers is your call — I'm not arguing either way. The point is that you should know which answer your zone is currently giving, because mine was giving one I hadn't chosen and hadn't noticed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual lesson: my tests were testing the wrong machine
&lt;/h2&gt;

&lt;p&gt;Three of those four bugs live in a layer my test suite could not see.&lt;/p&gt;

&lt;p&gt;I was running the app under PHP's built-in server (&lt;code&gt;php -S&lt;/code&gt;) against a throwaway database. It's fast, it's isolated, it's great. It also &lt;strong&gt;ignores &lt;code&gt;.htaccess&lt;/code&gt; entirely, has no vhost, and no &lt;code&gt;mod_headers&lt;/code&gt;.&lt;/strong&gt; So an entire layer of the deployed system was structurally invisible:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;.htaccess&lt;/code&gt; deny rules and rewrites&lt;/li&gt;
&lt;li&gt;vhost-level &lt;code&gt;Header&lt;/code&gt; directives&lt;/li&gt;
&lt;li&gt;headers inherited from server-wide config&lt;/li&gt;
&lt;li&gt;anything the CDN adds or rewrites in front of all of it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It got worse than "untested". When I moved &lt;code&gt;Cache-Control&lt;/code&gt; and CSP out of PHP and into the vhost, my existing tests for those headers &lt;strong&gt;kept passing against a server that was no longer sending them.&lt;/strong&gt; A green assertion that proves nothing is worse than no assertion, because it buys you confidence you haven't earned.&lt;/p&gt;

&lt;p&gt;The fix was to split the suite by layer and be explicit about what each one proves. I kept the fast in-process tests, then added a phase that talks to the real Apache over localhost with a &lt;code&gt;Host&lt;/code&gt; header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;http://127.0.0.1/feed.md&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Host&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;agora.tiiow.com&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&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 phase asserts a status code on every documented route, and that there is &lt;strong&gt;exactly one copy&lt;/strong&gt; of each security header — which is how the duplicate CSP surfaced. It's about forty lines and it catches an entire bug class that unit tests cannot reach by construction.&lt;/p&gt;

&lt;p&gt;If you take one thing from this: &lt;strong&gt;after you deploy, sweep every route in your public docs with curl and diff it against the list.&lt;/strong&gt; "Documented endpoint returns 403" is invisible to unit tests, embarrassing in production, and takes about thirty seconds to check.&lt;/p&gt;




&lt;p&gt;The board is at &lt;a href="https://agora.tiiow.com" rel="noopener noreferrer"&gt;agora.tiiow.com&lt;/a&gt; if you want to look, and there's a &lt;a href="https://tiiow.com/projects/agora.html" rel="noopener noreferrer"&gt;longer write-up&lt;/a&gt; covering the prompt-injection side — because a board that agents read is an injection surface by construction, and that turned out to be the genuinely interesting design problem in the whole thing.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>cloudflare</category>
      <category>php</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
