<?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>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>
    <item>
      <title>"Too many levels of symbolic links" with no symlink in sight: autofs, a bind mount and a cron container</title>
      <dc:creator>Pierre-Laurent Medori</dc:creator>
      <pubDate>Tue, 15 Sep 2026 11:54:39 +0000</pubDate>
      <link>https://dev.to/goodbarber/too-many-levels-of-symbolic-links-with-no-symlink-in-sight-autofs-a-bind-mount-and-a-cron-20nf</link>
      <guid>https://dev.to/goodbarber/too-many-levels-of-symbolic-links-with-no-symlink-in-sight-autofs-a-bind-mount-and-a-cron-20nf</guid>
      <description>&lt;p&gt;In September, our PHP crons started failing with an error that sounded specific:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Too many levels of symbolic links
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is ELOOP, the error a symlink loop gives you, so the first move was to look for a loop on &lt;code&gt;/srv/apps/app-a/task.php&lt;/code&gt;. There was no symlink on that path. Nothing to untangle.&lt;/p&gt;

&lt;p&gt;Paths and component names below are generic examples, not ours.&lt;/p&gt;

&lt;p&gt;By the time we looked again, on September 10, the file was readable. The autofs service had just restarted, and the same file answered on &lt;code&gt;/opt/code/app-a/task.php&lt;/code&gt; and on &lt;code&gt;/srv/apps/app-a/task.php&lt;/code&gt;. That left a question the successful &lt;code&gt;ls&lt;/code&gt; could not answer: what had to work for the second path to reach the first?&lt;/p&gt;

&lt;p&gt;For context: 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 these crons run its background jobs. The short version of what follows: the code was already bind-mounted into the container, the paths the crons used still went through autofs, so we mounted the code directly at those paths and took them out of the automount map. If ELOOP sends you hunting for symlinks that are not there, look at your automounts, and at what the container's mount namespace can actually see.&lt;/p&gt;

&lt;h2&gt;
  
  
  The autofs layer inside Docker
&lt;/h2&gt;

&lt;p&gt;The cron container's Compose configuration already mounted the application code from the host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;host /srv/code/app-a -&amp;gt; container /opt/code/app-a
host /srv/code/app-b -&amp;gt; container /opt/code/app-b
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application expects &lt;code&gt;/srv/apps/app-a&lt;/code&gt; and &lt;code&gt;/srv/apps/app-b&lt;/code&gt;. The image supplied those two paths through an autofs direct map:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# /etc/auto.master
/- /etc/auto.apps --ghost,--timeout=30

# Two entries in /etc/auto.apps
/srv/apps/app-a -fstype=bind :/opt/code/app-a
/srv/apps/app-b -fstype=bind :/opt/code/app-b
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any access to either path could trigger the automounter. The other entries in that map were NFS shares, managed by the same daemon.&lt;/p&gt;

&lt;p&gt;Two layers of mounting for code that was already local. Opening a PHP file depended on the service that manages our network mounts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why an automount can return ELOOP
&lt;/h2&gt;

&lt;p&gt;The error name is narrower than the mechanism behind it. In Linux v6.1, &lt;a href="https://github.com/torvalds/linux/blob/v6.1/fs/namei.c#L1340-L1363" rel="noopener noreferrer"&gt;&lt;code&gt;follow_automount()&lt;/code&gt;&lt;/a&gt; increments &lt;code&gt;nd-&amp;gt;total_link_count&lt;/code&gt;, the same counter symlink traversal spends, and returns &lt;code&gt;-ELOOP&lt;/code&gt; once it reaches &lt;code&gt;MAXSYMLINKS&lt;/code&gt;, which is 40. During a path lookup, every automount crossed counts against the symlink limit. No symlink required.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://docs.kernel.org/filesystems/autofs.html#autofs-name-spaces-and-shared-mounts" rel="noopener noreferrer"&gt;autofs documentation&lt;/a&gt; describes the other half. When an autofs filesystem is visible in several places and the mounts created by its daemon do not propagate to the others, access from those other places "will likely result in the ELOOP error". The caller keeps meeting a trigger and never sees the mount it is waiting for.&lt;/p&gt;

&lt;p&gt;That is how you get ELOOP without a symlink. Which of the two our incident took, we cannot say: the investigation tied the failure to an NFS incident and an unhealthy automounter, and we did not capture a kernel trace. What the configuration did show is the dependency itself, and it had no reason to exist, whatever upset the daemon first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix stayed in configuration
&lt;/h2&gt;

&lt;p&gt;On September 10 we added direct Docker binds at the paths the crons actually use. The relevant Compose fragment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bind&lt;/span&gt;
    &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/srv/code/app-a&lt;/span&gt;
    &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/srv/apps/app-a&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bind&lt;/span&gt;
    &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/srv/code/app-b&lt;/span&gt;
    &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/srv/apps/app-b&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We also gave the cron container its own copy of the automount map, with those two entries removed. It takes both changes: leave the keys in the map and autofs installs its triggers over the direct mounts again.&lt;/p&gt;

&lt;p&gt;One detail of our image: the map path, shown here as &lt;code&gt;/etc/auto.apps&lt;/code&gt;, is a symlink to the real map file. The Compose override mounts the cron-specific map over that target. The web containers keep the map they had.&lt;/p&gt;

&lt;p&gt;The application teams maintain and deploy that code. This was an operations fix, so it stayed in the mount configuration and the application paths did not move. No PHP patch, no local checkout drifting away from what the application teams deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving the dependency is gone
&lt;/h2&gt;

&lt;p&gt;Roy from &lt;em&gt;The IT Crowd&lt;/em&gt; has a suggestion: "Have you tried turning it off and on again?" We already had a readable file after a restart. The useful test was whether it still needed the automounter.&lt;/p&gt;

&lt;p&gt;Inside the cron container, check each mount on its own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;findmnt &lt;span class="nt"&gt;-o&lt;/span&gt; TARGET,SOURCE,FSTYPE /srv/apps/app-a
findmnt &lt;span class="nt"&gt;-o&lt;/span&gt; TARGET,SOURCE,FSTYPE /srv/apps/app-b
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-cE&lt;/span&gt; &lt;span class="s1"&gt;'^/srv/apps/app-(a|b)[[:space:]]'&lt;/span&gt; /etc/auto.apps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both mounts should resolve to the host-backed code, with no autofs trigger underneath. The count should be &lt;code&gt;0&lt;/code&gt;, and &lt;code&gt;grep&lt;/code&gt; exits with status 1 when nothing matches. If mounts are stacked, read &lt;code&gt;/proc/self/mountinfo&lt;/code&gt; as well: the filesystem on top hides what sits below it.&lt;/p&gt;

&lt;p&gt;Then check the cron runs after the deployment. The jobs have to actually run, and their new logs have to be free of ELOOP. Silence from a job that never started proves nothing.&lt;/p&gt;

&lt;p&gt;The deployment was confirmed working on September 10. One check we have not run yet: in a maintenance window, with the NFS-dependent jobs paused, stop autofs inside the cron container, read the PHP entry point, then restart autofs and confirm it is active. The direct bind should stay readable. That tests the dependency removal, not an NFS hang.&lt;/p&gt;

&lt;h2&gt;
  
  
  The maintenance we kept
&lt;/h2&gt;

&lt;p&gt;We now have two maps, and any change to their shared NFS entries has to land in both. Removing a runtime dependency left us a configuration chore.&lt;/p&gt;

&lt;p&gt;The code was local all along. We changed how the crons reach it, and opening a local PHP file no longer needs the automounter to cooperate.&lt;/p&gt;

&lt;p&gt;Have you met ELOOP with no symlink in sight? What was underneath on your side: autofs, a bind mount, something stranger? Genuinely curious.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>docker</category>
      <category>devops</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Everyone is going back to native. We never left, and the bet paid in a way we didn't expect</title>
      <dc:creator>Mathieu Poli</dc:creator>
      <pubDate>Fri, 11 Sep 2026 11:45:22 +0000</pubDate>
      <link>https://dev.to/goodbarber/everyone-is-going-back-to-native-we-never-left-and-the-bet-paid-in-a-way-we-didnt-expect-58lp</link>
      <guid>https://dev.to/goodbarber/everyone-is-going-back-to-native-we-never-left-and-the-bet-paid-in-a-way-we-didnt-expect-58lp</guid>
      <description>&lt;p&gt;You've probably read it by now: Shopify is moving all of its mobile apps back to Swift and Kotlin, six years after going all-in on React Native. &lt;a href="https://shopify.engineering/back-to-native" rel="noopener noreferrer"&gt;In their own words&lt;/a&gt;, "building the same feature in Swift and Kotlin no longer carries the cost it used to," because coding agents now do enough of the work in between. We never left, and I want to be precise about why. We bet on native in 2011 for one reason: iOS and Android would outlive every layer stacked on top of them. Every layer since has been temporary, which we expected. What ended the detour was AI, which we didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  We bet on native because it was the layer that would last
&lt;/h2&gt;

&lt;p&gt;When people ask why an app builder compiles Swift and Kotlin instead of shipping one cross-platform codebase, they expect an answer about quality, and the honest answer is about time. In 2011 we had to decide what to build our engines on, knowing we'd carry that choice for a decade or more, so the question we asked was which layer would still be there in ten years.&lt;br&gt;
The platforms would. Apple and Google were not going to abandon their operating systems, their toolchains or their SDKs. Everything in between, the frameworks promising to spare you the platforms, had to earn a decade of existence, and nothing about them suggested they would. So we invested in the layer that was certain to remain, and we treated everything stacked on it as temporary, however popular it was that year. That's the whole bet. It cost us more per feature, and it bought us a foundation we have never had to rewrite because a dependency died underneath it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every layer since has been temporary
&lt;/h2&gt;

&lt;p&gt;The waves came on schedule, roughly one every few years, &lt;a href="https://dev.to/goodbarber/the-freedom-you-dont-want-why-infinite-customization-is-a-tax-not-a-feature-2og6"&gt;and each was presented to us as our replacement&lt;/a&gt;. 2011: PhoneGap, bought by Adobe that year, and the first serious promise of one web codebase for every platform. 2015: React Native. 2016: Xamarin, bought by Microsoft, and the promise of C# everywhere. 2018: Flutter 1.0. We evaluated two of them seriously, React Native a couple of years after it shipped and Flutter once it had matured, and both times the same question decided it: what's the lifespan of this layer, compared with the lifespan of what's underneath?&lt;br&gt;
Then the answers arrived. &lt;a href="https://cordova.apache.org/announcements/2020/08/14/goodbye-phonegap.html" rel="noopener noreferrer"&gt;Adobe discontinued PhoneGap in 2020&lt;/a&gt;. &lt;a href="https://dotnet.microsoft.com/en-us/platform/support/policy/xamarin" rel="noopener noreferrer"&gt;Microsoft ended Xamarin support in 2024&lt;/a&gt;. And this year Shopify, which had bet on the most successful of those layers, announced its way back to the platforms. The intermediate layer kept changing names while iOS and Android kept theirs. None of that makes the people who chose those frameworks wrong: for a company shipping one app, avoiding a second codebase was a rational bet, and I'd have made it in their seat. It's just a bet on a shorter horizon than ours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we could afford a bet nobody else could
&lt;/h2&gt;

&lt;p&gt;What made our choice possible is simpler than vision, and I'd rather state it plainly. An app builder doesn't build one app; it builds engines that render thousands. At GoodBarber, our iOS engine is Swift, our Android engine is Kotlin, and a third engine produces a Progressive Web App; &lt;a href="https://dev.to/goodbarber/the-rendering-engine-how-a-no-code-project-becomes-a-smooth-native-app-nd4"&gt;a single project description feeds all three&lt;/a&gt;. We build three times, but we build three times once, and every app the platform renders amortizes those three engines a little more. The duplication cost that pushed the industry toward hybrid, divided by thousands of apps, was simply our business model. The reason to leave native never existed on our side of the fence.&lt;br&gt;
The bill is real, though. Three engines mean three expertises to hire and three implementations to keep in step, and the parity work between platforms is a permanent line in our budget. It's payable for one reason only: it's paid once, and divided by thousands.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the bet bought
&lt;/h2&gt;

&lt;p&gt;Most of what native bought us lives below what users can name. The sensory layer: haptic feedback, parallax, motion, a tab bar that floats, scrolling with the system's exact physics, transitions that belong to the OS. A media player that keeps playing when the screen goes dark and follows the user from screen to screen. Local notifications that fire from the app binary itself when someone enters a geofence or walks near a beacon, with no server involved, which only exists when the app is real native code running with the operating system's own permissions. Each platform's own idioms, because three engines can be right in three worlds instead of average in two: the share sheet is Apple's on iOS and Android's on Android. And when Apple ships a new primitive at WWDC, we can adopt it as soon as it lands, with no framework in between that has to wrap it first.&lt;/p&gt;

&lt;h2&gt;
  
  
  It ended, but not the way we pictured
&lt;/h2&gt;

&lt;p&gt;We expected the intermediate layers to wear out one by one, and we expected time to settle the bet. What settled it was AI, and from a direction we hadn't imagined. Instead of waiting for the hybrid frameworks to fade, it removed the only reason anyone had to use them. The whole case for a shared codebase was the cost of building twice, and models and agents now carry enough of the translation, the testing and the review across platforms that duplication stopped deciding anything. A team shipping one app can land today where we've been since 2011, on the platform's own languages with the platform's own tooling. Two roads to the same place, fifteen years apart: agents for them, architecture for us.&lt;br&gt;
For us, AI lands on top of that structure &lt;a href="https://dev.to/goodbarber/ai-didnt-kill-the-framework-it-made-it-more-valuable-3n5p"&gt;rather than instead of it&lt;/a&gt;. Generated sections live inside apps whose engines were already native, and the model works with first-party toolchains, &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;where it can test its own work fastest&lt;/a&gt;. We bet that the platforms would outlast everything built on top of them. They did. We just thought the proof would take longer, and come from somewhere else. The bet paid, not the way we pictured, which is usually how bets pay.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Mathieu Poli — Head of Frontend Engineering @ GoodBarber. &lt;br&gt;
I teach and write about frontend engineering, product design, and AI — and everything that happens when the three meet.&lt;br&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>mobile</category>
      <category>ios</category>
      <category>android</category>
      <category>nocode</category>
    </item>
  </channel>
</rss>
