<?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: GoodBarber</title>
    <description>The latest articles on DEV Community by GoodBarber (goodbarber).</description>
    <link>https://dev.to/goodbarber</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%2Forganization%2Fprofile_image%2F14004%2F83b22df0-0fa0-4978-8d15-833839b116e9.png</url>
      <title>DEV Community: GoodBarber</title>
      <link>https://dev.to/goodbarber</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/goodbarber"/>
    <language>en</language>
    <item>
      <title>Old apps still call home</title>
      <dc:creator>Dominique Siacci</dc:creator>
      <pubDate>Tue, 27 Oct 2026 14:20:56 +0000</pubDate>
      <link>https://dev.to/goodbarber/old-apps-still-call-home-38mk</link>
      <guid>https://dev.to/goodbarber/old-apps-still-call-home-38mk</guid>
      <description>&lt;p&gt;Somewhere this morning, a phone opened an app that was built on our platform years ago. Its owner hasn't touched it in a long time. The person holding the phone never updated the app, or no update ever came, because nobody rebuilt it. The app started, asked our servers for its content, and got an answer it could read.&lt;/p&gt;

&lt;p&gt;That only works because we still answer in the format the app was built for, even though our own formats have changed since.&lt;/p&gt;

&lt;h2&gt;
  
  
  The client you can't upgrade
&lt;/h2&gt;

&lt;p&gt;Most advice on API versioning assumes one of two things about your clients: you control them, or you can retire them. A web app ships its own front end, so a breaking change is a coordinated deploy. A public API has a deprecation calendar, a sunset header, an email to the developers. Either way, at some point the old client stops being your problem.&lt;/p&gt;

&lt;p&gt;A platform that publishes apps can do neither. The client is a binary on someone's phone. It was built on a day we didn't choose, and its next version, if there is one, comes from the store on a day we don't choose either. We don't force updates: an app that works keeps working, and nobody is asked to rebuild because our own formats changed. &lt;a href="https://dev.to/goodbarber/what-breaks-when-nobody-touches-your-app-for-three-years-2dgm"&gt;I've written about what that promise costs when the world around an app moves&lt;/a&gt;. This piece is about what it costs when &lt;em&gt;we&lt;/em&gt; move.&lt;/p&gt;

&lt;p&gt;And we do move. The engine gets new generations, our answers change shape, newer endpoints get a cleaner design than the old ones. Meanwhile, the apps published before each of those changes are still installed, still speaking the dialect they were built with, and still expecting an answer in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dialects, side by side
&lt;/h2&gt;

&lt;p&gt;When a new generation of the engine arrived, the old one didn't move. It kept its paths, its file layout and the shape of its answers. The new generation got its own tree next to it. The same servers answer both today, and an app built against the old generation is served the old generation, for as long as it's around.&lt;/p&gt;

&lt;p&gt;A phone only gets the new generation with a new build of the app, installed from the store. Until then, it runs the old binary, and the app's answers in the old tree are still there for it: they aren't deleted, and they keep the shape that binary was built for. The new build reads its own answers, written next to them in the new shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just force the update?
&lt;/h2&gt;

&lt;p&gt;Forcing an update would move the work onto other people. An update we force is a rebuild the owner has to run, &lt;a href="https://dev.to/goodbarber/every-rejection-becomes-a-check-gbk"&gt;a submission they have to make again&lt;/a&gt;, and a download the user has to accept: two parties doing work so that we can retire one code path. Multiplied by every app on the platform, that's a lot of work on their side to save some on ours.&lt;/p&gt;

&lt;p&gt;Keeping a dialect alive is work we do once, on our side, instead of asking every owner for a rebuild. It has a cost: we keep testing every old path, and we keep maintaining a reader for every format we still serve. It's the same reasoning that keeps &lt;a href="https://dev.to/goodbarber/the-url-on-the-flyer-doesnt-know-your-app-changed-9kn"&gt;a link printed on a flyer years ago&lt;/a&gt; opening the right screen in today's version of the app.&lt;/p&gt;

&lt;p&gt;There's a limit, and it comes from outside. When a store, an operating system or a certificate retires something an old binary relies on, that app needs a rebuild, whatever our servers answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Still asking
&lt;/h2&gt;

&lt;p&gt;Some of the apps calling our servers this morning haven't been rebuilt in years, and people still use them. Keeping them working means keeping their vocabulary: answering in the words they were built with, and retiring nothing while someone still depends on it.&lt;/p&gt;

&lt;p&gt;If you run an API for clients you can't upgrade, how do you decide when an old version can finally stop answering?&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>mobile</category>
      <category>backend</category>
      <category>api</category>
    </item>
    <item>
      <title>Agents don't mind the brackets: can AI bring back demanding languages?</title>
      <dc:creator>Mathieu Poli</dc:creator>
      <pubDate>Tue, 27 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/goodbarber/agents-dont-mind-the-brackets-can-ai-bring-back-demanding-languages-398g</link>
      <guid>https://dev.to/goodbarber/agents-dont-mind-the-brackets-can-ai-bring-back-demanding-languages-398g</guid>
      <description>&lt;p&gt;&lt;em&gt;Rust asked a lot of the people who wrote it. We're no longer the ones writing it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I had this hunch from reading what people write about Rust and agents: the languages we found too demanding might come back, because we're no longer the ones paying their price.&lt;/p&gt;

&lt;p&gt;That suited me: I started out as an Objective-C developer, and I've always kept a soft spot for this neglected language. In 2015, at dotSwift, the first Swift conference in Paris, I watched &lt;a href="https://www.youtube.com/watch?v=BVFtrrXhApw&amp;amp;t=295s" rel="noopener noreferrer"&gt;Daniel Steinberg compare it to French cuisine&lt;/a&gt;, as opposed to fast food. The image stuck with me, I still prefer fine dining, and it has become a running joke with our junior developers.&lt;/p&gt;

&lt;p&gt;In October 2026, an experiment on Objective-C added weight to that hunch. Fernando Olivares, an iOS developer at 37signals, asked two agents, Claude Opus 5.5 and GPT-6.1 Sol, to build the same chat screen in Swift and in Objective-C, then measured everything on an iPhone 12 and published &lt;a href="https://github.com/notolivaresf/uikit-vs-swiftui" rel="noopener noreferrer"&gt;the code and the results&lt;/a&gt;. The agents wrote the Objective-C, and it is the leanest version, on a single measurement pass per version: 35 MB of memory at peak, against 44.5 MB for the best Swift version built with the same framework.&lt;/p&gt;

&lt;p&gt;I'm not betting on an Objective-C comeback for all that. But what the experiment shows is serious: an agent effortlessly writes a language we found tedious, and that applies first of all to Rust.&lt;/p&gt;

&lt;h2&gt;
  
  
  The price of Rust
&lt;/h2&gt;

&lt;p&gt;Rust is the language developers admire most: in the &lt;a href="https://survey.stackoverflow.co/2026/technology" rel="noopener noreferrer"&gt;2026 Stack Overflow survey&lt;/a&gt;, 67% of the people who use it want to keep using it, a score no other language reaches. Yet many admire it from a distance. Its compiler rejects anything that isn't safe, and it takes a long time to stop fighting it.&lt;/p&gt;

&lt;p&gt;Objective-C was paid for in lines to type, with brackets everywhere. Rust is paid for in compiler rejections. In both cases, the cost fell on the people writing the code. For years, we chose our languages for their comfort.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the agent changes
&lt;/h2&gt;

&lt;p&gt;An agent doesn't get tired. Writing a forty-character method name costs it a few tokens, not its patience. And the Rust compiler's rejections, which discourage a human, become a free check on its own work.&lt;/p&gt;

&lt;p&gt;The first measurements point the same way. When &lt;a href="https://swebench.com/multilingual" rel="noopener noreferrer"&gt;SWE-bench Multilingual&lt;/a&gt; launched, a benchmark that asks an agent to fix real issues in nine languages, Rust was the language where the agent succeeded most often, and C or C++ where it succeeded least. In July, a team showed that feeding the Rust compiler's errors to the model while it writes brings the share of code that doesn't compile down from 20.7% to 13.1% (&lt;a href="https://arxiv.org/abs/2607.13921" rel="noopener noreferrer"&gt;arXiv 2607.13921&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;When writing costs almost nothing, what's left is what the language guarantees. For Rust, that's memory safety, and the stakes are concrete: at Chromium, &lt;a href="https://www.chromium.org/Home/chromium-security/memory-safety/" rel="noopener noreferrer"&gt;around 70% of serious security bugs&lt;/a&gt; are memory safety problems.&lt;/p&gt;

&lt;p&gt;And projects are already following. A team studied GitHub projects that use coding agents: among those created after August 2025, the share of Rust projects doubles, 10.5% against 5.3% among older ones, while C and C++ decline (&lt;a href="https://arxiv.org/abs/2606.07448" rel="noopener noreferrer"&gt;Robbes et al., arXiv 2606.07448&lt;/a&gt;). Niko Matsakis, one of Rust's longtime designers, &lt;a href="https://smallcultfollowing.com/babysteps/blog/2026/10/14/agentic-dev-and-rust/" rel="noopener noreferrer"&gt;sees it too&lt;/a&gt;: in his words, Rust "happens to be a really good fit for agentic coding", and he draws ideas from it for evolving the language. I see it from another angle, that of the price of these languages, which has changed hands.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I see at work
&lt;/h2&gt;

&lt;p&gt;We needed Rust on a specific project: the same calculation had to give exactly the same result in the browser and on our servers. Writing two versions, one in JavaScript and one on the server side, meant accepting that they would eventually drift apart. Rust compiles to both, but it wasn't part of our stack. We're very happy with the result and the experience, but let's be honest: without agents, we wouldn't have tried.&lt;/p&gt;

&lt;p&gt;The same mechanism plays out, more modestly, in our iOS engine. Born in Objective-C in 2011, it was gradually migrated to Swift, because it was getting hard to find developers who master Objective-C. The parts we no longer evolve stayed in their original language, and we can now fix them without depending on whoever on the team can still read it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further out: proof languages
&lt;/h2&gt;

&lt;p&gt;Rust isn't the most demanding language. With Lean or Dafny, you write the program, and also the mathematical proof that it does what you expect. The human price is huge: according to &lt;a href="https://martin.kleppmann.com/2025/12/08/ai-formal-verification.html" rel="noopener noreferrer"&gt;Martin Kleppmann&lt;/a&gt;, proving the 8,700 lines of C in the seL4 kernel took 20 person-years and about 200,000 lines of proof. Few people know how to write these proofs, and almost nobody does it for ordinary code.&lt;/p&gt;

&lt;p&gt;Kleppmann draws a prediction from it: AI will make formal verification mainstream. His argument is the Rust compiler's, pushed to the limit: the proof checker rejects any invalid proof and forces the agent to retry, so a made-up proof doesn't get through.&lt;/p&gt;

&lt;p&gt;The numbers partly bear him out. In a &lt;a href="https://arxiv.org/abs/2509.22908" rel="noopener noreferrer"&gt;study published in September 2025&lt;/a&gt;, the models' success rate on Dafny verification tasks had risen from 68% to 96% in a year. At the same date, they solved only 44% of the problems in Verus, the version of Rust built for proofs, and 27% in Lean. Verus is newer, and models have seen less of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I take from it
&lt;/h2&gt;

&lt;p&gt;The question "which language?" had stopped being asked: we took the most comfortable one, and paid the difference elsewhere, in memory vulnerabilities or in bugs no proof had ruled out. Agents reopen the question. We can once again choose a language for what it guarantees, as long as the model knows it well enough.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/photos/9TucEPaZxok" rel="noopener noreferrer"&gt;Judith Browne&lt;/a&gt; on Unsplash.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Mathieu Poli, Head of Frontend Engineering at GoodBarber. I write about frontend engineering, product design and AI.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;X: &lt;a href="https://x.com/hellomathieup" rel="noopener noreferrer"&gt;hellomathieup&lt;/a&gt; · LinkedIn: &lt;a href="https://www.linkedin.com/in/hellomathieup/" rel="noopener noreferrer"&gt;hellomathieup&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>ai</category>
      <category>computerscience</category>
      <category>ios</category>
    </item>
    <item>
      <title>Adding tools to an MCP server that ChatGPT has already approved</title>
      <dc:creator>Pierre-Laurent Medori</dc:creator>
      <pubDate>Tue, 20 Oct 2026 12:03:14 +0000</pubDate>
      <link>https://dev.to/goodbarber/adding-tools-to-an-mcp-server-that-chatgpt-has-already-approved-2cm4</link>
      <guid>https://dev.to/goodbarber/adding-tools-to-an-mcp-server-that-chatgpt-has-already-approved-2cm4</guid>
      <description>&lt;p&gt;On October 7 we added seven tools to our public MCP server: read the booking calendars of an app, list sessions, cancel a booking, manage closing days. The server is &lt;a href="https://dev.to/goodbarber/our-mcp-server-is-now-a-chatgpt-plugin-2pjm"&gt;listed in ChatGPT's directory&lt;/a&gt; and in Claude's, and people also connect to it directly from Claude Code, Cursor and others. The question I asked before anyone wrote code: how do we add tools for the direct users without touching what ChatGPT approved?&lt;/p&gt;

&lt;p&gt;One line of context: I run engineering at GoodBarber, an app platform, and &lt;a href="https://www.goodbarber.com/mcp/" rel="noopener noreferrer"&gt;our MCP server&lt;/a&gt; lets assistants manage the apps through an authenticated connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  One server, three audiences
&lt;/h2&gt;

&lt;p&gt;The first step was a map of who sees what. I had a rough idea; the code had the exact one.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;audience&lt;/th&gt;
&lt;th&gt;what it gets&lt;/th&gt;
&lt;th&gt;what a new tool does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ChatGPT app&lt;/td&gt;
&lt;td&gt;a frozen list of 45 tools, the ones OpenAI reviewed&lt;/td&gt;
&lt;td&gt;absent from the list; a call to it is refused&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude directory&lt;/td&gt;
&lt;td&gt;the full catalog, which follows each deploy&lt;/td&gt;
&lt;td&gt;appears with no new submission; the tool names shown on the listing page are updated by hand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Direct clients&lt;/td&gt;
&lt;td&gt;the full catalog&lt;/td&gt;
&lt;td&gt;appears&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So a new tool is free for the Claude directory and for direct clients, and the frozen list keeps it away from ChatGPT. That could have been the whole plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  The coupling no test covered
&lt;/h2&gt;

&lt;p&gt;The ChatGPT side is a facade over the same catalog. It picks the 45 tools and overrides their descriptions, plus one schema. Everything else it serves, the titles, the input schemas, the annotations that say whether a tool reads or writes, comes from the code shared with every other audience.&lt;/p&gt;

&lt;p&gt;That means a change made for the new tools, say a tweak to the helper that turns tool names into titles, or to the function that builds annotations, would also change what ChatGPT serves for tools OpenAI already reviewed. Nobody would notice: the existing test only checked the names and the order of the 45.&lt;/p&gt;

&lt;p&gt;So step zero, before any booking code, was a test that freezes everything ChatGPT sees, for several configurations the facade serves: name, title, description, input schema, annotations, compared to a committed reference file. Any difference fails CI and names the tool. Simplified:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_chatgpt_surface_matches_reference&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;served&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;chatgpt_tools_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SAMPLE_APP&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="n"&gt;reference&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;REFERENCE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;served&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&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;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;served&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; changed for ChatGPT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then we checked that the test can fail. The first attempt changed a field, and the test stayed green. Not because the test was wrong: that field is rewritten by an override before it reaches ChatGPT, so the change never got there. The second attempt added a field nothing overrides, and the test failed, naming the tool. A guard you haven't seen fail is a guess.&lt;/p&gt;

&lt;p&gt;A second test checks the other direction: none of the new tools reaches ChatGPT.&lt;/p&gt;

&lt;p&gt;With both in place, the booking work could touch the shared code freely. If it moved something OpenAI had approved, CI would say so before a deploy did.&lt;/p&gt;

&lt;h2&gt;
  
  
  A tool that cancels bookings
&lt;/h2&gt;

&lt;p&gt;Four of the seven tools read. The three that write are the reason this section exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Creating a closing day&lt;/strong&gt; in the back office cancels every booking on that day and sends each attendee a cancellation email. If the app's calendars inherit closing days from a default one, the closure applies to all of them. A person in the back office sees a confirmation dialog. A model sees a tool description, and a tool description is a suggestion.&lt;/p&gt;

&lt;p&gt;So the tool checks before it acts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it counts the bookings that the closure would cancel, across every calendar it would apply to;&lt;/li&gt;
&lt;li&gt;if there are any, it creates nothing and returns &lt;code&gt;confirmation_required&lt;/code&gt; with the count;&lt;/li&gt;
&lt;li&gt;it only goes ahead when the call carries &lt;code&gt;cancel_existing_bookings: true&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;it refuses past days;&lt;/li&gt;
&lt;li&gt;it is annotated as destructive, and its description says what happens to attendees.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The model can still cancel twenty bookings, and nothing stops it from setting the flag on its first call. What it can't do is cancel them by default: a call that doesn't ask for it returns the count, and the description tells the model to show that count to the user and get a yes first. That part is an instruction, not a lock.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deleting a closing day&lt;/strong&gt; makes the day bookable again if the calendar opens on it, but restores none of the bookings the closure cancelled. That one is not destructive in itself, and it's the kind of thing a model would assume: undoing the closure undoes the cancellations. It doesn't, and the description says so.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cancelling a booking&lt;/strong&gt; uses the same call as the back-office button, with the same email to the attendee. A booking that is already cancelled comes back as "not found, nothing was changed" instead of an error, so a model that retries after a timeout, or after the attendee cancelled from their own link, doesn't report a failure that didn't happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Access: a permission, not a list of pilot apps
&lt;/h2&gt;

&lt;p&gt;Booking data includes attendees' names and emails, so the tools got their own permission in our API credentials service, read-only or read-write, offered only to the app type that has bookings. Existing keys don't get it; someone has to tick it.&lt;/p&gt;

&lt;p&gt;That gave us a safe deploy order with no list of pilot apps: ship the server first, with tools nobody can see because no key carries the permission; then create the permission, which is the moment they become available. One decision went the other way on purpose: new keys pre-select every service in read-only, and bookings follow the same rule as the others.&lt;/p&gt;

&lt;h2&gt;
  
  
  The metadata that drifts
&lt;/h2&gt;

&lt;p&gt;The tools were live in minutes. The descriptions of the server took longer.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The server card&lt;/strong&gt; builds its tool list from the catalog, so it showed the new tools right after the deploy: 157, up from 150. Its version, though, was written by hand, and it was already behind the version in our registry entry. Both moved to 1.3.0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The registry entry&lt;/strong&gt; has a 100-character description. Adding "bookings" meant "mobile app" became "app". The publishing token had expired, so that took a new login.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Claude directory listing&lt;/strong&gt; shows a list of tool names that is edited by hand and validated by a reviewer, one of the details I went through in &lt;a href="https://dev.to/goodbarber/listing-a-remote-mcp-server-in-every-directory-what-each-one-actually-checks-september-2026-con"&gt;what each MCP directory actually checks&lt;/a&gt;. The tools themselves were already available to Claude users; the listing just didn't say so yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The ChatGPT app&lt;/strong&gt; needed nothing. Nothing it serves changed, and the frozen test is what lets us say that. The day bookings should reach ChatGPT, that will be a new version of the facade and a new submission, on purpose.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before the deploy, every call to the booking API ran against mocks, since only the production server is allowed to call it. The first real call came after the deploy, from a test key with only the booking permission. It worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd keep
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Map your audiences from the code, not from memory.&lt;/strong&gt; ChatGPT's frozen list was the part I knew about; the shared code underneath it was the part I didn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Freeze what a reviewer approved, not what you meant to ship.&lt;/strong&gt; Names and order are what you think of. Schemas, titles and annotations are what a shared helper changes behind your back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch the guard fail once.&lt;/strong&gt; Our first attempt at breaking it changed a field that never reaches ChatGPT.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For a model, a confirmation dialog is a refusal with a number in it.&lt;/strong&gt; Return what would happen, and require an explicit flag to make it happen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gate new tools with a permission.&lt;/strong&gt; It gives you a deploy where the code is live and the capability isn't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your MCP server is listed somewhere: what exactly did the reviewer approve, and which test would tell you it changed?&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>chatgpt</category>
      <category>python</category>
    </item>
    <item>
      <title>How do you manage CORS for thousands of customer domains? Per tenant, with three answers instead of two</title>
      <dc:creator>Pierre-Laurent Medori</dc:creator>
      <pubDate>Thu, 15 Oct 2026 13:19:58 +0000</pubDate>
      <link>https://dev.to/goodbarber/how-do-you-manage-cors-for-thousands-of-customer-domains-per-tenant-with-three-answers-instead-of-427</link>
      <guid>https://dev.to/goodbarber/how-do-you-manage-cors-for-thousands-of-customer-domains-per-tenant-with-three-answers-instead-of-427</guid>
      <description>&lt;p&gt;Our domain registry can stop answering. It can also answer that a customer's domain is no longer allowed. Both cases leave the proxy without a usable current list, but only the first gives us a reason to reuse an older one.&lt;/p&gt;

&lt;p&gt;That distinction shaped our per-tenant CORS cache. We keep valid results for 15 minutes and a fallback copy for 24 hours. An authoritative refusal erases that fallback, so the next outage cannot bring a removed domain back.&lt;/p&gt;

&lt;h2&gt;
  
  
  One domain per customer
&lt;/h2&gt;

&lt;p&gt;At &lt;a href="https://www.goodbarber.com/app-builder/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt;, the web version of each customer app has its own domain, either a platform subdomain or a custom one. We are piloting a proxy that lets widgets call third-party APIs while their private keys stay on the server. Its CORS policy has to fit that domain model, even while the service itself is limited to a small pilot.&lt;/p&gt;

&lt;p&gt;Our first static allowlist was manageable for five pilot apps. It could not follow the platform's thousands of domains as customers changed them. Allowing arbitrary origins would also defeat the restriction we wanted; PortSwigger documents that mistake in its guide to &lt;a href="https://portswigger.net/web-security/cors" rel="noopener noreferrer"&gt;CORS misconfigurations&lt;/a&gt;. We needed to resolve the list for each tenant.&lt;/p&gt;

&lt;h2&gt;
  
  
  The preflight does not know who is calling
&lt;/h2&gt;

&lt;p&gt;An actual API call carries a short-lived token naming the tenant. Its preflight arrives earlier, without that token: &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS" rel="noopener noreferrer"&gt;CORS preflight requests do not carry credentials&lt;/a&gt;. We receive an origin, a proposed method and a list of header names, so the token cannot supply the tenant for this lookup.&lt;/p&gt;

&lt;p&gt;The request path can. It contains an opaque connector handle belonging to one tenant. We resolve the handle, then the tenant's domains. Because this happens before token validation, the lookup must also tolerate requests from arbitrary callers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Origins, exactly as a browser sends them
&lt;/h2&gt;

&lt;p&gt;The registry returns a domain. A browser sends an origin, and the comparison is string equality, so the list has to be built the way a browser would write it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The default port for the scheme is omitted: &lt;code&gt;:443&lt;/code&gt; for HTTPS, &lt;code&gt;:80&lt;/code&gt; for HTTP. Non-default ports are kept.&lt;/li&gt;
&lt;li&gt;A custom domain yields two origins, with and without &lt;code&gt;www&lt;/code&gt;, since customers publish both. A subdomain of ours yields exactly one.&lt;/li&gt;
&lt;li&gt;An internationalised domain is converted to punycode, which is what the browser sends.&lt;/li&gt;
&lt;li&gt;The response carries the one matching origin and &lt;code&gt;Vary: Origin&lt;/code&gt;, as MDN recommends &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Origin" rel="noopener noreferrer"&gt;for an explicit origin&lt;/a&gt;. Never &lt;code&gt;*&lt;/code&gt;, and &lt;code&gt;Origin: null&lt;/code&gt; never matches.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A miss needs a reason
&lt;/h2&gt;

&lt;p&gt;Once the main cache entry expires, the registry lookup can produce three different outcomes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;The registry…&lt;/th&gt;
&lt;th&gt;What the proxy serves&lt;/th&gt;
&lt;th&gt;What it stores&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;answers with domains&lt;/td&gt;
&lt;td&gt;those origins&lt;/td&gt;
&lt;td&gt;the list for 15 minutes, and a second copy for 24 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cannot be reached, answers an error or sends garbage&lt;/td&gt;
&lt;td&gt;the 24-hour copy, if there is one&lt;/td&gt;
&lt;td&gt;the main entry again, for 60 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;answers clearly that the tenant has no usable domain&lt;/td&gt;
&lt;td&gt;no CORS headers&lt;/td&gt;
&lt;td&gt;an empty list for 60 seconds, and it deletes the 24-hour copy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The outage path borrows the idea of &lt;a href="https://www.rfc-editor.org/rfc/rfc5861" rel="noopener noreferrer"&gt;&lt;code&gt;stale-if-error&lt;/code&gt;&lt;/a&gt;. Apps can keep using a previously accepted origin while the registry is unavailable. Caching that fallback decision for 60 seconds reduces repeated lookups; it does not by itself guarantee a global request-rate bound when concurrent misses occur.&lt;/p&gt;

&lt;p&gt;An explicit removal has a different meaning. If we kept its old list, a later registry outage could restore the revoked origin from the 24-hour copy. Deleting that copy makes the refusal survive the outage too.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pre-auth path is bounded
&lt;/h2&gt;

&lt;p&gt;Before any I/O, we check the handle's shape and hash it for use in cache keys. Unknown handles are cached as unknown for 60 seconds. If the shared cache is unavailable, an in-process cache lasting 5 seconds, with at most 4,096 entries, limits repeated database work.&lt;/p&gt;

&lt;p&gt;The preflight path responds with &lt;code&gt;204&lt;/code&gt;. If neither the lookup nor its permitted fallback yields an allowed origin, the response omits CORS headers. Dependency errors therefore do not become a &lt;code&gt;500&lt;/code&gt;, and the browser cannot proceed with the cross-origin call after an unsuccessful preflight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat your cache as input
&lt;/h2&gt;

&lt;p&gt;The cache is shared infrastructure, so what comes out of it is validated like anything else that crosses a boundary. Values are JSON, never pickled objects. Every list read back is checked, item by item, against the same pattern that judged it on the way in. A value that exists but fails that check is ignored and logged as a tampering signal.&lt;/p&gt;

&lt;p&gt;Review found a mismatch here. Normalisation accepted a hostname containing an underscore, but cache validation used a stricter rule and rejected it. That value would have been repeatedly written, discarded and fetched again, leaving both caches ineffective for the tenant. Applying the same rule on write and read removed the disagreement.&lt;/p&gt;

&lt;h2&gt;
  
  
  CORS is not the lock
&lt;/h2&gt;

&lt;p&gt;These responses govern browser access. A client such as &lt;code&gt;curl&lt;/code&gt; does not enforce them, so actual requests still undergo token validation and the connector's permission checks. Using an older CORS list leaves those checks in place; it changes which browser origins may read the responses.&lt;/p&gt;

&lt;p&gt;The feature and its hardening landed on July 23, six minutes apart: the second commit is the adversarial review of the first. The module has 48 tests: normalisation, the three outcomes, the bounds of the pre-auth path, and the few static origins we add on top.&lt;/p&gt;

&lt;p&gt;To test the distinction, cache an allowed domain, have the registry explicitly remove it, then make the registry unavailable. The domain should remain absent. Testing an outage alone would miss the case where a cache reverses a decision it had already received.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>architecture</category>
      <category>python</category>
    </item>
    <item>
      <title>Fail-closed or fail-open? Decide per side effect, then check what your cache already decided</title>
      <dc:creator>Pierre-Laurent Medori</dc:creator>
      <pubDate>Tue, 13 Oct 2026 12:08:46 +0000</pubDate>
      <link>https://dev.to/goodbarber/fail-closed-or-fail-open-decide-per-side-effect-then-check-what-your-cache-already-decided-2pjg</link>
      <guid>https://dev.to/goodbarber/fail-closed-or-fail-open-decide-per-side-effect-then-check-what-your-cache-already-decided-2pjg</guid>
      <description>&lt;p&gt;Fail-closed: when something a decision depends on is unavailable, the answer is no.&lt;/p&gt;

&lt;p&gt;Fail-open: the answer is yes.&lt;/p&gt;

&lt;p&gt;Fail-bounded: the answer is yes, inside a limit that does not need the missing dependency.&lt;/p&gt;

&lt;p&gt;"Fail-closed or fail-open" is the wrong question at the scale of a service. Ask it per dependency and per kind of request. And before you answer, check your cache client: ours was configured to swallow errors, which turns every security decision built on it into fail-open without anyone deciding so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why two words are not enough
&lt;/h2&gt;

&lt;p&gt;The third definition has no standard name, and it is the one we use most. OWASP's principle is called &lt;a href="https://owasp.org/www-community/Fail_securely" rel="noopener noreferrer"&gt;fail securely&lt;/a&gt;, and it is usually read as "always closed". For a service that sits between customer apps and third-party APIs, always closed means that a cache outage takes every app's weather widget down with it. Always open means that the same outage removes the only brake on a connector that can charge a card.&lt;/p&gt;

&lt;p&gt;Both camps are right about their half of the traffic. So the unit of decision cannot be the service.&lt;/p&gt;

&lt;h2&gt;
  
  
  The table
&lt;/h2&gt;

&lt;p&gt;The proxy behind this table is one we are trialling at &lt;a href="https://www.goodbarber.com/app-builder/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt;. It keeps third-party API keys on the server for widgets that run inside customer apps, and today it serves a few test tenants. Its security specification settles the question in one decision, written before the code: the degraded mode depends on the side effect. A connector is registered either as read-only or as side-effecting, next to its host and its allowed methods, and every dependency gets a row. Everything below is conditional on that flag being right. The proxy applies the column it is given. It does not infer the class from the traffic, so a connector wrongly registered as read-only gets the lenient column.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;When this is unavailable&lt;/th&gt;
&lt;th&gt;Read-only connector&lt;/th&gt;
&lt;th&gt;Side-effecting connector&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The keys that verify token signatures&lt;/td&gt;
&lt;td&gt;refused, &lt;code&gt;503&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;refused, &lt;code&gt;503&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The rate limiter's shared store&lt;/td&gt;
&lt;td&gt;allowed, inside a per-process ceiling&lt;/td&gt;
&lt;td&gt;refused, &lt;code&gt;503&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The kill switch&lt;/td&gt;
&lt;td&gt;cannot be unavailable on its own: it lives in the primary database and is read on every request&lt;/td&gt;
&lt;td&gt;same&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first row has no nuance. A signature you cannot check is not a valid signature, whatever the request does. The status code matters as much as the refusal: &lt;code&gt;503&lt;/code&gt;, never &lt;code&gt;401&lt;/code&gt;. A &lt;code&gt;401&lt;/code&gt; tells the client its token is bad, and a well-behaved client responds by asking for a new one, which turns an outage of the key endpoint into a stampede on the token endpoint.&lt;/p&gt;

&lt;p&gt;The third row is a placement decision. A kill switch that lives in a cache disappears exactly when the infrastructure is having a bad day, which is when you reach for it. Ours is a column in the database the request already needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The option in the middle
&lt;/h2&gt;

&lt;p&gt;The second row did not start that way. In the first version, read-only traffic simply passed when the limiter's store was down. An adversarial review, run before the first commit of that code landed, flagged it, and the reason fits in one sentence. "Read-only" describes the effect on the third party, not the cost to us or to the customer's API quota. Fail-open on reads is still an unmetered pipe.&lt;/p&gt;

&lt;p&gt;The fix is a few lines, and it is the whole idea of fail-bounded:&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="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;connector&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# shared store
&lt;/span&gt;&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;StoreUnavailable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;side_effecting&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;FAIL_CLOSED&lt;/span&gt;                       &lt;span class="c1"&gt;# 503: no brake, no payment
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ALLOW&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;local_window_allows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;connector&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;THROTTLED&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ALLOW&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;THROTTLED&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;local_window_allows&lt;/code&gt; is a sliding one-second window kept in the worker's memory. It is cruder than the shared limiter and it needs nothing that can be down. It is also per process, so the real ceiling is that number multiplied by the number of workers. Reads keep flowing, and "how much" still has an answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setting that had already decided
&lt;/h2&gt;

&lt;p&gt;This is what I would check first in any codebase. Our Redis cache is configured with django-redis's &lt;code&gt;IGNORE_EXCEPTIONS&lt;/code&gt;, which its documentation describes as &lt;a href="https://github.com/jazzband/django-redis#memcached-exceptions-behavior" rel="noopener noreferrer"&gt;memcached-like behaviour&lt;/a&gt;: when the server is unreachable, a read returns &lt;code&gt;None&lt;/code&gt; and a write does nothing. For a cache, that is the right call. A cache that is down should look like a cache that is empty.&lt;/p&gt;

&lt;p&gt;A rate limiter built on that client inherits the setting. &lt;code&gt;incr&lt;/code&gt; does not raise. It returns &lt;code&gt;None&lt;/code&gt;. A naive comparison then either crashes or, written a little differently, treats "no answer" as "under the limit". Nobody chose fail-open. A convenience flag, set once for caching, chose it for every security decision stacked on the same client.&lt;/p&gt;

&lt;p&gt;A shared client's error policy is a security decision for everything built on top of it. So the limiter treats silence as a failure, explicitly:&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;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;incr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;              &lt;span class="c1"&gt;# the client swallowed a backend error
&lt;/span&gt;    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;StoreUnavailable&lt;/span&gt;       &lt;span class="c1"&gt;# silence is a failure, not "under the limit"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One of the six tests of that module exists only for this case, and its name says so: &lt;code&gt;none_from_ignore_exceptions_is_treated_as_unavailable&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here is the whole chain during an outage. Redis stops answering. &lt;code&gt;incr&lt;/code&gt; raises nothing and returns &lt;code&gt;None&lt;/code&gt;. The limiter calls that unavailable, and the table decides: &lt;code&gt;503&lt;/code&gt; for a connector that can charge a card, a local ceiling for one that reads the weather.&lt;/p&gt;

</description>
      <category>security</category>
      <category>architecture</category>
      <category>backend</category>
      <category>devops</category>
    </item>
    <item>
      <title>How do you prevent SSRF in a proxy that calls third-party APIs? Never accept a URL</title>
      <dc:creator>Pierre-Laurent Medori</dc:creator>
      <pubDate>Thu, 08 Oct 2026 12:03:13 +0000</pubDate>
      <link>https://dev.to/goodbarber/how-do-you-prevent-ssrf-in-a-proxy-that-calls-third-party-apis-never-accept-a-url-4l3o</link>
      <guid>https://dev.to/goodbarber/how-do-you-prevent-ssrf-in-a-proxy-that-calls-third-party-apis-never-accept-a-url-4l3o</guid>
      <description>&lt;p&gt;The contract for our API proxy has no target-URL parameter. A widget sends a connector handle and a relative path. The server loads the destination registered for that connector, then checks whether it is still safe to call.&lt;/p&gt;

&lt;p&gt;That choice rules out the usual request saying, in effect, "fetch this internal address for me". It leaves several ways for a registered destination to become unsafe, including DNS changes and redirects. Those are the cases the rest of this article covers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who gets to choose the destination?
&lt;/h2&gt;

&lt;p&gt;We built this proxy for &lt;a href="https://www.goodbarber.com/app-builder/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt;, where generated widgets need to call third-party APIs without receiving the private keys themselves. The AI extension feature is still a prototype in a small pilot. The proxy holds the credentials and injects them into outbound requests, which makes its destination checks part of the secret's protection.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP cheat sheet on SSRF&lt;/a&gt; distinguishes services that can restrict calls to known destinations from those that must fetch arbitrary URLs. We chose the first model so that an authenticated owner could approve a destination before a widget used it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision: a handle, not a URL
&lt;/h2&gt;

&lt;p&gt;The handle is a random identifier. It selects a server-side record containing the scheme, host, base path and permitted methods. The caller supplies the relative path, query and body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;What the widget sends            What the server already holds
-----------------------------    ---------------------------------------
handle:  k3J…x9  (opaque)        base URL:  https://api.example.com/v2
path:    /forecast?city=Paris    allowed hosts, allowed methods
body:    { … }                   the secret, encrypted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the owner approves an API and supplies its key, the base URL goes through the same validation function used for subsequent requests. Approval records the intended destination; it cannot guarantee what that hostname will resolve to later.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is left to defend
&lt;/h2&gt;

&lt;p&gt;Each outbound request still has to satisfy these checks:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Vector&lt;/th&gt;
&lt;th&gt;What we do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The host is an IP in disguise: &lt;code&gt;2130706433&lt;/code&gt;, &lt;code&gt;0x7f000001&lt;/code&gt;, &lt;code&gt;127.1&lt;/code&gt;, octal forms&lt;/td&gt;
&lt;td&gt;rejected before any resolution: a destination must be a real hostname, and a name whose labels are all numeric or hexadecimal is refused&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The name resolves to an internal address&lt;/td&gt;
&lt;td&gt;blocked on the resolved IP, not on the string: loopback, private ranges, link-local and its cloud metadata address, carrier-grade NAT, and their IPv6 equivalents&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One public A record and one private one&lt;/td&gt;
&lt;td&gt;every address in the answer is validated, and a single forbidden address rejects the whole answer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DNS rebinding between the check and the connection&lt;/td&gt;
&lt;td&gt;one resolution, then the connection goes to the validated IP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An IPv4 address wrapped in IPv6&lt;/td&gt;
&lt;td&gt;IPv4-mapped and NAT64 addresses are unwrapped and the embedded IPv4 is validated too&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A redirect to somewhere else&lt;/td&gt;
&lt;td&gt;redirects are never followed, the 30x status goes back to the caller as it is&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;../&lt;/code&gt; to escape the registered base path&lt;/td&gt;
&lt;td&gt;the path is normalised first, then compared with the base path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Headers that change the meaning of the request&lt;/td&gt;
&lt;td&gt;three headers are relayed from the client: &lt;code&gt;Content-Type&lt;/code&gt;, &lt;code&gt;Accept&lt;/code&gt;, &lt;code&gt;Accept-Language&lt;/code&gt;. &lt;code&gt;Host&lt;/code&gt;, forwarding headers and method overrides are set or dropped by the server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A proxy configured in the host's environment&lt;/td&gt;
&lt;td&gt;the HTTP client ignores environment proxies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lookalike hosts such as &lt;code&gt;api.example.com.evil.com&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;host matching is exact equality, never a suffix or a pattern&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The NAT64 case came from an adversarial review before the first commit. In the well-known prefix defined by &lt;a href="https://www.rfc-editor.org/rfc/rfc6052" rel="noopener noreferrer"&gt;RFC 6052&lt;/a&gt;, &lt;code&gt;64:ff9b::7f00:1&lt;/code&gt; embeds &lt;code&gt;127.0.0.1&lt;/code&gt;. An IPv6 classification alone can miss the address a translating network would reach. The embedded IPv4 address needs validation too.&lt;/p&gt;

&lt;p&gt;Cloud metadata is one reason that distinction matters. &lt;a href="https://aws.amazon.com/blogs/security/defense-in-depth-open-firewalls-reverse-proxies-ssrf-vulnerabilities-ec2-instance-metadata-service/" rel="noopener noreferrer"&gt;AWS added IMDSv2 in 2019&lt;/a&gt; as defense in depth against SSRF and related paths to instance metadata. Our proxy rejects those destinations rather than relying on the target to resist the request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connect to the address you validated
&lt;/h2&gt;

&lt;p&gt;Checking a name and then handing the same name to the HTTP client is two resolutions, and the second one can answer differently. The fix is to resolve once and pin.&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve_and_pin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;ips&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolve_ips&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;               &lt;span class="c1"&gt;# one resolution, A and AAAA
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ipaddress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ip_address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ips&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;is_blocked_ip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;                   &lt;span class="c1"&gt;# one bad address rejects the whole answer
&lt;/span&gt;            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SsrfBlocked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&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;ips&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="c1"&gt;# the address we will connect to
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The request then goes to that IP, while TLS still verifies the real hostname:&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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scheme&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;pinned_ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;headers&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="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hostname&lt;/span&gt;                                              &lt;span class="c1"&gt;# the real name, set by the server
&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&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="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;body&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="n"&gt;extensions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;extensions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sni_hostname&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;# SNI and certificate check too
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Host&lt;/code&gt; header and the SNI carry the real name, so certificate verification is as strict as on a normal call, and it is never disabled. The socket layer has nothing left to resolve. The client is created with &lt;code&gt;follow_redirects=False&lt;/code&gt; and &lt;code&gt;trust_env=False&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The read timeout that never fires
&lt;/h2&gt;

&lt;p&gt;An upstream can keep a worker occupied by sending one byte every twenty seconds. That never trips a 25-second &lt;a href="https://www.python-httpx.org/advanced/timeouts/" rel="noopener noreferrer"&gt;httpx read timeout&lt;/a&gt;, because the timeout limits the wait for each chunk. We therefore put a wall-clock deadline around the exchange, including body streaming:&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="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;forward&lt;/span&gt;&lt;span class="p"&gt;(...):&lt;/span&gt;
    &lt;span class="c1"&gt;# httpx's read timeout restarts at every chunk. This deadline does not.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_execute&lt;/span&gt;&lt;span class="p"&gt;(...),&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;TOTAL_DEADLINE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# 30 s, for a 25 s read timeout
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response size is capped the same way, by counting the bytes actually streamed, not by trusting a &lt;code&gt;Content-Length&lt;/code&gt; header.&lt;/p&gt;

&lt;h2&gt;
  
  
  The key arrives last
&lt;/h2&gt;

&lt;p&gt;Decryption happens after the destination checks. A request rejected for its method, path, host or resolved address therefore does not load the plaintext credential. Once admitted, the request receives the key for the approved upstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the decision costs
&lt;/h2&gt;

&lt;p&gt;Adding another API requires another approved connector. A widget cannot discover an arbitrary endpoint and ask our server to call it. That restriction suits a platform where an app owner approves which services receive their credentials.&lt;/p&gt;

&lt;p&gt;The egress code landed on July 7 with 105 tests, including 18 for SSRF cases such as numeric hosts, mixed DNS answers, NAT64 and rebinding. Its specification contains 19 egress requirements. The tests cover the checks; the request contract limits what those checks have to accept.&lt;/p&gt;

&lt;p&gt;In a review of another proxy, I would start where the outbound URL is constructed. Trace each component back to its source, then follow the chosen host through resolution and connection. A validator can be correct while the HTTP client quietly makes a different request.&lt;/p&gt;

</description>
      <category>security</category>
      <category>python</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How do you give local content in a WKWebView a real https origin? (iOS 17)</title>
      <dc:creator>Pierre-Laurent Medori</dc:creator>
      <pubDate>Thu, 01 Oct 2026 11:45:29 +0000</pubDate>
      <link>https://dev.to/goodbarber/how-do-you-give-local-content-in-a-wkwebview-a-real-https-origin-ios-17-2h64</link>
      <guid>https://dev.to/goodbarber/how-do-you-give-local-content-in-a-wkwebview-a-real-https-origin-ios-17-2h64</guid>
      <description>&lt;p&gt;On September 16 I opened a test widget on my iPhone. At the top, a quote that loads when the widget opens. Under it, two text areas and a Compare button that returns a similarity score. Two calls to the same third-party API with the same key: a GET for the quote, a POST for the score.&lt;/p&gt;

&lt;p&gt;The quote appeared. The Compare button answered with the only sentence its code can say when that call fails: "Impossible de calculer la similarité. Veuillez vérifier votre connexion." Unable to compute the similarity, please check your connection. The connection was fine, my Android test phone did exactly the same, and in a browser both calls worked.&lt;/p&gt;

&lt;p&gt;GET works, POST fails. I spent a morning on the verb. The cause was the page's origin, and the GET had never worked.&lt;/p&gt;

&lt;p&gt;This piece is about that origin: where a page in a &lt;code&gt;WKWebView&lt;/code&gt; gets one when its files live on the device, how our iOS engine now serves them from a fixed &lt;code&gt;https://secure.internal&lt;/code&gt; with a proxy API that arrived in iOS 17, and what changing an origin breaks in places no crash log reaches. The widget comes back at the end, once the dates make sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why local content needs an origin at all
&lt;/h2&gt;

&lt;p&gt;At &lt;a href="https://www.goodbarber.com/app-builder/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt;, an app builder for native iOS and Android apps, some sections of an app are web content: custom code written by the app's owner, HTML widgets, extensions. The files live on the device so that the section opens offline. On iOS they are displayed in a &lt;code&gt;WKWebView&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;How those files are served decides whether the page is a &lt;a href="https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts" rel="noopener noreferrer"&gt;secure context&lt;/a&gt;, a prerequisite for APIs such as &lt;code&gt;crypto.subtle&lt;/code&gt;. It also sets the origin seen by CORS and CSP checks, cookie behaviour, and where &lt;code&gt;localStorage&lt;/code&gt; and IndexedDB data are stored.&lt;/p&gt;

&lt;p&gt;One of those checks is ours. My team is piloting an extension builder, where I look after the backend: the owner of an app asks for a feature in a sentence, a model writes it, and the result runs in the app's web version and inside its native apps. A widget that needs a private API key never holds it. The page frames a small page served by our token issuer, gets a short-lived token, and calls a proxy of ours that keeps the key. The issuer lets only approved origins frame its page, through a CSP &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/frame-ancestors" rel="noopener noreferrer"&gt;&lt;code&gt;frame-ancestors&lt;/code&gt;&lt;/a&gt; directive. So the origin a native shell gives its local files decides whether any of those widgets can work at all. The builder has not shipped to customers, and the phones in this story are my own test devices.&lt;/p&gt;

&lt;p&gt;Change the serving mechanism and all of that changes with it. We went through three versions in one year.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three origins in one year
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Period&lt;/th&gt;
&lt;th&gt;How the page was served&lt;/th&gt;
&lt;th&gt;Origin&lt;/th&gt;
&lt;th&gt;What it cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;before June 2026&lt;/td&gt;
&lt;td&gt;an HTML string with a custom base URL, files through a &lt;code&gt;WKURLSchemeHandler&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;a custom scheme&lt;/td&gt;
&lt;td&gt;not a secure context, an &lt;code&gt;Origin&lt;/code&gt; header that third-party servers do not recognise, &lt;code&gt;Secure&lt;/code&gt; cookies ignored, URL rewriting and a permissive CSP to hold it together&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;June to August 2026&lt;/td&gt;
&lt;td&gt;a small HTTP server inside the app, bound to loopback&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://127.0.0.1:&amp;lt;port&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a secure context and plain HTTP semantics, but an IP address and a changing port in the origin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;since September 2, 2026&lt;/td&gt;
&lt;td&gt;a loopback CONNECT proxy in front of a TLS listener&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://secure.internal&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;iOS 17 minimum for this approach, and web storage starting from empty once&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The second step was already a big one, and our iOS team built it as a real server: request parser, range requests for media, keep-alive, recovery when the listener dies. It also relays the page's cross-origin &lt;code&gt;fetch&lt;/code&gt; calls, which the shell rewrites towards it. Loopback is a &lt;a href="https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts" rel="noopener noreferrer"&gt;potentially trustworthy origin&lt;/a&gt;, so the page became a secure context and third-party scripts finally saw an &lt;code&gt;http&lt;/code&gt; origin they understood.&lt;/p&gt;

&lt;p&gt;The remaining problem was the port, assigned at runtime. There was no single origin to put in a remote allowlist. On July 30 the token issuer started accepting &lt;code&gt;http://127.0.0.1:*&lt;/code&gt;, which is valid CSP and accepts any port, including any unrelated local server on the phone. A fixed &lt;code&gt;https&lt;/code&gt; name would give the app and those services a value they could agree on.&lt;/p&gt;

&lt;h2&gt;
  
  
  WebKit will not hand over https
&lt;/h2&gt;

&lt;p&gt;Apple's documentation for &lt;a href="https://developer.apple.com/documentation/webkit/wkwebviewconfiguration/seturlschemehandler%28_:forurlscheme:%29" rel="noopener noreferrer"&gt;&lt;code&gt;setURLSchemeHandler(_:forURLScheme:)&lt;/code&gt;&lt;/a&gt; rules out the direct approach: registering a handler for a scheme WebKit already handles, such as &lt;code&gt;https&lt;/code&gt;, raises an exception.&lt;/p&gt;

&lt;p&gt;Android takes the opposite position. &lt;code&gt;shouldInterceptRequest&lt;/code&gt; lets an app answer any request, &lt;code&gt;https&lt;/code&gt; included, and &lt;a href="https://developer.android.com/reference/androidx/webkit/WebViewAssetLoader" rel="noopener noreferrer"&gt;&lt;code&gt;WebViewAssetLoader&lt;/code&gt;&lt;/a&gt; packages the pattern with a domain reserved for it, &lt;code&gt;appassets.androidplatform.net&lt;/code&gt;. Our Android engine moved from &lt;code&gt;file://&lt;/code&gt; to that loader on July 31, a fixed &lt;code&gt;https&lt;/code&gt; origin came with it, and the issuer accepted it the same day.&lt;/p&gt;

&lt;p&gt;That Android approach does not transfer to &lt;code&gt;WKURLSchemeHandler&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not a real certificate for a real domain?
&lt;/h2&gt;

&lt;p&gt;The tempting shortcut is a public hostname that resolves to &lt;code&gt;127.0.0.1&lt;/code&gt;, a certificate from a public authority, and the private key shipped in the app. Let's Encrypt has a page about &lt;a href="https://letsencrypt.org/docs/certificates-for-localhost/" rel="noopener noreferrer"&gt;certificates for localhost&lt;/a&gt; whose answer is: don't. A private key distributed in an app is a compromised key, the certificate may be revoked, and the DNS lookup gives an attacker something to intercept. It also makes an offline section depend on DNS.&lt;/p&gt;

&lt;p&gt;We could still use a certificate whose trust was limited to our own WebView. It did not need recognition from a public authority.&lt;/p&gt;

&lt;h2&gt;
  
  
  The piece that arrived with iOS 17
&lt;/h2&gt;

&lt;p&gt;iOS 17 added &lt;a href="https://developer.apple.com/documentation/webkit/wkwebsitedatastore/proxyconfigurations-cdc1" rel="noopener noreferrer"&gt;&lt;code&gt;proxyConfigurations&lt;/code&gt;&lt;/a&gt; to &lt;code&gt;WKWebsiteDataStore&lt;/code&gt;, fed by the Network framework's &lt;a href="https://developer.apple.com/documentation/network/proxyconfiguration" rel="noopener noreferrer"&gt;&lt;code&gt;ProxyConfiguration&lt;/code&gt;&lt;/a&gt;. Apple presented it at WWDC23 for &lt;a href="https://developer.apple.com/videos/play/wwdc2023/10002/" rel="noopener noreferrer"&gt;network relays&lt;/a&gt;, with a remote relay in the example. Nothing says the proxy has to be remote.&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="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;Network&lt;/span&gt;
&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;WebKit&lt;/span&gt;

&lt;span class="c1"&gt;// The CONNECT proxy is a listener our own app opened on loopback.&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;proxy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;NWEndpoint&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hostPort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"127.0.0.1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;NWEndpoint&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;Port&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;rawValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;connectProxyPort&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="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;configuration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;ProxyConfiguration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;httpCONNECTProxy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;proxy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;tlsOptions&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="n"&gt;configuration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;matchDomains&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"secure.internal"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;// only this name uses the proxy&lt;/span&gt;
&lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allowFailover&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;               &lt;span class="c1"&gt;// never try a direct connection&lt;/span&gt;

&lt;span class="kt"&gt;WKWebsiteDataStore&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;default&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;proxyConfigurations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those two properties carry the design. With &lt;code&gt;matchDomains&lt;/code&gt;, WebKit sends &lt;code&gt;CONNECT secure.internal:443&lt;/code&gt; to our listener instead of resolving the name, so the name never reaches DNS and the section opens in airplane mode. With &lt;code&gt;allowFailover&lt;/code&gt; off, WebKit never falls back to a direct connection, so the name cannot leak to a resolver while our server is restarting.&lt;/p&gt;

&lt;p&gt;Inside the app, three loopback listeners do the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A TLS listener serves the files. Its identity is embedded in the app: self-signed, RSA 2048, a single SAN &lt;code&gt;secure.internal&lt;/code&gt;, TLS 1.2 minimum, ALPN &lt;code&gt;http/1.1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A CONNECT proxy on a port the OS assigns. It accepts exactly one request, &lt;code&gt;CONNECT secure.internal:443&lt;/code&gt;, and pipes the bytes to the TLS listener. Anything else gets a 403.&lt;/li&gt;
&lt;li&gt;A plain HTTP fallback for media, in case the media stack does not follow the data store's proxy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The configuration is pushed again every time the server restarts, since the port changes, and before the WebViews are told to reload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Accept one certificate, for one host
&lt;/h2&gt;

&lt;p&gt;A self-signed certificate fails the system's trust evaluation. That is expected, and the navigation delegate is where it is settled. Ours is Objective-C. Here is the same logic in Swift:&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="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;webView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;webView&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;WKWebView&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="n"&gt;didReceive&lt;/span&gt; &lt;span class="nv"&gt;challenge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;URLAuthenticationChallenge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="nv"&gt;completionHandler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;@escaping&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;URLSession&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;AuthChallengeDisposition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;URLCredential&lt;/span&gt;&lt;span class="p"&gt;?)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Void&lt;/span&gt;&lt;span class="p"&gt;)&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;space&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;challenge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;protectionSpace&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;space&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;authenticationMethod&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kt"&gt;NSURLAuthenticationMethodServerTrust&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="n"&gt;space&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;caseInsensitiveCompare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"secure.internal"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orderedSame&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;trust&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;space&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serverTrust&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;completionHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;performDefaultHandling&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="c1"&gt;// every other host: the normal path&lt;/span&gt;
        &lt;span class="k"&gt;return&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;chain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;SecTrustCopyCertificateChain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trust&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;SecCertificate&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;leaf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kt"&gt;SecCertificateCopyData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kt"&gt;Data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;leaf&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;pinnedCertificateDER&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;                     &lt;span class="c1"&gt;// byte-identical to the embedded one&lt;/span&gt;
        &lt;span class="nf"&gt;completionHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;useCredential&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;URLCredential&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;trust&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;trust&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;completionHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cancelAuthenticationChallenge&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="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;The exception applies only to that host and that exact certificate. Every other host follows normal trust evaluation.&lt;/p&gt;

&lt;p&gt;The embedded key is shared across our apps and can be extracted from a binary. We treat it accordingly. The design rests on the local routing and on the delegate's narrow trust decision; it does not give the certificate public authority or make it an app identity.&lt;/p&gt;

&lt;p&gt;One more gate sits in front of the files. Loopback listeners are reachable by other processes on the device, so every URL starts with a random token generated for the session, and a request without it gets a 403.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing the name
&lt;/h2&gt;

&lt;p&gt;Since the name never touches DNS, it is cosmetic. It shows up in &lt;code&gt;location.origin&lt;/code&gt;, in the web inspector and in server-side allowlists, never in front of a user. Two constraints still apply: the suffix must never be delegated on the public DNS, and no certificate authority must ever issue for it.&lt;/p&gt;

&lt;p&gt;That rules out &lt;code&gt;.app&lt;/code&gt;, which is a real top-level domain, and &lt;code&gt;.local&lt;/code&gt;, which belongs to multicast DNS and answers on the local network. It points at &lt;code&gt;.internal&lt;/code&gt;, which the ICANN Board &lt;a href="https://www.icann.org/en/board-activities-and-meetings/materials/approved-resolutions-special-meeting-of-the-icann-board-29-07-2024-en" rel="noopener noreferrer"&gt;reserved for private-use applications&lt;/a&gt; on July 29, 2024, permanently excluding it from delegation in the DNS root zone.&lt;/p&gt;

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

&lt;p&gt;The new engine landed on September 2, and the issuer swapped &lt;code&gt;http://127.0.0.1:*&lt;/code&gt; for &lt;code&gt;https://secure.internal&lt;/code&gt; the same day. These are the September 4 verification runs: iPhone 15 Pro simulator, iOS 17.4, our internal app in Debug, two real customer apps loaded in it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&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;Page loads logging &lt;code&gt;location.origin&lt;/code&gt; and &lt;code&gt;isSecureContext&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;43 loads across plugin sections, HTML widgets and scratch sections, 43 times &lt;code&gt;https://secure.internal&lt;/code&gt; and &lt;code&gt;true&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;crypto.subtle&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;present&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TLS handshake through the tunnel&lt;/td&gt;
&lt;td&gt;TLS 1.3, 6 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Import of the embedded identity&lt;/td&gt;
&lt;td&gt;3.7 to 4.0 ms, measured four times&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Live CONNECT tunnels&lt;/td&gt;
&lt;td&gt;peak of 1, back to 0 after each cycle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Native bridge, storage round trip, cross-origin GET and POST relays&lt;/td&gt;
&lt;td&gt;exercised on 9 scopes towards 3 remote hosts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backgrounding for 50 seconds, then return&lt;/td&gt;
&lt;td&gt;the WebView survives and the appear event fires again&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Video rendering&lt;/td&gt;
&lt;td&gt;not testable on the simulator, whose WebKit GPU process is denied GPU access; normal on a device&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What the https origin changed
&lt;/h2&gt;

&lt;p&gt;Mixed content appeared. Old custom code links &lt;code&gt;http://fonts.googleapis.com/…&lt;/code&gt; stylesheets, in dozens of HTML files across 12 sections of a single test app. Under an &lt;code&gt;http&lt;/code&gt; origin nobody noticed. Under &lt;code&gt;https&lt;/code&gt;, WebKit blocks them as active mixed content and the page silently loses its fonts. The server now adds &lt;code&gt;Content-Security-Policy: upgrade-insecure-requests&lt;/code&gt; to HTML documents, and to nothing else. Our Android engine met the same files after its own migration.&lt;/p&gt;

&lt;p&gt;Tunnels have to die on the first EOF. A 28-minute capture showed 68 CONNECT tunnels alive at the same instant, all killed together with &lt;code&gt;ENETDOWN&lt;/code&gt; when the app was suspended. Our relay waited for both directions to finish before closing, and WebKit keeps its side open and idle. A CONNECT tunnel is done as soon as either peer stops writing. With that rule, and a counter logged on every open and close, the peak is 1. &lt;code&gt;ENETDOWN&lt;/code&gt; on suspension is now logged as a routine end of life, because it is one.&lt;/p&gt;

&lt;p&gt;Web storage starts from empty. &lt;code&gt;localStorage&lt;/code&gt;, IndexedDB and cookies are filed by origin, and WebKit offers no supported way to move them from one origin to another: &lt;code&gt;WKWebsiteDataStore&lt;/code&gt; can enumerate and delete by origin, not read or rewrite. A custom section that kept a login token asks for the login once after the update. That is the visible price of an origin change.&lt;/p&gt;

&lt;p&gt;The fourth change has no log line at all. It is the widget from the first paragraph.&lt;/p&gt;

&lt;h2&gt;
  
  
  Back to September 16
&lt;/h2&gt;

&lt;p&gt;The phones ran internal builds of our shells from early summer. That morning I built both engines on my laptop and ran the same widget on fresh installs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Surface&lt;/th&gt;
&lt;th&gt;Native shell&lt;/th&gt;
&lt;th&gt;GET, the quote&lt;/th&gt;
&lt;th&gt;POST, the score&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Web&lt;/td&gt;
&lt;td&gt;none, a browser&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iOS simulator&lt;/td&gt;
&lt;td&gt;built that morning&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android emulator&lt;/td&gt;
&lt;td&gt;built that morning&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;ok, 48%, no console error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test iPhone&lt;/td&gt;
&lt;td&gt;internal build, early summer&lt;/td&gt;
&lt;td&gt;a quote was displayed&lt;/td&gt;
&lt;td&gt;error message&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test Android phone&lt;/td&gt;
&lt;td&gt;internal build, early summer&lt;/td&gt;
&lt;td&gt;a quote was displayed&lt;/td&gt;
&lt;td&gt;error message&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same bundle, same backend, same key. The only variable left was the shell installed on the phones. Read the last two rows again: I wrote "a quote was displayed", not "ok". That morning I wrote "ok".&lt;/p&gt;

&lt;p&gt;The verb was a good suspect, because a WebView does treat a request with a body differently. Browsers attach an &lt;code&gt;Origin&lt;/code&gt; header to a same-origin POST and not to a same-origin GET, as the &lt;a href="https://fetch.spec.whatwg.org/#origin-header" rel="noopener noreferrer"&gt;Fetch standard&lt;/a&gt; specifies, so any guard that compares &lt;code&gt;Origin&lt;/code&gt; to an expected value is only ever exercised by POSTs. On Android, &lt;code&gt;shouldInterceptRequest&lt;/code&gt; hands the app a &lt;a href="https://developer.android.com/reference/android/webkit/WebResourceRequest" rel="noopener noreferrer"&gt;&lt;code&gt;WebResourceRequest&lt;/code&gt;&lt;/a&gt; with a URL, a method and headers, and no body. On iOS, our relay has to buffer the whole body before forwarding anything, and a GET has nothing to buffer. One fact should have cooled me down: both calls carried a bearer token in an &lt;code&gt;Authorization&lt;/code&gt; header, which makes both of them preflighted cross-origin requests. For CORS, my GET and my POST were in the same class.&lt;/p&gt;

&lt;p&gt;The coding agent I was pairing with asked for one trace from the phone, Safari's Web Inspector or &lt;code&gt;chrome://inspect&lt;/code&gt;. I chose to build instead, because a build feels like progress. It worked, and this went into my notes: an outdated app relays the GETs and loses the body of the POSTs; check the build date before you look at the server. The second half is good advice. The first half is a mechanism nobody observed. The fix was right, the cause was invented.&lt;/p&gt;

&lt;p&gt;Two days later I asked the same agent to draft an article from that session. Before writing the mechanism down for strangers, it checked it against git history, and the dates in this article do not allow it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The iOS relay has buffered the complete body since the day it was written, June 4. There is no older relay that drops bodies for a phone to be outdated with.&lt;/li&gt;
&lt;li&gt;On September 2 the issuer stopped accepting &lt;code&gt;http://127.0.0.1:*&lt;/code&gt;. My commit message gives the reason: the feature is unreleased, and an app is rebuilt when the feature is activated for it. An iPhone build older than September 2 can no longer frame the token page. No frame, no token. No token, no call, whatever the verb.&lt;/li&gt;
&lt;li&gt;Android builds older than July 31 load the page from &lt;code&gt;file://&lt;/code&gt;. A &lt;code&gt;file://&lt;/code&gt; page has an opaque origin, and an opaque origin cannot be listed in &lt;code&gt;frame-ancestors&lt;/code&gt;. No token there either.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So on those phones the GET could not have worked. Yet both showed a quote. The simulator still had the bundle it downloaded on the 16th, with the function that loads the quote, as the model wrote it (key name and author line shortened):&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="nf"&gt;gbProxyFetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&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;/v1/quotes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Erreur API&lt;/span&gt;&lt;span class="dl"&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;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;quotes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* render quotes[0] */&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;quote-text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;« La simplicité est la sophistication suprême. »&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="cm"&gt;/* ...and "Léonard de Vinci" in quote-author */&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a shell that cannot frame the token page, the token request fails, the promise rejects, and this &lt;code&gt;catch&lt;/code&gt; prints Leonardo da Vinci. That is what both phones were showing: Leonardo, in French. The POST went through the same failure with nothing to fall back on, so it told me to check my connection.&lt;/p&gt;

&lt;p&gt;The asymmetry was not between two HTTP verbs. It was between two &lt;code&gt;catch&lt;/code&gt; blocks. Nothing had worked on the phones; one of the two failures was dressed as a success. The evidence was on the screen: the API returns its quotes in English, the fallback is in French. And the SDK rejects with a typed error whose &lt;code&gt;kind&lt;/code&gt; names the stage that failed, &lt;code&gt;init&lt;/code&gt; when no token could be obtained. Both &lt;code&gt;catch&lt;/code&gt; blocks received it. Neither read it.&lt;/p&gt;

&lt;h2&gt;
  
  
  An origin is versioned by the binary
&lt;/h2&gt;

&lt;p&gt;A feature that lives in a web page inherits the page's origin, and inside a native app the shell chooses it: &lt;code&gt;file://&lt;/code&gt;, a loopback port, a custom scheme, a fixed &lt;code&gt;https&lt;/code&gt; name. CORS, CSP &lt;code&gt;frame-ancestors&lt;/code&gt;, cookies and every server-side allowlist key on it. So every allowlist that names an origin is, in practice, a list of builds, and the builds are installed on people's phones on dates you do not control.&lt;/p&gt;

&lt;p&gt;The switch of September 2 was a deliberate decision, and it holds: a rebuilt app carries the current shell, the current shell serves the origin the issuer expects, and the two move together. An app owner never meets the old origin. That works because the platform owns both ends, the native shell and the services behind it, which is also why every date in this article is known to the day. The one way to meet the old origin was to test an unreleased feature on an internal build from early summer, on the phone of the person who made the switch.&lt;/p&gt;

&lt;p&gt;On the company blog I wrote about &lt;a href="https://www.goodbarber.com/blog/what-quietly-breaks-when-you-don-t-update-your-app-and-why-you-never-notice-on-goodbarber-a1566/" rel="noopener noreferrer"&gt;what quietly breaks when an app is not updated&lt;/a&gt;: store visibility, push delivery, OS changes. This is the same decay seen from the server side. An allowlist moves, and every build older than that date loses a capability, without a crash and without a line in the phone's log.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I check first now
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Is the half that works real?&lt;/strong&gt; Before comparing a GET and a POST, find out whether the GET's data came from the network. Search the page for &lt;code&gt;catch&lt;/code&gt;. If a model wrote the code, expect a fallback: models are polite about failure, they fill the hole.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One trace from the failing device.&lt;/strong&gt; Web Inspector or &lt;code&gt;chrome://inspect&lt;/code&gt;, five minutes. It would have shown no request to the proxy at all, neither GET nor POST, and a refused frame in the console.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The build date of the shell,&lt;/strong&gt; before any server log, whenever the report says "fine on the web, broken in the app". Then compare it with the dates your allowlists changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Substitution, last.&lt;/strong&gt; The same bundle on a fresh shell proves where the fault is not, and says nothing about what happened on the old one. "Works on the simulator I built this morning" and "works on the phone in my pocket" are different claims. That gap is where a correct fix picked up an invented cause.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The model's fallback stays. A quote widget that shows a default quote instead of a broken box is the right behaviour for the person using the app; it is only a trap for the person debugging it. So its firing becomes visible:&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="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[quote] proxy call failed:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// the line that was missing&lt;/span&gt;
  &lt;span class="nf"&gt;showFallbackQuote&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;With that line, the first look at the console says &lt;code&gt;init&lt;/code&gt;, no token, before anyone opens Xcode. Degrade gracefully for the user, loudly for the developer.&lt;/p&gt;

&lt;h2&gt;
  
  
  After the migration
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;https://secure.internal&lt;/code&gt; is shared across our apps, just as &lt;code&gt;appassets.androidplatform.net&lt;/code&gt; is shared by Android apps using that loader. It gives browser policies a stable value to compare. Authentication still needs its own mechanism, which is what the token is for.&lt;/p&gt;

&lt;p&gt;The practical gain is that local content gets ordinary HTTPS behaviour while staying available offline. The cost sits in everything attached to the previous origin: storage, remote allowlists, and the builds already installed. That is why the new name is boring, fixed, and meant to stay. Changing it later is another migration, even if every HTML file stays the same, and the phones in people's pockets will not follow.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>webdev</category>
      <category>security</category>
      <category>debugging</category>
    </item>
    <item>
      <title>The assistant is making the native app mandatory again</title>
      <dc:creator>Mathieu Poli</dc:creator>
      <pubDate>Thu, 01 Oct 2026 07:25:35 +0000</pubDate>
      <link>https://dev.to/goodbarber/the-assistant-is-making-the-native-app-mandatory-again-dh5</link>
      <guid>https://dev.to/goodbarber/the-assistant-is-making-the-native-app-mandatory-again-dh5</guid>
      <description>&lt;p&gt;For ten years, a well-built website was an acceptable answer to the question "do I need an app?". This year, Apple and Google settled how the phone's assistant will act for us, and that answer no longer quite holds. The app is becoming an entry ticket again, and not for the reasons of 2008.&lt;/p&gt;

&lt;p&gt;My colleague Dominique Siacci explained at the end of September &lt;a href="https://dev.to/goodbarber/agents-wont-kill-apps-on-a-phone-they-cant-act-without-them-58h6"&gt;why agents won't kill apps&lt;/a&gt;: on a phone, they can't act without them, because the app already carries the identity, the rights and the relationship an agent needs. I'd like to draw the consequence for the question we've been asked for fifteen years, and which is changing its answer for the third time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three answers to the same question
&lt;/h2&gt;

&lt;p&gt;I've been building app platforms since 2011, and I've watched the answer to "do I need an app?" change twice.&lt;/p&gt;

&lt;p&gt;The first answer was yes, no discussion. Between 2008 and 2014, being on the home screen meant existing. An icon on the phone was worth a shop window downtown. That rush is what gave birth to GoodBarber, and to dozens of tools like it.&lt;/p&gt;

&lt;p&gt;Then the web took back control. &lt;a href="https://www.goodbarber.com/blog/ios-opens-its-doors-to-progressive-web-apps-a898/" rel="noopener noreferrer"&gt;Progressive Web Apps&lt;/a&gt;, Google pushing the browser as a platform, and a decade of "you don't need an app, a website is enough". The argument was solid. So solid that we added a PWA engine next to our native engines, in 2017, and we still sell it. One project description, three outputs: Swift, Kotlin, and the web. When I say the app had become optional, I say it as someone who built the option.&lt;/p&gt;

&lt;p&gt;The third answer is taking shape this year.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed this year
&lt;/h2&gt;

&lt;p&gt;At WWDC in June, Apple made &lt;a href="https://developer.apple.com/videos/play/wwdc2026/343/" rel="noopener noreferrer"&gt;App Intents&lt;/a&gt; the door through which Siri gets into third-party apps, and deprecated the old mechanism, SiriKit. iOS 27, released on September 14, launched a new Siri meant to act and search inside apps from a single sentence. It's early days. &lt;a href="https://www.apple.com/newsroom/2026/09/siri-ai-a-profoundly-more-capable-and-personal-assistant-is-here/" rel="noopener noreferrer"&gt;According to Apple&lt;/a&gt;, it's in beta and English first. It isn't available on iPhones in the European Union, and actions in third-party apps are coming "soon". Android is heading the same way with &lt;a href="https://developer.android.com/blog/posts/the-intelligent-os-making-ai-agents-more-helpful-for-android-apps" rel="noopener noreferrer"&gt;AppFunctions&lt;/a&gt;, shipped in Android 16 and still in beta with Gemini. Dominique covers the mechanism, so I won't go over it again.&lt;/p&gt;

&lt;p&gt;The door itself isn't new: App Intents have existed since 2022. What changes this year is that Apple makes it the standard way in, and that the assistant will walk through it on its own, from a sentence.&lt;/p&gt;

&lt;p&gt;For the question of this article, one thing matters: both mechanisms are native development kits, built into an app's binary, and a web page can't register with them. There is work on the web side, &lt;a href="https://developer.chrome.com/blog/webmcp-epp" rel="noopener noreferrer"&gt;WebMCP&lt;/a&gt;, but it targets agents running in the browser, on an open page. The phone's assistant only looks at what's installed. I'm not saying anything about adoption, which nobody knows, or about when everyone will get access. I'm saying the entry ticket is defined, by both platforms, the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this isn't 2008
&lt;/h2&gt;

&lt;p&gt;It's tempting to read this as a step back.&lt;/p&gt;

&lt;p&gt;In 2008, having an app was about capturing attention: an icon on the home screen, notifications. The app was a shop window. Tomorrow, having an app will be about being present in the assistant: when someone says "get me a seat for Saturday's concert", it's your app or someone else's that answers. The first was won with design and marketing. The second is won by describing your app, what it does as well as what it holds, precisely enough for a machine to use it.&lt;/p&gt;

&lt;p&gt;There's a condition, and Dominique puts it himself on his list of what would prove him wrong: the assistant only calls what's installed. To be present in the assistant, you first have to be installed, and that's exactly the 2008 problem: the shop window is still the prerequisite.&lt;/p&gt;

&lt;p&gt;This isn't the story &lt;a href="https://dev.to/goodbarber/everyone-is-going-back-to-native-we-never-left-and-the-bet-paid-in-a-way-we-didnt-expect-58lp"&gt;I told in September&lt;/a&gt; either. There, I was talking about the cost of building: coding agents make it cheap to build the same app three times. Here, it's about distribution: who gets to be in the assistant. Two different mechanisms, pushing in the same direction.&lt;/p&gt;

&lt;h2&gt;
  
  
  So, do you need an app in 2026?
&lt;/h2&gt;

&lt;p&gt;The question "do I need an app?" has had one answer per era. Yes, to be seen. No, a website is enough. And now: if you want to exist, one way or another, in the phone's assistant, yes.&lt;/p&gt;

&lt;p&gt;First, to act. Booking a class, ordering, tracking a delivery, moving an appointment: these are exactly the sentences people will say to the assistant, and it can only carry them out in a native app that has declared those actions.&lt;/p&gt;

&lt;p&gt;But also to be found. The assistant searches as much as it acts. When someone asks it to "find me a concert in Ajaccio next weekend", it will look in the content that installed apps have described to it: Apple &lt;a href="https://developer.apple.com/videos/play/wwdc2026/240/" rel="noopener noreferrer"&gt;explicitly plans&lt;/a&gt; for Siri to answer questions about an app's content. It's already what apps built on GoodBarber do when you ask Siri "what's new?", from iOS 17, without their owners having to touch anything. The rollout happens in stages, and my colleague João Aleixo &lt;a href="https://www.goodbarber.com/blog/siri-actions-the-mcp-of-your-ios-app-a1616/" rel="noopener noreferrer"&gt;explains how on our blog&lt;/a&gt;. A cultural events calendar or a local news outlet has as much to gain as a booking app.&lt;/p&gt;

&lt;p&gt;And the assistant doesn't only live in the phone. In the car, few apps get a place on the CarPlay screen: Apple reserves it for a few categories, navigation or audio for instance, and rebuilding an interface for the dashboard rarely makes sense. But Siri is in the car, and an app that has declared its actions can answer there by voice.&lt;/p&gt;

&lt;p&gt;The website keeps its role: it's what people find on Google, and it's what welcomes the visitor who comes once and won't install anything. We still sell PWAs, and many projects will have both: the website for Google, the app for the assistant.&lt;/p&gt;

&lt;p&gt;It isn't urgent. If your users are in Europe, the new Siri hasn't reached them yet, and I don't know when it will. But the entry ticket is already set, and it's a native app.&lt;/p&gt;

&lt;p&gt;The web had made the app optional. The assistant is making it mandatory for anyone who wants to be within earshot.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Mathieu Poli, Head of Frontend Engineering @ &lt;a href="https://goodbarber.com/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt;. I teach and write about frontend engineering, product design, and AI, and everything that happens when the three meet.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;X: &lt;a href="https://x.com/hellomathieup" rel="noopener noreferrer"&gt;hellomathieup&lt;/a&gt; · LinkedIn: &lt;a href="https://www.linkedin.com/in/hellomathieup/" rel="noopener noreferrer"&gt;hellomathieup&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>mcp</category>
      <category>mobile</category>
      <category>ios</category>
    </item>
    <item>
      <title>Agents won't kill apps... on a phone, they can't act without them</title>
      <dc:creator>Dominique Siacci</dc:creator>
      <pubDate>Tue, 29 Sep 2026 07:08:15 +0000</pubDate>
      <link>https://dev.to/goodbarber/agents-wont-kill-apps-on-a-phone-they-cant-act-without-them-58h6</link>
      <guid>https://dev.to/goodbarber/agents-wont-kill-apps-on-a-phone-they-cant-act-without-them-58h6</guid>
      <description>&lt;p&gt;At WWDC this year, in the keynote, Apple confirmed something I had been holding as a logical intuition more than as an open question: when the assistant that lives in your phone wants to do something for you, it goes into the apps installed on that phone. An app declares the actions it can perform, and that is what Siri calls. On Android, Google is building the same door under the name AppFunctions, and describes it in its own documentation as the on-device equivalent of the tools an agent calls over MCP.&lt;/p&gt;

&lt;p&gt;Two operating systems, the same year, the same decision. Meanwhile what you keep reading in 2026 is that apps are on their way out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I'm standing
&lt;/h2&gt;

&lt;p&gt;I am a co-founder of GoodBarber, a platform that builds and publishes mobile apps, where I run engineering and a little bit of everything. So a piece by me arguing that apps have a future deserves the suspicion you'd give a baker writing about bread. Read it that way. Further down I list what would prove me wrong, and I'll come back with numbers when we have them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The case against apps, taken seriously
&lt;/h2&gt;

&lt;p&gt;Carl Pei, the CEO of Nothing, &lt;a href="https://techcrunch.com/2026/03/18/nothing-ceo-carl-pei-says-smartphone-apps-will-disappear-as-ai-agents-take-their-place/" rel="noopener noreferrer"&gt;said it plainly at SXSW this spring&lt;/a&gt;: apps are going to disappear. The argument is coherent. An agent doesn't need a screen to order a pizza; it needs an endpoint. If it can reach your service through an API or a website, the app in between is a detour, and a company's real product becomes the surface its agents can call.&lt;/p&gt;

&lt;p&gt;I believe half of it. Agents do remove navigation: nobody wants to tap through four screens when a sentence does the job. A clean, documented surface matters more than it ever did. And we run one ourselves: &lt;a href="https://dev.to/pierrelaurentmedori/building-a-production-mcp-server-how-we-made-goodbarber-agent-ready-without-the-glue-code-4co6"&gt;a production MCP server&lt;/a&gt; that agents call all day to manage apps, without opening a single one of them.&lt;/p&gt;

&lt;p&gt;The disagreement is about where the agent goes when the user is holding a phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I think, and I'll own it
&lt;/h2&gt;

&lt;p&gt;Apple clearly wants everything the assistant in your phone does, in what they now call your intelligent personal hub, to go through the apps installed on it. And I think they're right. It's the most logical channel there is.&lt;/p&gt;

&lt;p&gt;Not because apps are nice, or because I sell them. Because on a phone, the app is the one place where three things already exist together: an identity, a set of rights, and a relationship. Everything an agent would have to rebuild from scratch on the web is already sitting there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The door
&lt;/h2&gt;

&lt;p&gt;On a phone, the assistant doesn't browse. It calls what an app has declared. The shape is familiar if you've written tools for an agent: a name, typed parameters, a description the model reads, a result it can show or speak. The difference is that the tool lives inside an installed app, with the app's data and the app's rights, and the operating system is the one dispatching the call.&lt;/p&gt;

&lt;p&gt;If your service isn't installed, it isn't a tool. The assistant can't find it, can't ask it anything, can't act through it. That is a hard line, and both platforms drew it on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rights
&lt;/h2&gt;

&lt;p&gt;Think about what an agent needs before it can do anything useful for a person: who they are, what they've paid for, what they've allowed, how to reach them. On the web, every one of those is a login, a consent screen, a token, a form. In an installed app, all of it already exists. The session is open. The subscription is active. The notification permission was granted months ago. The agent inherits all of it the moment it goes through the app, and it can't do more than the user could, because &lt;a href="https://dev.to/goodbarber/your-chatbot-is-a-second-door-onto-your-content-535h"&gt;an interface inherits the rights of whoever it acts for&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This is why I don't think the app's role shrinks as agents get better. An agent that can act on your behalf is worth exactly as much as the rights it can act with, and those rights live in apps.&lt;/p&gt;

&lt;h2&gt;
  
  
  The interface
&lt;/h2&gt;

&lt;p&gt;Here is where I go a step further. I think the assistant is going to be the natural way to use an app, on the phone. You say what you want; the assistant asks the app; the app answers, in the assistant, with a card, a list, a confirmation. The screens stay for what a screen does better than a sentence, comparing, browsing, checking. I wrote about &lt;a href="https://dev.to/goodbarber/the-first-interface-you-dont-have-to-learn-1f9b"&gt;an agent as the first interface you don't have to learn&lt;/a&gt; from the operator's side; this is the same idea reaching the person who uses the app.&lt;/p&gt;

&lt;p&gt;And I don't think it stops at Siri. An assistant that belongs to the app itself, that answers from its content and acts within its rights, is a second way in, next to the screens, not instead of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two doors, one rule
&lt;/h2&gt;

&lt;p&gt;From where we sit, an app now has two doors for agents, and they don't lead to the same room. Its owner's agent talks to the server that manages the app: content, products, notifications, the things an owner does. The phone's assistant talks to the installed app itself: the things a user does. Two doors, two sides of the product, and one rule that holds on both: each door inherits the rights of whoever it acts for, the owner on one side, the user on the other.&lt;/p&gt;

&lt;p&gt;Our engine already answers one question from Siri, "what's new", and it is &lt;a href="https://www.goodbarber.com/blog/siri-actions-the-mcp-of-your-ios-app-a1616/" rel="noopener noreferrer"&gt;reaching apps in stages&lt;/a&gt;. The real work is ahead, and it has a shape of its own: we don't write one app, we publish thousands from the same engine, each described by its own configuration. So what an assistant can do in an app is something we define the way we define a feature: once, in the engine, as an opening, and every app that has the feature gets it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What would prove me wrong
&lt;/h2&gt;

&lt;p&gt;I want this list to be checkable, so here it is.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The assistants route around the apps.&lt;/strong&gt; If in two years Siri and Gemini reach most services through the web or through APIs without an installed app, and the platforms let them, the door I'm describing was a phase.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nobody installs.&lt;/strong&gt; If assistants don't bring people to install anything, and the only apps worth calling are the ten everyone already has, this thesis holds for the giants and for nobody else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The door stays half-open.&lt;/strong&gt; If third-party actions stay a demo feature, reserved for a few partners, the mechanism exists and doesn't matter.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you ship an app: what did you expose to Siri or Gemini first for this release, and what did you decide to keep behind a screen?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>mcp</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Our tools are built for humans, not for agents</title>
      <dc:creator>Mathieu Poli</dc:creator>
      <pubDate>Wed, 23 Sep 2026 07:20:37 +0000</pubDate>
      <link>https://dev.to/goodbarber/our-tools-are-built-for-humans-not-for-agents-5f34</link>
      <guid>https://dev.to/goodbarber/our-tools-are-built-for-humans-not-for-agents-5f34</guid>
      <description>&lt;p&gt;You don’t need to believe AI thinks to anthropomorphize it. Handing it our tools is enough. GitHub, the pull request, code review, Scrum, the sprint, the standup, everything that organizes the work of a development team was designed to coordinate humans, and since agents started writing code, we’ve been handing all of it to them as is.&lt;/p&gt;

&lt;p&gt;The frontend team I work in, at GoodBarber, started out adapting that way, then realized fairly quickly it was a mistake and changed course. What I take from it, personally, is that the tooling has to be redefined for machines, not adapted from ours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our tools are tools for humans
&lt;/h2&gt;

&lt;p&gt;The standup exists so that people working side by side don’t each drift off in their own direction. The sprint bounds time because we always overestimate what we’ll ship in two weeks. The pull request keeps a record of a choice its author will have forgotten in a month, and code review puts a second pair of eyes where the first no longer sees anything.&lt;/p&gt;

&lt;p&gt;None of these tools is about code. They compensate for the flaws of people, and they were tuned, over twenty-five years, to the speed at which people work. &lt;a href="https://medium.com/@lvanderbijl/built-for-humans-how-agile-process-change-in-an-ai-world-3d34ec93b528" rel="noopener noreferrer"&gt;A Medium post titled “Built for Humans”&lt;/a&gt;, published in June 2026, says it in one sentence: these processes were calibrated on us.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anthropomorphism, at industry scale
&lt;/h2&gt;

&lt;p&gt;When AI coding agents arrived, we built nothing for them. An agent opens a branch, pushes commits, writes its pull request, answers the reviewer’s comments, and nobody found that strange.&lt;/p&gt;

&lt;p&gt;A study from NAIST, published on February 19, 2026, on &lt;a href="https://arxiv.org/abs/2602.17084" rel="noopener noreferrer"&gt;33,596 pull requests opened by five agents&lt;/a&gt;, shows how far it has gone: depending on how the agent writes its description, humans answer it faster or slower and accept its code more or less often. We are grading a machine’s manners on a form written for colleagues.&lt;/p&gt;

&lt;p&gt;In the other direction, &lt;a href="https://webcamp.stanford.edu/session/agile-for-agents-managing-robots-the-way-we-manage-humans" rel="noopener noreferrer"&gt;a session at Stanford on April 30, 2026&lt;/a&gt; proposed managing agents with Scrum and Kanban, standups included, to give them the memory and coordination they lack.&lt;/p&gt;

&lt;p&gt;In July 2026, Kyle Galbraith of Depot wrote that &lt;a href="https://depot.dev/blog/github-is-the-wrong-shape-for-this-new-world" rel="noopener noreferrer"&gt;GitHub is the wrong shape&lt;/a&gt; for this new world. His subject was infrastructure. I replied that the problem looked wider to me: Scrum, PRs, sprints all coordinate humans, and we keep anthropomorphizing AI by handing it our rituals.&lt;/p&gt;

&lt;p&gt;Anthropomorphizing, here, means giving the machine a process designed for someone who forgets and gets tired, without having checked what that process was protecting us from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do pull requests work for AI agents?
&lt;/h2&gt;

&lt;p&gt;Yes, they work, and that’s the trap. An agent knows how to open a PR, describe it and answer a comment. The ritual accepts it without blinking, so we keep the ritual and try to adapt inside it.&lt;/p&gt;

&lt;p&gt;That’s what we did at GoodBarber. Before agents, the pull request was already our bottleneck. Code waited for a reviewer longer than it had taken to write. With agents, the number of pull requests exploded, our reading capacity stayed the same, and review delays started being counted in weeks. We spent our days reading code written by a machine, never catching up with the queue.&lt;/p&gt;

&lt;p&gt;The Faros AI report of April 2026, on 22,000 developers, counts&lt;a href="https://www.faros.ai/blog/ai-acceleration-whiplash-takeaways" rel="noopener noreferrer"&gt; 67.4% more pull requests to handle per developer per day&lt;/a&gt;, so there is nothing local about it.&lt;/p&gt;

&lt;p&gt;Reading all that code, I understood that review rested on an assumption we had never had to state: the author will be there tomorrow. They will remember why they made that choice, and they will own the bug if there is one. The whole conversation around a PR rests on that. An agent closes its session and its context is gone.&lt;/p&gt;

&lt;p&gt;A January 2026 study of 33,707 agent-authored pull requests, &lt;a href="https://addyosmani.com/blog/agentic-code-review/" rel="noopener noreferrer"&gt;picked up by Addy Osmani&lt;/a&gt;, sees it in the data: agents merge small, well-scoped changes fast, and disappear the moment they get subjective feedback.&lt;br&gt;
The pull request assumes an author who will come back. The agent doesn’t come back.&lt;/p&gt;

&lt;p&gt;We didn’t manage to adapt review. We removed it. In the spring of 2026 it stopped being mandatory on more and more pull requests, starting with the changes we knew how to undo, the line I&lt;a href="https://dev.to/goodbarber/trust-is-the-net-not-the-eyes-can-you-ship-ai-built-software-you-cant-review-1gfm"&gt; described in my piece on the net&lt;/a&gt;. As of September 2026, it only remains on changes rated critical, the ones whose release is risky. Everything else goes through a pull request loop between agents, and ships without a human having read the diff.&lt;/p&gt;

&lt;p&gt;The first weeks were stressful, I won’t pretend otherwise. But the productivity gain is such that we can afford to handle the misses, provided we assessed beforehand that the risk stayed contained.&lt;/p&gt;

&lt;p&gt;Scrum went the same way, only more abruptly. A two-week sprint is tuned to one person’s throughput, and once execution moves to agents, the sprint is empty long before it ends. The “Built for Humans” post &lt;a href="https://medium.com/@lvanderbijl/built-for-humans-how-agile-process-change-in-an-ai-world-3d34ec93b528" rel="noopener noreferrer"&gt;tells it&lt;/a&gt; as a scene: two weeks of work finished by Tuesday. What limits the team now is what it can specify and verify, and Scrum has no tool to measure that.&lt;/p&gt;

&lt;p&gt;At the September 2026 rentrée, we used the calendar to drop our human-designed methods all at once. We’re trying other ways of working right now, and I don’t have a method to recommend yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a tool for machines needs
&lt;/h2&gt;

&lt;p&gt;What’s missing is the tooling that comes next. Bryan Finster published in July 2026 the experiment that gave me my diagnostic. He had agents run the same tasks with and without our disciplines: TDD, pair programming, separating author and reviewer. &lt;a href="https://bryanfinster.substack.com/p/agentic-workflows-do-agents-work" rel="noopener noreferrer"&gt;His result&lt;/a&gt;: refactoring improves the code, writing the test first changes nothing. “Refactoring is the mechanism. Test-first ordering is the ceremony.”&lt;/p&gt;

&lt;p&gt;The mechanism serves the machine. The ceremony served the human who had to force himself to think before writing. The ceremony is the human part of the tool, and it’s exactly what we hand the agent when we anthropomorphize.&lt;/p&gt;

&lt;p&gt;From there, we can say what a tool designed for a machine looks like, because we already know its properties in the negative.&lt;/p&gt;

&lt;p&gt;It asks nobody to be present while the machine works, whereas a ritual rests entirely on presence.&lt;/p&gt;

&lt;p&gt;It draws the line between what the agent executes and what it proposes on the reversibility of the mistake, not on how confident the model sounds.&lt;/p&gt;

&lt;p&gt;It gives the agent a written context it reads before starting, in place of a meeting where someone recites it.&lt;br&gt;
And it verifies at the machine’s production speed, not at a human’s reading speed.&lt;/p&gt;

&lt;p&gt;We had to design such a tool, on a small scale, because we had no choice. When AI writes code for someone who isn’t a developer, inside our product, that person has no pull request, no sprint, no reviewer. There was no ritual to lend it.&lt;/p&gt;

&lt;p&gt;What we wrote looks like none of our meetings: a line drawn on reversibility, and an access surface declared up front and checked server-side, &lt;a href="https://dev.to/goodbarber/we-let-an-ai-write-code-inside-our-no-code-platform-generating-it-was-the-easy-part-1p65"&gt;a choice my CTO Dominique has written about&lt;/a&gt;. It’s not much, but it’s designed for the machine, and it was easier to find there than in my own team, where we had a ritual within reach.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tooling has to be rebuilt
&lt;/h2&gt;

&lt;p&gt;My position is that the next generation of development tools has to be defined for machines, not adapted from ours. A faster pull request or an automated standup keep the human shape, and the human shape is the problem.&lt;/p&gt;

&lt;p&gt;We’ll need tools that carry context so nobody has to remember it, that bound damage rather than time, and that verify at the pace the machine produces. I don’t know yet what they will look like. I’m wary of those who already do, they usually sell one.&lt;/p&gt;

&lt;p&gt;It’s the same conclusion I reached &lt;a href="https://dev.to/goodbarber/ai-didnt-kill-the-framework-it-made-it-more-valuable-3n5p"&gt;about frameworks&lt;/a&gt;: when producing gets cheap, what holds value is what remains underneath, and for a team that’s the tooling.&lt;/p&gt;

&lt;p&gt;If you’re adapting your team to AI, look at what you’re handing it. If it’s a pull request, a sprint and a standup, you’re not adapting to AI. You’re teaching it to pretend to be one of your colleagues.&lt;/p&gt;

&lt;p&gt;Our tools compensate for our flaws. Before handing one to a machine, ask whether it has the flaw.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Mathieu Poli — Head of Frontend Engineering @ GoodBarber. I teach and write about frontend engineering, product design, and AI, and everything that happens when the three meet.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;X: &lt;a href="https://x.com/hellomathieup" rel="noopener noreferrer"&gt;@hellomathieup&lt;/a&gt; · LinkedIn: &lt;a href="https://www.linkedin.com/in/hellomathieup/" rel="noopener noreferrer"&gt;hellomathieup&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cover photo by &lt;a href="https://unsplash.com/photos/3qZR6AQx_8w" rel="noopener noreferrer"&gt;nikohoshi&lt;/a&gt; on Unsplash.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>productivity</category>
      <category>career</category>
    </item>
    <item>
      <title>Listing a remote MCP server in every directory: what each one actually checks (September 2026)</title>
      <dc:creator>Pierre-Laurent Medori</dc:creator>
      <pubDate>Thu, 17 Sep 2026 11:52:00 +0000</pubDate>
      <link>https://dev.to/goodbarber/listing-a-remote-mcp-server-in-every-directory-what-each-one-actually-checks-september-2026-con</link>
      <guid>https://dev.to/goodbarber/listing-a-remote-mcp-server-in-every-directory-what-each-one-actually-checks-september-2026-con</guid>
      <description>&lt;p&gt;The Save button did nothing. We opened the network panel. The request had returned HTTP 200.&lt;/p&gt;

&lt;p&gt;Inside the response: a validation error on a field the form did not show.&lt;/p&gt;

&lt;p&gt;By then we had spent several days trying to get the same remote MCP server listed across directories. Another directory had published us in about ten minutes. A GitHub list had tested our endpoint and accepted its 401. Elsewhere, our submission was waiting for a human.&lt;/p&gt;

&lt;p&gt;"Get the server listed" had turned into a collection of different jobs.&lt;/p&gt;

&lt;p&gt;One line of context so you know where I stand: I run engineering at &lt;a href="https://www.goodbarber.com/app-builder/" rel="noopener noreferrer"&gt;GoodBarber&lt;/a&gt;, an app platform, and &lt;a href="https://www.goodbarber.com/mcp/" rel="noopener noreferrer"&gt;our MCP server&lt;/a&gt; lets assistants manage the apps through an authenticated connection. It is hosted, it requires OAuth, and there is no public server code for a directory to scan: our public repository holds skills and client configuration. That distinction decided which submission paths worked.&lt;/p&gt;

&lt;p&gt;After &lt;a href="https://dev.to/goodbarber/our-mcp-server-is-now-a-chatgpt-plugin-2pjm"&gt;getting the server into ChatGPT's plugin directory&lt;/a&gt;, we widened the search. What follows happened between September 7 and 14, and every public state was checked again on September 14. The delays are the ones our submissions went through, not a promise from anyone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The official MCP Registry: publish the identity first
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://modelcontextprotocol.io/registry/about" rel="noopener noreferrer"&gt;official MCP Registry&lt;/a&gt; accepts metadata for hosted servers, closed-source implementations included, as long as the server is publicly reachable. It authenticates the publisher's namespace and validates the metadata. Beyond that, its metadata is deliberately unopinionated, and curation is left to the aggregators downstream.&lt;/p&gt;

&lt;p&gt;Our entry, &lt;code&gt;dev.goodbarber/goodbarber-public-mcp&lt;/code&gt;, has been there since April 28 (version 1.0.0). On September 7 we published version &lt;code&gt;1.2.1&lt;/code&gt;, with DNS authentication: it declares &lt;code&gt;streamable-http&lt;/code&gt;, the remote endpoint and the product page. On September 14 the &lt;a href="https://registry.modelcontextprotocol.io/v0.1/servers?search=dev.goodbarber&amp;amp;limit=100" rel="noopener noreferrer"&gt;registry API&lt;/a&gt; still marked it active and latest.&lt;/p&gt;

&lt;p&gt;We would do this first again. It gives importers a stable identity and a record to consume, instead of asking each directory to rebuild the product from a README.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://glama.ai/mcp/connectors/dev.goodbarber/goodbarber-public-mcp" rel="noopener noreferrer"&gt;Glama&lt;/a&gt; had already imported a connector entry from that record. We claimed it, and on September 8 it displayed "Ownership verified" and "Healthy". Both were still there on September 14.&lt;/p&gt;

&lt;p&gt;Upstream publication does not tell you when the downstream pages will exist. MCP.Directory says it auto-discovers servers from the official registry, where our entry has been since April. Its submission form promises a review within 24 hours, and its first, required field is a GitHub repository, from which it detects the tools by analyzing the server's implementation. The Knights Who Say Ni wanted a shrubbery; a hosted server with no public code has no repository to give. On September 14, MCP.Directory's &lt;a href="https://mcp.directory/sitemap.xml" rel="noopener noreferrer"&gt;sitemap&lt;/a&gt; still had no GoodBarber URL, six days after a server listing request and five skill submissions. Where those submissions sit, we do not know.&lt;/p&gt;

&lt;h2&gt;
  
  
  awesome-remote-mcp-servers: the CI that accepts a 401
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/punkpeye/awesome-remote-mcp-servers" rel="noopener noreferrer"&gt;awesome-remote-mcp-servers&lt;/a&gt; is built for our shape of product. It only lists servers the provider hosts: reachable at a public URL, usable by anyone who can sign up, speaking Streamable HTTP or SSE. Each entry's name links to the product's homepage, not to a GitHub repository, because the endpoint is the thing being listed. The list was created on September 8, and our PR went in the same morning.&lt;/p&gt;

&lt;p&gt;It also gave us something we could test. Its &lt;a href="https://github.com/punkpeye/awesome-remote-mcp-servers/blob/a3bb155f3a2ad0cc159781f919fc78c187b84bec/.github/workflows/check-submission.yml" rel="noopener noreferrer"&gt;submission workflow at the merge commit&lt;/a&gt; probes each endpoint with a real &lt;code&gt;initialize&lt;/code&gt; handshake and sends no credentials. We replayed the check before submitting. Our endpoint answered with an authentication challenge, structurally like this (generic domain):&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="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;401&lt;/span&gt; &lt;span class="ne"&gt;Unauthorized&lt;/span&gt;
&lt;span class="na"&gt;WWW-Authenticate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The workflow takes a 401 or a 403 as a live endpoint that requires authentication, and reads the &lt;code&gt;WWW-Authenticate&lt;/code&gt; header to choose between the OAuth and API-key markers. Our 401 passed with the OAuth marker. The probe never completes OAuth and never calls a tool: a green check here means the door exists.&lt;/p&gt;

&lt;p&gt;Two more rules matter before you open the PR. Every entry needs a Glama &lt;strong&gt;connector&lt;/strong&gt; badge, and CI checks that the connector exists. And the &lt;a href="https://github.com/punkpeye/awesome-remote-mcp-servers/blob/main/CONTRIBUTING.md" rel="noopener noreferrer"&gt;contribution rules&lt;/a&gt; document a fast track for automated agents: three robot emoji at the end of the PR title. Our PR was prepared by an agent, so we used it. &lt;a href="https://github.com/punkpeye/awesome-remote-mcp-servers/pull/4" rel="noopener noreferrer"&gt;PR #4&lt;/a&gt; was opened at 07:23 UTC, labeled &lt;code&gt;endpoint-ok&lt;/code&gt; and &lt;code&gt;has-connector&lt;/code&gt; by CI nine seconds later, and merged at 13:50 UTC, on September 8.&lt;/p&gt;

&lt;p&gt;Then the bot ran again on the merged PR. It added &lt;code&gt;duplicate&lt;/code&gt; and &lt;code&gt;missing-connector&lt;/code&gt;, removed &lt;code&gt;has-connector&lt;/code&gt;, and asked us to remove the duplicate entry. The duplicate was ours: the check was now reading the list it had just merged. The README on the main branch settled what the labels could not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gemini CLI and GitHub: a crawler versus a review queue
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://geminicli.com/docs/extensions/releasing/" rel="noopener noreferrer"&gt;Gemini CLI gallery instructions&lt;/a&gt; describe a daily crawl of public GitHub repositories carrying the &lt;code&gt;gemini-cli-extension&lt;/code&gt; topic. A &lt;code&gt;gemini-extension.json&lt;/code&gt; manifest at the root of the repository supplies the metadata, and an extension appears in the gallery only if it passes validation. The instructions say there is no issue to file and no email to send.&lt;/p&gt;

&lt;p&gt;We added the topic on September 9. On September 14 the topic and the manifest were in place, and the &lt;a href="https://geminicli.com/extensions/" rel="noopener noreferrer"&gt;gallery page&lt;/a&gt; had no GoodBarber entry. A daily crawl gives no per-repository result, so we cannot tell whether we failed validation or were never picked up.&lt;/p&gt;

&lt;p&gt;GitHub's MCP gallery has another gate. In &lt;a href="https://github.com/github/github-mcp-server/discussions/1257" rel="noopener noreferrer"&gt;discussion #1257&lt;/a&gt;, a GitHub answer explains that onboarding a new server is a manual curation process, and that new registry versions sync once a server has been onboarded. We asked to be included on September 8; on September 14 the expected gallery URL still returned 404. Republishing the registry record would change nothing there.&lt;/p&gt;

&lt;p&gt;Our Claude Code plugin sits in a similar waiting room. Plugins are submitted through a form, and the public &lt;a href="https://github.com/anthropics/claude-plugins-community/blob/main/.claude-plugin/marketplace.json" rel="noopener noreferrer"&gt;community marketplace catalogue&lt;/a&gt; is a read-only mirror, synced nightly from Anthropic's review pipeline, of the plugins that passed automated security scanning and were approved. Ours was submitted on September 7, shown as awaiting review on September 8, and still absent from that catalogue on September 14.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude connectors: ten minutes, then an inventory problem
&lt;/h2&gt;

&lt;p&gt;The Claude &lt;strong&gt;connectors directory&lt;/strong&gt; is a different destination from the Claude Code plugin marketplace. Remote servers are submitted from an organization's admin settings, through a portal that connects to the server. Our &lt;a href="https://claude.ai/directory/424480c2-9956-40d4-8af3-5db873be30ed" rel="noopener noreferrer"&gt;connector listing&lt;/a&gt; appeared about ten minutes after we submitted it on September 8, while the plugin was still waiting. Its tier is &lt;strong&gt;community&lt;/strong&gt;, and that is the word we use.&lt;/p&gt;

&lt;p&gt;Then we read the published inventory and found our own mistake. The directory's &lt;a href="https://api.anthropic.com/api/directory/servers?limit=5000&amp;amp;visibility=commercial,gsuite,gsuite-google&amp;amp;verified_tier=anthropic,partner,community" rel="noopener noreferrer"&gt;public record&lt;/a&gt; listed 120 tool names and left out an entire tool family, the one behind push notifications and analytics, while the description we wrote advertised both. The portal syncs the tool list from the server through the connection made at submission, and our connection did not expose that family. The description is ours to edit from the dashboard; the tool list is not an editable field, and changing it goes through the review team. Either way, the fix is ours.&lt;/p&gt;

&lt;p&gt;For someone following the &lt;a href="https://www.goodbarber.com/connect-claude-app/" rel="noopener noreferrer"&gt;Claude connection guide&lt;/a&gt;, the only question that matters is whether the tools they get cover the task they came for. A directory listing does not change the tools a connection exposes, so the check we owe that reader goes beyond the listing: the published inventory against the description, then a real connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  mcp.so: HTTP 200, application-level failure
&lt;/h2&gt;

&lt;p&gt;The Save button from the opening was on mcp.so. We were updating an existing listing whose transport and overview had gone stale.&lt;/p&gt;

&lt;p&gt;On September 8 the edit request returned HTTP 200, and its body carried application code &lt;code&gt;-1&lt;/code&gt;, an "Invalid submission" message and a validation error under &lt;code&gt;fieldErrors.tagline&lt;/code&gt;. The old tagline was too long. The form had no field to fix it and showed no error.&lt;/p&gt;

&lt;p&gt;We sent the edit again with a shorter tagline, then read the public page, because a save response is a claim, not a result. On September 14 the &lt;a href="https://mcp.so/servers/goodbarber-skills" rel="noopener noreferrer"&gt;listing&lt;/a&gt; showed Streamable HTTP, OAuth and the updated overview.&lt;/p&gt;

&lt;p&gt;If that sounds familiar, it is &lt;a href="https://dev.to/pierrelaurentmedori/your-mcp-write-returned-200-did-the-right-thing-actually-happen-38n0"&gt;the write-safety piece&lt;/a&gt; in miniature, with a web form in place of an agent. The green request in the network panel meant an HTTP exchange had completed, nothing more. Read the body, then read the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  MCP Market: fast, paid, and one button to check
&lt;/h2&gt;

&lt;p&gt;On September 8 we paid $69 for the remote-server "Official" listing option, which is a different product from the GitHub-repository submission. The &lt;a href="https://mcpmarket.com/server/goodbarber" rel="noopener noreferrer"&gt;listing&lt;/a&gt; was live the next morning, inside the advertised 24 hours, with the badge and the categories we asked for.&lt;/p&gt;

&lt;p&gt;Search MCP Market for GoodBarber and a second card shows up next to ours: GoodBarber Skills, filed under Marketing Automation.&lt;/p&gt;

&lt;p&gt;Then we clicked &lt;strong&gt;Try Now&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;We had submitted our MCP product page as the destination. The published button led to our app-creation page, with MCP Market campaign parameters added, and still did on September 14. Both pages are ours, and it matters anyway: someone looking for connection instructions lands on a different step of the product journey.&lt;/p&gt;

&lt;p&gt;Paying got the listing published fast. Checking where it sends people stayed our job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Smithery and skills.sh: read what the signal measures
&lt;/h2&gt;

&lt;p&gt;On Smithery, verification happens in the server's settings, through what its publishing guide calls an automatic official-vendor verification checklist. Our domain and backlink proofs passed their recheck on September 8. The public data embedded in the &lt;a href="https://smithery.ai/servers/goodbarber/goodbarber-public-mcp" rel="noopener noreferrer"&gt;listing page&lt;/a&gt; said &lt;code&gt;verified: false&lt;/code&gt; afterwards, and still did on September 14. The guide names the checklist without listing its items, and nothing we could see explains the step between two passed proofs and a false flag. We write down both states rather than calling the whole thing verified.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://skills.sh/goodbarber/goodbarber-skills" rel="noopener noreferrer"&gt;skills.sh&lt;/a&gt; showed our 44 skills on September 14, with installation counts. There is nothing to submit there: skills appear through the anonymous install telemetry of its CLI, when people run &lt;code&gt;npx skills add&lt;/code&gt;. Those skills are instruction packages for using the server, and the counter measures installs: not a completed workflow, and not an OAuth flow that works in every client.&lt;/p&gt;

&lt;h2&gt;
  
  
  The September 14 snapshot
&lt;/h2&gt;

&lt;p&gt;Every state below was checked on September 14, 2026; submission and publication dates are the real ones. Read "pending" as pending, and "absent" as unexplained.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Directory or catalogue&lt;/th&gt;
&lt;th&gt;Admission mechanism&lt;/th&gt;
&lt;th&gt;Observed delay&lt;/th&gt;
&lt;th&gt;State on September 14&lt;/th&gt;
&lt;th&gt;Pitfall&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Official MCP Registry&lt;/td&gt;
&lt;td&gt;Namespace authentication, metadata validation&lt;/td&gt;
&lt;td&gt;Entry since April 28; 1.2.1 on September 7&lt;/td&gt;
&lt;td&gt;Version 1.2.1 active and latest&lt;/td&gt;
&lt;td&gt;Curation is left to aggregators&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Glama&lt;/td&gt;
&lt;td&gt;Registry import, ownership claim, health checks&lt;/td&gt;
&lt;td&gt;Initial import not timed&lt;/td&gt;
&lt;td&gt;Ownership verified; Healthy&lt;/td&gt;
&lt;td&gt;Ownership and health are separate signals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/punkpeye/awesome-remote-mcp-servers" rel="noopener noreferrer"&gt;awesome-remote-mcp-servers&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Endpoint CI without credentials, Glama connector badge, PR&lt;/td&gt;
&lt;td&gt;Same day, September 8&lt;/td&gt;
&lt;td&gt;PR #4 merged&lt;/td&gt;
&lt;td&gt;The probe stops at the 401&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MCP.Directory&lt;/td&gt;
&lt;td&gt;Registry auto-discovery; GitHub-based form with a 24-hour review promise&lt;/td&gt;
&lt;td&gt;September 8 to 14, unresolved&lt;/td&gt;
&lt;td&gt;No GoodBarber URL in sitemap&lt;/td&gt;
&lt;td&gt;The form expects a GitHub repository&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini CLI gallery&lt;/td&gt;
&lt;td&gt;Topic crawl, root manifest, validation&lt;/td&gt;
&lt;td&gt;September 9 to 14, unresolved&lt;/td&gt;
&lt;td&gt;No entry on the gallery page&lt;/td&gt;
&lt;td&gt;A daily crawl gives no per-repo result&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub MCP gallery&lt;/td&gt;
&lt;td&gt;Manual curation, then version sync&lt;/td&gt;
&lt;td&gt;September 8 to 14, unresolved&lt;/td&gt;
&lt;td&gt;Expected URL returns 404&lt;/td&gt;
&lt;td&gt;Registry publication does not grant admission&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code community marketplace&lt;/td&gt;
&lt;td&gt;Submission form, security scan, approval, nightly mirror&lt;/td&gt;
&lt;td&gt;September 7 to 14, unpublished&lt;/td&gt;
&lt;td&gt;Absent from public catalogue&lt;/td&gt;
&lt;td&gt;Separate from the connectors directory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude connectors directory&lt;/td&gt;
&lt;td&gt;Admin portal; tools synced from the connected server&lt;/td&gt;
&lt;td&gt;About 10 minutes, September 8&lt;/td&gt;
&lt;td&gt;Community listing; 120 declared tools, no push or analytics&lt;/td&gt;
&lt;td&gt;The synced tools did not match our description&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mcp.so&lt;/td&gt;
&lt;td&gt;Authenticated form and API validation&lt;/td&gt;
&lt;td&gt;Update completed September 8&lt;/td&gt;
&lt;td&gt;Corrected transport and overview live&lt;/td&gt;
&lt;td&gt;HTTP 200 carried a validation failure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MCP Market&lt;/td&gt;
&lt;td&gt;Paid remote-server submission&lt;/td&gt;
&lt;td&gt;Live the next morning, September 9&lt;/td&gt;
&lt;td&gt;Official listing live; a GoodBarber Skills card also listed&lt;/td&gt;
&lt;td&gt;Try Now destination changed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Smithery&lt;/td&gt;
&lt;td&gt;Official-vendor verification checklist&lt;/td&gt;
&lt;td&gt;Proofs passed September 8&lt;/td&gt;
&lt;td&gt;Public data still says &lt;code&gt;verified: false&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Passed proofs did not flip the public flag&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;skills.sh&lt;/td&gt;
&lt;td&gt;No submission; CLI install telemetry&lt;/td&gt;
&lt;td&gt;First install not timed&lt;/td&gt;
&lt;td&gt;44 skills listed&lt;/td&gt;
&lt;td&gt;Installs are not workflow success&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/docker/mcp-registry/pull/4968" rel="noopener noreferrer"&gt;Docker MCP Catalog&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Remote-server PR, Docker team review, test credentials by form&lt;/td&gt;
&lt;td&gt;Open since September 8&lt;/td&gt;
&lt;td&gt;PR #4968 open; 0 comments&lt;/td&gt;
&lt;td&gt;Review outcome unknown&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/VoltAgent/awesome-agent-skills/pull/1031" rel="noopener noreferrer"&gt;VoltAgent skills list&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Skills-list PR; rules require real community usage&lt;/td&gt;
&lt;td&gt;September 8 to 14&lt;/td&gt;
&lt;td&gt;PR #1031 closed without merge or comment&lt;/td&gt;
&lt;td&gt;Adoption comes before the listing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/BehiSecc/awesome-claude-skills/pull/689" rel="noopener noreferrer"&gt;BehiSecc skills list&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Skills-list PR&lt;/td&gt;
&lt;td&gt;Open since September 8&lt;/td&gt;
&lt;td&gt;PR #689 open; 0 comments&lt;/td&gt;
&lt;td&gt;Skills listings have their own review&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/cline/mcp-marketplace/issues/2492" rel="noopener noreferrer"&gt;Cline marketplace&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Submission issue; review of adoption, credibility, maturity, security&lt;/td&gt;
&lt;td&gt;Open since September 9&lt;/td&gt;
&lt;td&gt;Issue #2492 open; 0 comments&lt;/td&gt;
&lt;td&gt;Review weighs community adoption&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The order we would follow next time
&lt;/h2&gt;

&lt;p&gt;Start with the official registry, and with one description, one endpoint, one product page and one documentation URL, the same everywhere. Separate the hosted server from the skills package before choosing routes: some submission forms start with a GitHub repository, and a hosted server has nothing to put in that field.&lt;/p&gt;

&lt;p&gt;Then read each gate before you submit. Replay public CI checks. If an agent prepares the PR, use the documented agent fast track. For a crawler, check the topic and the manifest. For a curated catalogue, read the acceptance criteria first, since some weigh community adoption, then keep the submission reference and wait for evidence of admission.&lt;/p&gt;

&lt;p&gt;After publication, read what went live: the tool inventory, the authentication metadata, every destination link. Keep dates next to results and recheck weekly. The merged PR, the badge and the live page can tell three different stories.&lt;/p&gt;

&lt;p&gt;All of it points to one page, &lt;a href="https://www.goodbarber.com/mcp-complete-guide/" rel="noopener noreferrer"&gt;the connection guide&lt;/a&gt;. If you are connecting GoodBarber to an assistant, start there.&lt;/p&gt;

&lt;p&gt;One more thing: that was a dense week, and these procedures move fast. What you read above comes from each directory's own documentation where it has one, and from what we saw on the forms otherwise, checked on September 14. If you run one of these directories and something here is wrong, or already out of date, tell us in the comments.&lt;/p&gt;

&lt;p&gt;Which check caught a mismatch in your own listing: the response body, the tool inventory, or the link after publication? Genuinely curious.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>opensource</category>
      <category>devrel</category>
    </item>
    <item>
      <title>The URL on the flyer doesn't know your app changed</title>
      <dc:creator>Dominique Siacci</dc:creator>
      <pubDate>Tue, 15 Sep 2026 14:29:50 +0000</pubDate>
      <link>https://dev.to/goodbarber/the-url-on-the-flyer-doesnt-know-your-app-changed-9kn</link>
      <guid>https://dev.to/goodbarber/the-url-on-the-flyer-doesnt-know-your-app-changed-9kn</guid>
      <description>&lt;p&gt;A link leaves your system the moment it's printed. The QR code on a restaurant's counter card, the URL at the bottom of a club's poster, the link a shop put on its packaging three seasons ago — none of these can be recalled. You can't redirect ink. Whoever scans that code has an appointment with your app, and &lt;em&gt;they&lt;/em&gt; chose the date: maybe this afternoon, maybe in four years, long after the app was redesigned twice and the section it pointed to was renamed.&lt;/p&gt;

&lt;p&gt;A link is a promise. And the inconvenient part of the promise is that its duration was never yours to choose.&lt;/p&gt;

&lt;h2&gt;
  
  
  One door, many keys
&lt;/h2&gt;

&lt;p&gt;Links reach an app from more places than anyone plans for: a QR code, a push notification, a URL shared in a chat, a link inside the app's own pages, another app handing over an address. Every one of those arrives shaped differently.&lt;/p&gt;

&lt;p&gt;Our answer is architectural: all of them funnel into a single resolver. Whatever the source, the link is first &lt;a href="https://dev.to/goodbarber/feeding-an-app-from-someone-elses-cms-61l"&gt;translated into the same internal representation&lt;/a&gt; — what is being asked for, in which section — and only then does anything happen. The variety of doors is a parsing problem at the edge; behind the edge there's one door, and one promise to keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  Formats get added. They don't get retired.
&lt;/h2&gt;

&lt;p&gt;Over the years, the shape of our links has changed — of course it has: URLs got cleaner, more readable, more shareable. Each generation was an improvement, and each one created the same obligation: every link emitted in the old shape was already out in the world, printed and pinned and bookmarked.&lt;/p&gt;

&lt;p&gt;So the rule is simple and it only goes one direction: formats get added, never retired. A link built the way we built them years ago still resolves in the app that ships today — not because that old parsing code is anyone's pride, but because the alternative is breaking a promise someone else is still holding. Backward compatibility of this kind isn't a feature you announce; it's debt you carry with a straight face, because the person who scanned yesterday's format doesn't know it's yesterday's, and shouldn't have to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Durable by construction
&lt;/h2&gt;

&lt;p&gt;Honoring old links forever would be impossible if a link depended on things that change. So the durability is built into the addressing, not into a policy of never changing anything.&lt;/p&gt;

&lt;p&gt;A link targets a section by a stable identifier — never by its position in a menu. Reorder the navigation, redesign every screen: the identifier doesn't move, so the link doesn't care. Renamed a section's public URL? Where the platform can, the old slug is absorbed by a permanent redirect to the new one. Even the domain gets the benefit of the doubt: a link is matched with and without &lt;code&gt;www&lt;/code&gt;, in &lt;code&gt;http&lt;/code&gt; as well as &lt;code&gt;https&lt;/code&gt; — so a flyer printed back when the web still said &lt;code&gt;http://&lt;/code&gt; opens today's app without noticing what decade it is. None of these tricks is clever on its own; it's the ordinary toolbox of URL hygiene. The only discipline is in refusing to skip any of it, anywhere, ever.&lt;/p&gt;

&lt;p&gt;The pattern behind all of it: &lt;a href="https://dev.to/goodbarber/what-breaks-when-nobody-touches-your-app-for-three-years-2dgm"&gt;everything around an app changes over three years&lt;/a&gt; — the design, the structure, the web's own defaults. The address layer is built so that nothing a link relies on is among the things that changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the promise can't be kept
&lt;/h2&gt;

&lt;p&gt;Sometimes resolution genuinely fails — the section is gone, the content deleted. What happens next depends on where the link came from, and the distinction matters.&lt;/p&gt;

&lt;p&gt;Inside the app, a link that resolves to nothing is a bug, and it gets treated like one: a not-found screen, visible enough that someone fixes it.&lt;/p&gt;

&lt;p&gt;But a link arriving from the outside world — the flyer, the QR in the street — never ends on a dead end. As a last resort, when every attempt at resolution has failed, the visitor lands on the app's home. Not because errors should be hidden, but because of who's standing there: this person didn't hit a bug, they exercised an old promise. Handing them an error page punishes them for our history. Handing them the home screen at least honors the half of the promise that's left — &lt;em&gt;there is an app here, and you're welcome in it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You don't control when a link will be redeemed. You only control whether, on that day, it's honored. Build for the second, because the first was never up to you.&lt;/p&gt;

</description>
      <category>nocode</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
