<?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: Vadym Arnaut</title>
    <description>The latest articles on DEV Community by Vadym Arnaut (@arvavit).</description>
    <link>https://dev.to/arvavit</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3896990%2Ff229c67f-46a1-4ecf-b3f5-71e2dd14f1bc.jpg</url>
      <title>DEV Community: Vadym Arnaut</title>
      <link>https://dev.to/arvavit</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/arvavit"/>
    <language>en</language>
    <item>
      <title>I sent HTTP QUERY through the real internet. The smarter the layer, the harder it broke.</title>
      <dc:creator>Vadym Arnaut</dc:creator>
      <pubDate>Tue, 07 Jul 2026 14:49:11 +0000</pubDate>
      <link>https://dev.to/arvavit/i-sent-http-query-through-the-real-internet-the-smarter-the-layer-the-harder-it-broke-586b</link>
      <guid>https://dev.to/arvavit/i-sent-http-query-through-the-real-internet-the-smarter-the-layer-the-harder-it-broke-586b</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; HTTP got a new method in June: QUERY (RFC 10008), a GET that carries a body. Everyone wrote explainers and threat models. Nobody actually fired it at real infrastructure. So I did, for a few nights: ten production edges from CloudFront to raw nginx, three browser engines, real client libraries. One law fell out of all of it. The more a layer "understands" HTTP methods, the more likely it is to break the newest one. And when I forced a real cache to store a QUERY, it served one query's answer to a different query.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;RFC 10008 shipped in June. If you missed it: QUERY is a request method with GET's semantics (safe, idempotent, cacheable) that carries its query in the body like POST. It's the thing we've faked with &lt;code&gt;POST /search&lt;/code&gt; for twenty years, finally given a name.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7vlchr977b1weka7o4s0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7vlchr977b1weka7o4s0.png" alt="Comparison: GET carries no body, POST is not safe or cacheable, QUERY is the first method that is all three of body-carrying, safe-and-idempotent, and cacheable." width="800" height="431"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Twenty years of &lt;code&gt;POST /search&lt;/code&gt; was a small lie we told every cache and WAF. QUERY is the honest version.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Within days of the RFC there was already a wave of posts. Explainers mostly, plus a couple of good security write-ups mapping the "attack surface." Every one of them was theory. Not one ran a request. The &lt;a href="https://hivesecurity.gitlab.io/blog/http-query-method-rfc-10008-attack-surface/" rel="noopener noreferrer"&gt;Hive Security piece&lt;/a&gt; even calls its own smuggling section "the most speculative entry on this list, there's no confirmed public case yet." So the interesting question was wide open: what does QUERY actually DO when it hits the real internet?&lt;/p&gt;

&lt;p&gt;I built a little lab and spent a few nights finding out. Repo at the bottom. Here's what happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  The edge decides, and it's a coin toss
&lt;/h2&gt;

&lt;p&gt;First stop, the CDN. I sent one QUERY request through ten production edges and watched who forwarded it to the origin and who killed it on the doorstep.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4c1slqsfk7nq75tcca3l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4c1slqsfk7nq75tcca3l.png" alt="Ten CDNs split in two. Blocking at the edge: CloudFront 403, Akamai 400, Google 405, Netlify 405, Bunny 405, Vercel 403 challenge. Forwarding to origin: Cloudflare, Fastly, Fly.io, nginx, HAProxy, Varnish." width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One QUERY request, ten edges. Every status is the edge's own, not the origin's.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The split was clean, and it was backwards from what you'd want. CloudFront answers &lt;code&gt;403 Invalid method&lt;/code&gt;, and there is NO config that fixes it: its allowed-methods list is a closed set of seven verbs and QUERY will never be one of them. Akamai (which, and I love this, co-authored the RFC) returns &lt;code&gt;400&lt;/code&gt;, treating QUERY as an unparseable token. Google, Netlify, Bunny all say &lt;code&gt;405&lt;/code&gt;. Vercel does something stranger, it challenges the verb as bot traffic, which I wrote up on its own.&lt;/p&gt;

&lt;p&gt;And the edges that let QUERY through? nginx. HAProxy. Fastly. Cloudflare. Fly. Plain, configurable proxies with no opinion about your verbs.&lt;/p&gt;

&lt;p&gt;That's when it clicked. The blockers all shared one trait: they're the smart, managed, "we handle HTTP methods for you" platforms. The forwarders are the dumb pipes. &lt;strong&gt;The more a layer understands about HTTP methods, the more likely it is to reject the newest one.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I want to be careful here, because the docs lie about this and I almost repeated it. The research I read said Varnish "breaks" QUERY. So I installed real Varnish and sent it one. It forwarded it fine (uncached, but it reached the origin). Not blocked. The real blockers are the proprietary edges with closed method vocabularies, not the open-source proxies you can actually configure. Measure, don't trust the write-up. Even this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same list, written before QUERY existed, is everywhere
&lt;/h2&gt;

&lt;p&gt;Once you see the pattern at the edge you can't unsee it up and down the stack. It's always the same shape: a hardcoded allow-list of "known" methods, frozen before June 2026, and QUERY isn't on it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7ww6uod2h1up7793ygph.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7ww6uod2h1up7793ygph.png" alt="The pattern at every layer: CDN edge, browser CORS, client retries, observability, API schema, WAF. GET passes at every layer; QUERY is blocked at every layer, each by an allow-list written before QUERY existed." width="799" height="571"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Change nothing but the verb, and every layer flips.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I measured the ones I could reach. Cross-origin QUERY in the browser preflights (it isn't CORS-safelisted), and if your server answers &lt;code&gt;Access-Control-Allow-Methods: GET, POST&lt;/code&gt;, which is what a huge share of the internet hardcodes, the browser blocks it before your code runs. Identical in Chromium, Firefox, and WebKit.&lt;/p&gt;

&lt;p&gt;Client retries are my favorite one. QUERY is idempotent, the ONE property that makes a retry safe. So I pointed urllib3's default retry policy at a server that fails once and fired a GET and a QUERY. The GET retried and succeeded. The QUERY failed on the first try and gave up, because urllib3's retry allow-list is &lt;code&gt;{GET, HEAD, PUT, DELETE, OPTIONS, TRACE}&lt;/code&gt; and nobody has added QUERY. Your CDN breaks QUERY because it's too smart. Your HTTP client breaks it because it's too careful. The exact safety mechanism built to stop clients double-firing POSTs now refuses to retry the one new method that is genuinely safe to retry.&lt;/p&gt;

&lt;p&gt;It just keeps going. OpenTelemetry drops QUERY into &lt;code&gt;_OTHER&lt;/code&gt; on its Java and Go agents, so it vanishes from your per-method dashboards. OpenAPI 3.1 literally cannot name a QUERY operation. OWASP CRS blocks it with a four-token string older than the RFC. Nobody sat down and wrote "block QUERY." They wrote "here are the methods" years ago, and the list is now the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  I made a real cache store QUERY. It served the wrong answer.
&lt;/h2&gt;

&lt;p&gt;Here's the part that made me sit back.&lt;/p&gt;

&lt;p&gt;QUERY's whole reason to exist over POST is that it's cacheable. But the cache key has to include the body, because the body IS the query. The RFC says exactly that, then adds one nervous sentence in the Security Considerations:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Caches that normalize QUERY content incorrectly or in ways that are significantly different from how the resource processes the content can return an incorrect response if normalization results in a false positive.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That "false positive" is a polite way to spell cache poisoning. The authors saw it, wrote it down, and shipped anyway. I wanted to watch it happen.&lt;/p&gt;

&lt;p&gt;So I told real Varnish to cache QUERY and sent it three requests.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6uevyv8eys5ix3i39jns.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6uevyv8eys5ix3i39jns.png" alt="Varnish forced to cache QUERY. Request with body a:1 gets origin token 2. Second identical request hits the cache. Third request with a totally different body a:999 also gets token 2, the cached response for a:1." width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Three different queries, one URL. Varnish served the first one's answer to all of them.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Varnish keys its cache on the URL and host. Not the body. So the moment you force it to cache a QUERY, every request to &lt;code&gt;/search&lt;/code&gt; collapses into one entry. I sent &lt;code&gt;{"a":1}&lt;/code&gt;, then &lt;code&gt;{"a":1}&lt;/code&gt; again (fine, a real cache hit), then &lt;code&gt;{"a":999}&lt;/code&gt;, a completely different query. It got &lt;code&gt;{"a":1}&lt;/code&gt;'s cached response. The origin was never even asked. And it's worse than key collision: Varnish drops the request body on the cacheable fetch, so the origin couldn't have computed the right answer even if it had been asked.&lt;/p&gt;

&lt;p&gt;I checked this the honest way, hitting the origin directly to confirm it echoes each body correctly. It does. This is Varnish, not my test. It's that Security Considerations sentence sitting live in production-grade software: cache QUERY naively and you hand one user's search results to another. The write-ups modeled this risk on paper. Here it is running, in a tiny VCL snippet.&lt;/p&gt;

&lt;p&gt;For balance: I also wrote a ~40-line cache that does it RIGHT, keying on a hash of the body. The fix is genuinely easy. The point is that the caches you already run don't do it, and turning caching on is the dangerous part.&lt;/p&gt;

&lt;h2&gt;
  
  
  The good news: your code is already fine
&lt;/h2&gt;

&lt;p&gt;Here's the twist that keeps this from being all doom. Every layer that breaks QUERY is infrastructure you didn't write. The layer you DID write is ready.&lt;/p&gt;

&lt;p&gt;I checked the receive side: Express, Fastify, and FastAPI all route a QUERY request and read its body without a fuss. Browsers send it, all three engines, via both &lt;code&gt;fetch&lt;/code&gt; and &lt;code&gt;XMLHttpRequest&lt;/code&gt;. The runtimes rolled out the carpet. It's the bouncers at the edge who never got the memo.&lt;/p&gt;

&lt;p&gt;So you can ship QUERY today, as long as you're willing to duck when the edge swings.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4yys3uldhvxd8gj68swu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4yys3uldhvxd8gj68swu.png" alt="The adapter: the client tries native QUERY first; a blocking edge returns 403 or 405; the client retries as POST with an X-HTTP-Method-Override QUERY header, which every edge forwards, and a server middleware unwraps it back into a QUERY." width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Try the real method first. Fall back to POST plus a header the moment an intermediary says no.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The trick is the oldest one in the book: &lt;code&gt;X-HTTP-Method-Override&lt;/code&gt;. The client tries native QUERY. If it eats a 403 or 405 from some middlebox, it retries as a plain POST carrying &lt;code&gt;X-HTTP-Method-Override: QUERY&lt;/code&gt;, and a tiny server middleware unwraps it back into a QUERY. The edge only ever sees a POST, which every CDN on earth forwards. You do lose edge cacheability on that fallback path, which, after the Varnish section, you may not want anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's actually new here
&lt;/h2&gt;

&lt;p&gt;Quick honesty, because it matters. The RISKS in this post are not secret. There's a real blog wave, including solid security write-ups, that describe cache poisoning, the CORS gotcha, unknown-method blocking, all of it. What none of them did was run it. Every piece I found is threat-modeling, and says so. What's new here is the measurement: which edge does what and why, the live Varnish poisoning, the retry gap, the cross-engine CORS behavior, the working fix. Theory is cheap for a three-week-old RFC. I wanted receipts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd want to hear back
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;If you run QUERY behind Cloudflare or Fastly with real caching switched on, does the body-blind poisoning reproduce there too? That's the one experiment I couldn't run without your origin.&lt;/li&gt;
&lt;li&gt;Anyone shipping QUERY in prod, not a demo? Which layer bit you first?&lt;/li&gt;
&lt;li&gt;Is there a single CDN that keys a cache on the request body today, or does "cacheable QUERY" stay theoretical until someone builds it?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lab, every script, the raw numbers, and the ~40-line safe cache:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArVaViT" rel="noopener noreferrer"&gt;
        ArVaViT
      &lt;/a&gt; / &lt;a href="https://github.com/ArVaViT/http-query-lab" rel="noopener noreferrer"&gt;
        http-query-lab
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Empirical tests of the new HTTP QUERY method (RFC 10008) against real clients, frameworks, and production infra
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;http-query-lab&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;Empirical tests of the new &lt;strong&gt;HTTP QUERY method (&lt;a href="https://www.rfc-editor.org/rfc/rfc10008" rel="nofollow noopener noreferrer"&gt;RFC 10008&lt;/a&gt;, June 2026)&lt;/strong&gt;
against real clients, servers, frameworks, and production infrastructure. Not
what the spec says, but what actually happens on the wire in July 2026.&lt;/p&gt;
&lt;p&gt;QUERY is a request method with GET semantics (safe, idempotent, cacheable per
RFC 10008 §2 and §2.7) that carries its query in the body instead of the URI
This repo answers: &lt;em&gt;who on a real request path already speaks it, and who breaks?&lt;/em&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Headline: Vercel's default bot mitigation treats QUERY as a bot signal&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Twenty requests, one client, 400ms apart, one method at a time:&lt;/p&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;&lt;pre class="notranslate"&gt;&lt;code&gt;GET   x20  from node/undici : all 200            (clean)
POST  x20  from node/undici : all 200            (clean, and POST carries a body)
QUERY x20  from node/undici : 200,200,200,403... (challenge at request #4)
QUERY x20  from curl        : 200,...,403...      (challenge at request #5)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The 403 is &lt;code&gt;X-Vercel-Mitigated: challenge&lt;/code&gt; from…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArVaViT/http-query-lab" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>webdev</category>
      <category>http</category>
      <category>security</category>
      <category>backend</category>
    </item>
    <item>
      <title>HTTP finally shipped QUERY. I fired it at production and the edge called it a bot.</title>
      <dc:creator>Vadym Arnaut</dc:creator>
      <pubDate>Sat, 04 Jul 2026 02:58:12 +0000</pubDate>
      <link>https://dev.to/arvavit/http-finally-shipped-query-i-fired-it-at-production-and-the-edge-called-it-a-bot-7cj</link>
      <guid>https://dev.to/arvavit/http-finally-shipped-query-i-fired-it-at-production-and-the-edge-called-it-a-bot-7cj</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; QUERY (RFC 10008) is a real HTTP method now, a GET that carries a body. I deployed echo endpoints on Vercel (Python and Node), Supabase Edge, and local FastAPI, then fired GET, POST and QUERY at each. Every runtime handled QUERY fine. Then Vercel's default bot mitigation started 403-challenging my QUERY traffic specifically. Same client, same body: 20 GETs clean, 20 POSTs clean, QUERY tripped the challenge on request #4. Reproduced from a second client. The functions are ready for QUERY. The edge in front of them is not.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;RFC 10008 landed in June. If you missed the wave of explainers: QUERY is a request method with GET semantics (safe, idempotent, cacheable) that carries its query in the body instead of the URL. It closes the old gap where a search filter is too big for a query string but you don't want to lie and call it a POST.&lt;/p&gt;

&lt;p&gt;Every post I read explained the spec. Nobody actually fired it at a live production edge. So I did, late one night, with a throwaway lab. Most of it was boring. One result made me sit up and say "wait, what?" out loud. Repo at the bottom.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Four echo endpoints, each returns the method and body it saw:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Vercel serverless, Python runtime&lt;/li&gt;
&lt;li&gt;Vercel serverless, Node runtime&lt;/li&gt;
&lt;li&gt;Supabase Edge Function (Kong gateway in front of Deno)&lt;/li&gt;
&lt;li&gt;local FastAPI on uvicorn, for the parser tests&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole Python function is basically this:&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="c1"&gt;# api/echo.py on Vercel
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseHTTPRequestHandler&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;do_QUERY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;   &lt;span class="c1"&gt;# yes, you just define do_QUERY
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_echo&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;do_GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_echo&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Python's &lt;code&gt;BaseHTTPRequestHandler&lt;/code&gt; dispatches on &lt;code&gt;do_&amp;lt;METHOD&amp;gt;&lt;/code&gt;, so &lt;code&gt;do_QUERY&lt;/code&gt; is all it takes. Miss the handler and you get a clean 501, which is exactly what the RFC wants.&lt;/p&gt;
&lt;h2&gt;
  
  
  The runtimes are ready
&lt;/h2&gt;

&lt;p&gt;This part was boring in the best way. QUERY went end to end everywhere.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; QUERY https://http-query-lab.vercel.app/api/echo &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"q":"hi"}'&lt;/span&gt;
&lt;span class="c"&gt;# {"runtime":"python","method":"QUERY","body":"{\"q\":\"hi\"}"}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Node's parser (llhttp) has known QUERY since v21.7.2, it sits in &lt;code&gt;http.METHODS&lt;/code&gt; on 22 and 26. Supabase's Deno gateway forwarded it on both HTTP/1.1 and HTTP/2. The only 405 I got was on a static asset, which is correct.&lt;/p&gt;

&lt;p&gt;Frameworks are where it gets uneven. What actually happens today:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;QUERY today&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Node &lt;code&gt;http&lt;/code&gt; / llhttp&lt;/td&gt;
&lt;td&gt;in &lt;code&gt;http.METHODS&lt;/code&gt; since 21.7.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Express 5&lt;/td&gt;
&lt;td&gt;routes it, &lt;code&gt;app.query()&lt;/code&gt; exists (auto-generated from &lt;code&gt;http.METHODS&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fastify 5&lt;/td&gt;
&lt;td&gt;refuses it until &lt;code&gt;addHttpMethod('QUERY', {hasBody:true})&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python &lt;code&gt;http.server&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;define &lt;code&gt;do_QUERY&lt;/code&gt;, missing handler gives 501&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FastAPI&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@app.api_route(methods=["QUERY"])&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;.NET 10&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;HttpMethod.Query&lt;/code&gt; on client and server&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Fastify was the one that bit me. A &lt;code&gt;QUERY&lt;/code&gt; route throws at registration with "QUERY method is not supported", and an incoming QUERY 404s until you opt in. Not a bug, it validates against a fixed method list. Just a footgun if you assume parity with Express.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5zbhhcyil28ovzyhpw0s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5zbhhcyil28ovzyhpw0s.png" alt="Request-path diagram. GET and POST pass through Vercel's edge to the Python and Node functions and return 200. QUERY is stopped at Vercel's edge with a 403 challenge and never reaches the function. Supabase's Kong plus Deno edge forwards QUERY over HTTP/1.1 and HTTP/2 and returns 200."&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Every runtime I tried returns QUERY. The only thing that drops it sits at one edge.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Then the edge called it a bot
&lt;/h2&gt;

&lt;p&gt;Here is the part nobody wrote about.&lt;/p&gt;

&lt;p&gt;I ran a burst test against the Vercel endpoint. Twenty requests, same client, 400ms apart, one method at a time from the same connection pool. GET first, then POST, then QUERY:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET   x20:  all 200
POST  x20:  all 200        (POST carries a body too, remember)
QUERY x20:  200,200,200,403,403,403,403...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq0rwnbucccn29okxbncz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq0rwnbucccn29okxbncz.png" alt="Four rows of twenty status cells. GET from node runs all twenty green (200 OK). POST from node runs all twenty green. QUERY from node turns red (403 challenge) from the fourth cell on. QUERY from curl turns red from the fifth cell on."&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Same client and same body across the top three rows. GET and POST finish clean, QUERY does not.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Request #4 came back &lt;code&gt;403&lt;/code&gt;. &lt;code&gt;X-Vercel-Mitigated: challenge&lt;/code&gt;. That 403 was not from my function. It was the edge, and my request NEVER reached the runtime. Once it flagged me, every method got challenged, GET included, for more than ten minutes.&lt;/p&gt;

&lt;p&gt;My first thought was the boring one: "it's the client, curl and undici look like bots, of course the scorer hates me." So I controlled for it. The GET and POST bursts above are from the exact same client, the same fingerprint, and POST even carries a body. They ran clean to twenty. Only the method changed.&lt;/p&gt;

&lt;p&gt;Then I ran the QUERY burst from a second client, curl instead of undici. Challenged on request #5. Same story!&lt;/p&gt;

&lt;p&gt;So it is not the fingerprint, not the body, not the request rate. Twenty GETs and twenty POSTs at the same cadence sail right through. Swap the method to QUERY, change nothing else, and a bot challenge fires in four requests. That is when it clicked: the edge is treating the QUERY verb ITSELF as a bot signal. No custom firewall rules, no attack mode. This is the default behavior on a fresh project.&lt;/p&gt;

&lt;p&gt;And honestly? It makes a grim kind of sense. Real browser traffic doesn't send QUERY yet, so to a heuristic "QUERY from a non-browser" looks exactly like a scanner poking your API with a weird verb. The spec shipped faster than the security tooling learned the verb exists. The runtime rolled out the carpet, and the bouncer at the door never got the memo.&lt;/p&gt;

&lt;p&gt;You can turn Vercel's bot management off or bypass it with a token, so this isn't fatal. But flip QUERY on for a public endpoint today, sit behind a CDN with default bot rules, and some slice of your legitimate non-browser callers get challenged into a wall. You won't even see it in your app logs, because it never reaches your app.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two more footguns before you ship QUERY
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Lowercase kills it.&lt;/strong&gt; &lt;code&gt;fetch&lt;/code&gt; only uppercases the six classic methods, so &lt;code&gt;fetch(url, {method:'query'})&lt;/code&gt; goes on the wire literally as lowercase &lt;code&gt;query&lt;/code&gt;. That is not cosmetic. The default uvicorn parser (httptools, which wraps llhttp) rejects it with a 400 before your app runs:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;uvicorn --http httptools:   QUERY -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;200,  query -&amp;gt; 400,  get -&amp;gt; 400
&lt;span class="gp"&gt;uvicorn --http h11:         QUERY -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;200,  query -&amp;gt; 200,  get -&amp;gt; 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;llhttp matches its method table case-sensitively, so any lowercase verb is an invalid token to it, not just QUERY. Switch to the pure-Python h11 parser and the same lowercase request passes through to a 405. So "my QUERY works locally but 400s in prod" can just be two different parsers. The normalization gap itself is tracked in &lt;a href="https://github.com/whatwg/fetch/issues/1938" rel="noopener noreferrer"&gt;whatwg/fetch#1938&lt;/a&gt;, worth a read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing caches it yet.&lt;/strong&gt; The whole selling point of QUERY over POST is that it is cacheable. In practice no browser cache stores it today. I put &lt;code&gt;Cache-Control: public, max-age=300&lt;/code&gt; on a QUERY response against a local origin, no CDN in the path, and fired two identical requests. Both hit the origin. The GET control on the same setup served the second from cache. That matches what jeswr measured for Chrome and Firefox in that same fetch issue. The RFC allows caching QUERY with a cache key that includes the body, but existing caches key on method plus URL, so they structurally can't, and they don't. The "cacheable" promise is real on paper and ZERO in your browser right now.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I'd want to hear back
&lt;/h2&gt;

&lt;p&gt;This was one edge, one night, one very confused engineer staring at a wall of 403s. I really want to know how far it generalizes.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If you're on Cloudflare or AWS WAF, does default bot protection challenge QUERY the way Vercel's does? I only tested one edge, curious whether this is industry-wide or a Vercel default.&lt;/li&gt;
&lt;li&gt;Anyone running QUERY on a real public endpoint, not a demo? What broke?&lt;/li&gt;
&lt;li&gt;Is body-aware cache keying ever landing in a shipping cache, or does "cacheable QUERY" stay theoretical?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Lab, all the scripts, and the raw numbers:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArVaViT" rel="noopener noreferrer"&gt;
        ArVaViT
      &lt;/a&gt; / &lt;a href="https://github.com/ArVaViT/http-query-lab" rel="noopener noreferrer"&gt;
        http-query-lab
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Empirical tests of the new HTTP QUERY method (RFC 10008) against real clients, frameworks, and production infra
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;http-query-lab&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;Empirical tests of the new &lt;strong&gt;HTTP QUERY method (&lt;a href="https://www.rfc-editor.org/rfc/rfc10008" rel="nofollow noopener noreferrer"&gt;RFC 10008&lt;/a&gt;, June 2026)&lt;/strong&gt;
against real clients, servers, frameworks, and production infrastructure. Not
what the spec says, but what actually happens on the wire in July 2026.&lt;/p&gt;
&lt;p&gt;QUERY is a request method with GET semantics (safe, idempotent, cacheable per
RFC 10008 §2 and §2.7) that carries its query in the body instead of the URI
This repo answers: &lt;em&gt;who on a real request path already speaks it, and who breaks?&lt;/em&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Headline: Vercel's default bot mitigation treats QUERY as a bot signal&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Twenty requests, one client, 400ms apart, one method at a time:&lt;/p&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;&lt;pre class="notranslate"&gt;&lt;code&gt;GET   x20  from node/undici : all 200            (clean)
POST  x20  from node/undici : all 200            (clean, and POST carries a body)
QUERY x20  from node/undici : 200,200,200,403... (challenge at request #4)
QUERY x20  from curl        : 200,...,403...      (challenge at request #5)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;The 403 is &lt;code&gt;X-Vercel-Mitigated: challenge&lt;/code&gt; from…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArVaViT/http-query-lab" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>webdev</category>
      <category>backend</category>
      <category>http</category>
      <category>api</category>
    </item>
    <item>
      <title>Three dumb ways our prod got slow (and not one was a slow algorithm)</title>
      <dc:creator>Vadym Arnaut</dc:creator>
      <pubDate>Thu, 25 Jun 2026 17:13:29 +0000</pubDate>
      <link>https://dev.to/arvavit/three-dumb-ways-our-prod-got-slow-and-not-one-was-a-slow-algorithm-29ag</link>
      <guid>https://dev.to/arvavit/three-dumb-ways-our-prod-got-slow-and-not-one-was-a-slow-algorithm-29ag</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; Three prod slowdowns in one week. A 30-second timeout that was secretly TWO MINUTES. A math library we shipped twice to students who don't even have math. And a dashboard that built tens of thousands of objects just to draw a few hundred rows. Not one of them was a slow algorithm. The cause was never where I looked first.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We run Equip (open-source LMS: FastAPI + React + Supabase Postgres). I spent a week chasing latency, and the slow-query log lied to me every single time. Every fix lived UPSTREAM of the code that looked slow. Here's the wall of shame.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The 30-second timeout that was actually two minutes
&lt;/h2&gt;

&lt;p&gt;I was sure we had a 30s &lt;code&gt;statement_timeout&lt;/code&gt;. I set it the obvious way:&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="c1"&gt;# looks right. does absolutely nothing in prod.
&lt;/span&gt;&lt;span class="n"&gt;connect_args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;options&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c statement_timeout=30000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Then I ran &lt;code&gt;SHOW statement_timeout&lt;/code&gt; on prod. It said &lt;code&gt;2min&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two. Minutes. Not 30 seconds. The raw cluster default.&lt;/p&gt;

&lt;p&gt;Here's why. We sit behind Supabase's Supavisor in transaction-pooling mode, and a transaction pooler SILENTLY drops libpq startup &lt;code&gt;options&lt;/code&gt;. The server connection you borrow for a transaction is not the one you "connected" with, so everything you set at startup just... evaporates. Our 30-second safety net was a comment. One ugly query could pin a pooled connection for the full two minutes.&lt;/p&gt;

&lt;p&gt;The fix is a &lt;code&gt;SET LOCAL&lt;/code&gt; on every transaction, wired through a SQLAlchemy engine event:&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="nd"&gt;@event.listens_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_engine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;begin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_set_statement_timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# SET LOCAL is scoped to THIS transaction, so it can't leak
&lt;/span&gt;    &lt;span class="c1"&gt;# onto the next client that borrows this pooled connection
&lt;/span&gt;    &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec_driver_sql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SET LOCAL statement_timeout = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;30s&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;SET LOCAL&lt;/code&gt; is the ONLY form that survives a transaction pooler. A plain session &lt;code&gt;SET&lt;/code&gt; leaks the value onto whoever grabs that connection next. The startup &lt;code&gt;options&lt;/code&gt; get dropped. &lt;code&gt;SET LOCAL&lt;/code&gt; resets on commit, so every transaction gets exactly the ceiling you asked for and nobody inherits it.&lt;/p&gt;

&lt;p&gt;Lesson: behind a transaction pooler, "I configured the connection" is a sentence you should not trust. That connection isn't yours.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. We shipped KaTeX TWICE, to people who have no math
&lt;/h2&gt;

&lt;p&gt;The student chapter page kept getting heavier and nobody could tell me why. The content is mostly text. There's basically no math in prod.&lt;/p&gt;

&lt;p&gt;So I opened the bundle. We were shipping KaTeX twice. TWICE! One copy (v0.17.0) imported straight into &lt;code&gt;ChapterView&lt;/code&gt;. A second copy (v0.16.47) dragged in transitively by the rich-text editor's math extension. Two full copies of a typesetting library, and the student-facing one loaded eagerly on every single chapter render, formula or not.&lt;/p&gt;

&lt;p&gt;And there was a nastier landmine under it. &lt;code&gt;katex.min.css&lt;/code&gt; only lived in the teacher-editor chunk. So the very first time a teacher typed a formula, it looked perfect for THEM and rendered unstyled and broken for every student, because students never load that chunk. Ouch.&lt;/p&gt;

&lt;p&gt;Two fixes. Dedupe to one copy with an npm &lt;code&gt;override&lt;/code&gt; so the editor extension uses the app's katex. Then make the whole thing lazy, loading the library AND its CSS only when there's actually math on the page:&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="c1"&gt;// only runs if the page has unrendered math markers&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;katex&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;katex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;katex/dist/katex.min.css&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// ship the CSS WITH the lib, not in some other chunk&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;ChapterView&lt;/code&gt; fires it and forgets. The student chapter chunk dropped from 84.2 KB to 9.9 KB gzip. For the page loads with no math (almost all of them) katex now never downloads at all.&lt;/p&gt;

&lt;p&gt;Lesson: a dependency that your dependency drags in is STILL your bytes. "Import it where it's used" beats "import it at the top" exactly when most people never hit that path.&lt;/p&gt;
&lt;h2&gt;
  
  
  3. We built tens of thousands of objects to draw a few hundred rows
&lt;/h2&gt;

&lt;p&gt;This one wore three masks, all the same sin: doing O(N×M) work to render O(N) of UI.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/users/me/courses&lt;/code&gt; (the dashboard) eager-loaded the WHOLE module-and-chapter tree for every course. Hundreds of entities on a big course. Then the dashboard schema threw all of it in the bin, because the dashboard shows course cards, not chapters.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/progress/course/{id}/students&lt;/code&gt; shipped the full per-chapter breakdown for every student. That's students times chapters. On our fat-seed test course it was tens of thousands of objects in ONE response, just to draw a table of names and averages.&lt;/li&gt;
&lt;li&gt;The localized course detail validated the same tree up to THREE times per request and cloned a couple hundred chapter objects with &lt;code&gt;model_copy&lt;/code&gt; on the way.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fixes are boring, and that's the point. A slim dashboard schema with no &lt;code&gt;modules&lt;/code&gt; field, so the tree never loads. A progress list that returns server-computed averages and lazy-loads one student's detail only when you expand their row. And a single bottom-up validation pass with Pydantic v2's &lt;code&gt;revalidate_instances="never"&lt;/code&gt;, so each entity is validated once instead of three times.&lt;/p&gt;

&lt;p&gt;Then the database side of the exact same story: we were missing indexes. Three of them, one migration. The two that mattered are partial composite indexes:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- we only ever aggregate COMPLETED attempts, so don't index the rest&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;ix_quiz_attempts_quiz_user_completed&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;quiz_attempts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quiz_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;completed_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;ix_chapter_progress_chapter_user_completed&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;chapter_progress&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chapter_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;completed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That &lt;code&gt;WHERE&lt;/code&gt; predicate is the whole trick. These queries only ever touch completed work, so indexing the unfinished rows is dead weight. The third index, on &lt;code&gt;certificates(status)&lt;/code&gt;, was already declared in our ORM model and had just never been migrated to prod. A model-vs-schema drift the audit caught on the way past.&lt;/p&gt;

&lt;p&gt;Lesson: the slow-query log shows you the query that's slow. It does NOT tell you the query should never have been fetching that shape to begin with.&lt;/p&gt;
&lt;h2&gt;
  
  
  The thread tying it together
&lt;/h2&gt;

&lt;p&gt;None of this was algorithmic. A startup option a pooler ignores. A duplicated dependency. Over-fetching a tree just to throw it away. A missing &lt;code&gt;WHERE&lt;/code&gt; on an index. Every one of them lived upstream of the code that profiled slow: in the connection layer, the bundler, the serializer, the migration history.&lt;/p&gt;

&lt;p&gt;The profiler points at the symptom. Now I try to ask, every single time: "what BUILT this input?" before I optimize whatever's chewing through it.&lt;/p&gt;

&lt;p&gt;A few things I'd honestly love to hear back on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If you're behind a transaction pooler (Supavisor, PgBouncer): do you &lt;code&gt;SET LOCAL&lt;/code&gt; per transaction, or push timeouts up to the gateway and keep the app dumb? We went app-side and I'm genuinely not sure it's right long-term.&lt;/li&gt;
&lt;li&gt;Has a transitive dependency ever shipped a second copy of something already in your bundle? How did you catch it before prod? We're eyeing a gzip size budget in CI.&lt;/li&gt;
&lt;li&gt;Where do you draw the lazy-loading line? Math was easy. I'm much less sure about, say, a charting lib used on a third of pages.&lt;/li&gt;
&lt;/ul&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArVaViT" rel="noopener noreferrer"&gt;
        ArVaViT
      &lt;/a&gt; / &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;
        equip
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Free, open-source LMS for Bible schools, ministries, and nonprofit educational programs. React + FastAPI + Supabase.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/frontend/public/favicon.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2Ffrontend%2Fpublic%2Ffavicon.svg" width="80" alt="Equip logo"&gt;&lt;/a&gt;
&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Equip&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;
  A free, open-source learning management system built for Bible schools
  church ministries, and nonprofit educational programs
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://github.com/ArVaViT/equip/blob/main/LICENSE" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/fbd05efc647372434fa773c0393f29e76f8dfd729ea1b5e069038c6e8179f339/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f417256615669542f65717569703f7374796c653d666c61742d737175617265" alt="MIT License"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/backend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/659c778682ca665110e0873307d8412572e9b5a59ecf78cbd04e38dab4804200/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f6261636b656e642d63692e796d6c3f6c6162656c3d6261636b656e64267374796c653d666c61742d737175617265" alt="Backend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/frontend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a549c27eebbf23022773ad1017442dcaf20b27c7ef080b4f3f8ac3344872eb87/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f66726f6e74656e642d63692e796d6c3f6c6162656c3d66726f6e74656e64267374796c653d666c61742d737175617265" alt="Frontend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/7e02756ed49d82974d5e10b79c40680819b90f5f2076338b4f3812b1cb096ae2/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6973737565732f417256615669542f65717569702f676f6f64253230666972737425323069737375653f7374796c653d666c61742d73717561726526636f6c6f723d373035376666266c6162656c3d676f6f642532306669727374253230697373756573" alt="Good first issues"&gt;
  &lt;/a&gt;
  &lt;a href="https://app.codecov.io/gh/ArVaViT/equip" rel="nofollow noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a2082409068ba3266a009213fdfef36845f9ba34386f31c378b6d250482598fa/68747470733a2f2f696d672e736869656c64732e696f2f636f6465636f762f632f6769746875622f417256615669542f65717569703f7374796c653d666c61742d737175617265266c6162656c3d636f766572616765" alt="Code coverage"&gt;
  &lt;/a&gt;
  &lt;a href="https://scorecard.dev/viewer/?uri=github.com/ArVaViT/equip" rel="nofollow noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/63c1ae6c3a1ca1f1b29e7730e3c6ec8aab5caa520e104beaa3dcd66af5d66dc3/68747470733a2f2f696d672e736869656c64732e696f2f6f7373662d73636f7265636172642f6769746875622e636f6d2f417256615669542f65717569703f7374796c653d666c61742d737175617265266c6162656c3d6f70656e73736625323073636f726563617264" alt="OpenSSF Scorecard"&gt;
  &lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;Live demo&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/ROADMAP.md" rel="noopener noreferrer"&gt;Roadmap&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CONTRIBUTING.md" rel="noopener noreferrer"&gt;Contributing&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/SUPPORT.md" rel="noopener noreferrer"&gt;Support&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CHANGELOG.md" rel="noopener noreferrer"&gt;Changelog&lt;/a&gt;
&lt;/p&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Screenshots&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;&lt;/p&gt;&lt;div class="table-wrapper-paragraph"&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;table&gt;

  &lt;tbody&gt;

&lt;tr&gt;

    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/login-desktop.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Flogin-desktop.png" alt="Equip login page — two-column layout with scripture on the left and a clean sign-in form on the right"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Sign in (light)&lt;br&gt;
    &lt;/td&gt;

    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/login-desktop-dark.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Flogin-desktop-dark.png" alt="Equip login page in dark mode"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Sign in (dark)&lt;br&gt;
    &lt;/td&gt;

  &lt;/tr&gt;

  &lt;tr&gt;

    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/register-desktop.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Fregister-desktop.png" alt="Equip account creation with a Student or Teacher role chooser"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Account creation — student / teacher role picker&lt;br&gt;
    &lt;/td&gt;

    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/login-mobile.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Flogin-mobile.png" alt="Equip sign-in on a 390px mobile viewport" width="240"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Mobile (390px)&lt;br&gt;
    &lt;/td&gt;

  &lt;/tr&gt;

&lt;/tbody&gt;

&lt;/table&gt;&lt;/div&gt;&lt;p&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Live at &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;equipbible.com&lt;/a&gt;. Teacher and admin views (gradebook, course editor, analytics) are behind sign-in — create a free account to explore.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why this project?&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;Hundreds of small Bible schools, home churches, and missionary training
programs around the world still manage courses on paper, WhatsApp, or
spreadsheets. Commercial LMS platforms are expensive, overkill, or require
technical expertise that volunteer-run organizations simply don't have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Equip&lt;/strong&gt; is designed to change that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free forever&lt;/strong&gt; — MIT-licensed, no paywalls, no "premium" tiers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple to deploy&lt;/strong&gt; — one-click Vercel deploy with a free Supabase
database. No Docker, no servers to manage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built for small scale&lt;/strong&gt; — optimized for 20-100 students, not…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>postgres</category>
      <category>webdev</category>
      <category>performance</category>
      <category>supabase</category>
    </item>
    <item>
      <title>I had real backend auth. The browser just walked around it.</title>
      <dc:creator>Vadym Arnaut</dc:creator>
      <pubDate>Wed, 17 Jun 2026 18:33:50 +0000</pubDate>
      <link>https://dev.to/arvavit/i-had-real-backend-auth-the-browser-just-walked-around-it-58kj</link>
      <guid>https://dev.to/arvavit/i-had-real-backend-auth-the-browser-just-walked-around-it-58kj</guid>
      <description>&lt;p&gt;Here's the thing nobody warns you about when you put Supabase behind a "real" backend.&lt;/p&gt;

&lt;p&gt;My stack is React + FastAPI + Supabase Postgres. Every write goes through FastAPI. Every endpoint checks the user, the role, the ownership. I audited that backend HARD — rate limits, JWT validation, RLS, the whole thing. I was proud of it.&lt;/p&gt;

&lt;p&gt;And none of it mattered for the two holes I actually shipped.&lt;/p&gt;

&lt;p&gt;Because the Supabase anon key lives in the browser. It HAS to — that's how &lt;code&gt;supabase-js&lt;/code&gt; talks to your project. Which means every logged-in user is holding a key that talks to Postgres &lt;strong&gt;directly&lt;/strong&gt;. Not through my FastAPI. Around it.&lt;/p&gt;

&lt;p&gt;That anon key is a SECOND API. And I'd spent months hardening the first one while the second one sat there, wide open, the whole time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hole #1 — the answers were just... readable
&lt;/h2&gt;

&lt;p&gt;Quiz questions live in &lt;code&gt;quiz_options&lt;/code&gt;, one &lt;code&gt;is_correct&lt;/code&gt; boolean per option. My backend never sends &lt;code&gt;is_correct&lt;/code&gt; to a student before they submit. Obviously.&lt;/p&gt;

&lt;p&gt;But the browser doesn't have to ask my backend.&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="c1"&gt;// any logged-in student, straight from the console:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;quiz_options&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;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;question_id, label, is_correct&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// &amp;lt;- the answer key. all of it.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The RLS policy said "authenticated users can read &lt;code&gt;quiz_options&lt;/code&gt;." Totally true for the rows. It just &lt;em&gt;also&lt;/em&gt; handed back the column that decides the grade. The answer key. To anyone with a login and ten seconds of curiosity.&lt;/p&gt;

&lt;p&gt;Fix: column-level &lt;code&gt;REVOKE SELECT&lt;/code&gt; from the client role, and let the backend be the only thing that ever reads &lt;code&gt;is_correct&lt;/code&gt;. (PR #775.)&lt;/p&gt;
&lt;h2&gt;
  
  
  Hole #2 — they could WRITE things they shouldn't
&lt;/h2&gt;

&lt;p&gt;Same class of bug, bigger blast radius. The default Postgres grants let the client role insert/update far more than I'd realized — including a path toward forging a certificate. Nobody did it. But "nobody did it yet" is not a security model!&lt;/p&gt;

&lt;p&gt;So I stopped patching table by table and flipped the whole thing:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- kill the client's entire write surface, then grant back the ONE thing it needs&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;PRIVILEGES&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;
  &lt;span class="k"&gt;REVOKE&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;TABLES&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* your own profile fields */&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;profiles&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;After that migration the browser can write exactly one thing: your own profile. EVERYTHING else goes through FastAPI, where the real checks live. (PR #794.)&lt;/p&gt;
&lt;h2&gt;
  
  
  The lesson I wish I'd had on day one
&lt;/h2&gt;

&lt;p&gt;If your anon key ships to the browser — and with &lt;code&gt;supabase-js&lt;/code&gt;, it does — then your RLS grants ARE your API. Not "a backend detail." The actual, public, internet-facing contract.&lt;/p&gt;

&lt;p&gt;So audit them like it. Read every GRANT. For every table, ask one question: &lt;em&gt;if a logged-in user typed this &lt;code&gt;select&lt;/code&gt; (or &lt;code&gt;insert&lt;/code&gt;) in their console, what comes back, and what changes?&lt;/em&gt; Default the client to read-almost-nothing, write-nothing — and earn each grant back on purpose.&lt;/p&gt;

&lt;p&gt;Your backend auth can be flawless and still be theater, if the front door right next to it is unlocked.&lt;/p&gt;



&lt;p&gt;Equip is open source under MIT: &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;github.com/ArVaViT/equip&lt;/a&gt;. Both fixes are in &lt;code&gt;main&lt;/code&gt; — PR #775 (the answer-key leak) and PR #794 (the write-surface lockdown).&lt;/p&gt;

&lt;p&gt;What's the worst RLS grant you've ever found in your own project? I genuinely want to hear it.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArVaViT" rel="noopener noreferrer"&gt;
        ArVaViT
      &lt;/a&gt; / &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;
        equip
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Free, open-source LMS for Bible schools, ministries, and nonprofit educational programs. React + FastAPI + Supabase.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/frontend/public/favicon.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2Ffrontend%2Fpublic%2Ffavicon.svg" width="80" alt="Equip logo"&gt;&lt;/a&gt;
&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Equip&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;
  A free, open-source learning management system built for Bible schools
  church ministries, and nonprofit educational programs
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://github.com/ArVaViT/equip/blob/main/LICENSE" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/fbd05efc647372434fa773c0393f29e76f8dfd729ea1b5e069038c6e8179f339/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f417256615669542f65717569703f7374796c653d666c61742d737175617265" alt="MIT License"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/backend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/659c778682ca665110e0873307d8412572e9b5a59ecf78cbd04e38dab4804200/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f6261636b656e642d63692e796d6c3f6c6162656c3d6261636b656e64267374796c653d666c61742d737175617265" alt="Backend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/frontend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a549c27eebbf23022773ad1017442dcaf20b27c7ef080b4f3f8ac3344872eb87/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f66726f6e74656e642d63692e796d6c3f6c6162656c3d66726f6e74656e64267374796c653d666c61742d737175617265" alt="Frontend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/7e02756ed49d82974d5e10b79c40680819b90f5f2076338b4f3812b1cb096ae2/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6973737565732f417256615669542f65717569702f676f6f64253230666972737425323069737375653f7374796c653d666c61742d73717561726526636f6c6f723d373035376666266c6162656c3d676f6f642532306669727374253230697373756573" alt="Good first issues"&gt;
  &lt;/a&gt;
  &lt;a href="https://app.codecov.io/gh/ArVaViT/equip" rel="nofollow noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a2082409068ba3266a009213fdfef36845f9ba34386f31c378b6d250482598fa/68747470733a2f2f696d672e736869656c64732e696f2f636f6465636f762f632f6769746875622f417256615669542f65717569703f7374796c653d666c61742d737175617265266c6162656c3d636f766572616765" alt="Code coverage"&gt;
  &lt;/a&gt;
  &lt;a href="https://scorecard.dev/viewer/?uri=github.com/ArVaViT/equip" rel="nofollow noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/63c1ae6c3a1ca1f1b29e7730e3c6ec8aab5caa520e104beaa3dcd66af5d66dc3/68747470733a2f2f696d672e736869656c64732e696f2f6f7373662d73636f7265636172642f6769746875622e636f6d2f417256615669542f65717569703f7374796c653d666c61742d737175617265266c6162656c3d6f70656e73736625323073636f726563617264" alt="OpenSSF Scorecard"&gt;
  &lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;Live demo&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/ROADMAP.md" rel="noopener noreferrer"&gt;Roadmap&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CONTRIBUTING.md" rel="noopener noreferrer"&gt;Contributing&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/SUPPORT.md" rel="noopener noreferrer"&gt;Support&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CHANGELOG.md" rel="noopener noreferrer"&gt;Changelog&lt;/a&gt;
&lt;/p&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Screenshots&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;&lt;/p&gt;&lt;div class="table-wrapper-paragraph"&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;table&gt;

  &lt;tbody&gt;

&lt;tr&gt;

    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/login-desktop.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Flogin-desktop.png" alt="Equip login page — two-column layout with scripture on the left and a clean sign-in form on the right"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Sign in (light)&lt;br&gt;
    &lt;/td&gt;

    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/login-desktop-dark.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Flogin-desktop-dark.png" alt="Equip login page in dark mode"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Sign in (dark)&lt;br&gt;
    &lt;/td&gt;

  &lt;/tr&gt;

  &lt;tr&gt;

    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/register-desktop.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Fregister-desktop.png" alt="Equip account creation with a Student or Teacher role chooser"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Account creation — student / teacher role picker&lt;br&gt;
    &lt;/td&gt;

    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/login-mobile.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Flogin-mobile.png" alt="Equip sign-in on a 390px mobile viewport" width="240"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Mobile (390px)&lt;br&gt;
    &lt;/td&gt;

  &lt;/tr&gt;

&lt;/tbody&gt;

&lt;/table&gt;&lt;/div&gt;&lt;p&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Live at &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;equipbible.com&lt;/a&gt;. Teacher and admin views (gradebook, course editor, analytics) are behind sign-in — create a free account to explore.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why this project?&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;Hundreds of small Bible schools, home churches, and missionary training
programs around the world still manage courses on paper, WhatsApp, or
spreadsheets. Commercial LMS platforms are expensive, overkill, or require
technical expertise that volunteer-run organizations simply don't have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Equip&lt;/strong&gt; is designed to change that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free forever&lt;/strong&gt; — MIT-licensed, no paywalls, no "premium" tiers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple to deploy&lt;/strong&gt; — one-click Vercel deploy with a free Supabase
database. No Docker, no servers to manage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built for small scale&lt;/strong&gt; — optimized for 20-100 students, not…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>supabase</category>
      <category>security</category>
      <category>postgres</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I ran 4 AI agents on yesterday's PRs. Two real security bugs surfaced.</title>
      <dc:creator>Vadym Arnaut</dc:creator>
      <pubDate>Tue, 09 Jun 2026 03:01:20 +0000</pubDate>
      <link>https://dev.to/arvavit/i-ran-4-ai-agents-on-yesterdays-prs-two-real-security-bugs-surfaced-43dl</link>
      <guid>https://dev.to/arvavit/i-ran-4-ai-agents-on-yesterdays-prs-two-real-security-bugs-surfaced-43dl</guid>
      <description>&lt;p&gt;After every coding session I run a 4-agent parallel audit on the diff I just shipped. A recent session of mine was seven PRs landing a new daily-challenge feature on my open-source LMS. Two of the audit findings were real security or integrity bugs that my human review missed. This is the playbook.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four agents
&lt;/h2&gt;

&lt;p&gt;I split the audit into four narrow roles. Narrow because a generalist agent tells you everything is fine; a specialist with a clear mandate tells you what is broken.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cleanup agent.&lt;/strong&gt; Looks for leftover patterns from the work just done: dead references to removed roles, unused i18n keys, orphan test fixtures, dual-named scripts where one should have died.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security agent.&lt;/strong&gt; Auth, tokens, rotation, CSP, RLS, secrets in code, error messages that leak structure. Treats every new endpoint as hostile until proven otherwise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test integrity agent.&lt;/strong&gt; Reads each test that was added or changed and asks the harder question: would this test fail if the code under test was wrong? Hunts for tautologies, tests that pass by insertion order, assertions that match the implementation instead of the requirement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live prod verification agent.&lt;/strong&gt; Pulls actual production data, hits the new endpoints with real auth, confirms the change reached prod and behaves like the diff implies.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each agent runs in parallel with a 15-20 minute budget, not a 6-minute skim. The output is a findings list, severity-tagged, with &lt;code&gt;file:line&lt;/code&gt; references and proposed fixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The audit's haul: two real bugs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Critical: PyJWT &lt;code&gt;decode&lt;/code&gt; does not validate &lt;code&gt;iat&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The diff included an iCal subscription feature with a 365-day token TTL and a "rotate token" button. Rotation generated a new JWT with a fresh &lt;code&gt;iat&lt;/code&gt; and updated nothing else. The implicit promise was that the old token would stop working.&lt;/p&gt;

&lt;p&gt;It did not. The security agent flagged this in one line: &lt;em&gt;"&lt;code&gt;pyjwt.decode&lt;/code&gt; does not validate &lt;code&gt;iat&lt;/code&gt; by default. Old tokens with the original &lt;code&gt;iat&lt;/code&gt; remain valid for the full 365-day TTL after rotation."&lt;/em&gt;&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="c1"&gt;# Before — rotation does nothing useful
&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;algorithms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HS256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="c1"&gt;# iat is in the payload but never compared to anything
&lt;/span&gt;
&lt;span class="c1"&gt;# After — refuse any token issued before the user's rotation floor
&lt;/span&gt;&lt;span class="n"&gt;floor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;calendar_ical_min_iat&lt;/span&gt;
&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;algorithms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HS256&lt;/span&gt;&lt;span class="sh"&gt;"&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;floor&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;InvalidToken&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Schema change: one new column on &lt;code&gt;profiles&lt;/code&gt;, &lt;code&gt;calendar_ical_min_iat BIGINT NULL&lt;/code&gt;. Rotation writes the new token's &lt;code&gt;iat&lt;/code&gt; to that floor. Verification refuses any token whose &lt;code&gt;iat&lt;/code&gt; is below it. Three tests pin the contract: rotation invalidates the old token, a token signed with a different secret fails, the floor read works on first rotation.&lt;/p&gt;

&lt;p&gt;PyJWT validates &lt;code&gt;exp&lt;/code&gt; and &lt;code&gt;nbf&lt;/code&gt; by default. It does not validate &lt;code&gt;iat&lt;/code&gt;. Even passing &lt;code&gt;options={"verify_iat": True}&lt;/code&gt; only checks that &lt;code&gt;iat&lt;/code&gt; is a valid timestamp, not that it is recent enough. If your auth design assumes "rotation invalidates everything older," you have to enforce it yourself.&lt;/p&gt;
&lt;h3&gt;
  
  
  High: a test that was passing by insertion order
&lt;/h3&gt;

&lt;p&gt;The test integrity agent caught a test named &lt;code&gt;test_prefers_live_over_archive_attempt&lt;/code&gt;. The function under test ordered results by &lt;code&gt;is_archive ASC&lt;/code&gt;. The test inserted the live attempt first, then the archive attempt, then asserted the live one came back.&lt;/p&gt;

&lt;p&gt;That assertion passed for the wrong reason. SQLite returned rows in insertion order absent an effective ORDER BY. If the ORDER BY had been deleted from the production code, the test would have stayed green. The agent flagged this as "tautological under the current backend; rewrite so only the ORDER BY can make the test pass." I reversed the insertion order. The test passes for the right reason now.&lt;/p&gt;

&lt;p&gt;This is the finding that scared me most. The other one was a security bug, but at least it was a bug I would have learned from when it bit. A test that passes for the wrong reason is a bug you never learn from, because it tells you everything is fine while the implementation rots underneath.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why specialist agents beat one generalist
&lt;/h2&gt;

&lt;p&gt;The first version of this workflow used one agent with a long prompt that said "find security issues, cleanup issues, test issues, and verify against prod." The output was a list of fifteen "potential concerns" that read like a checklist someone copied from a blog post. Half were false positives, none were severity-ranked, and the actually-critical PyJWT thing was buried at item nine.&lt;/p&gt;

&lt;p&gt;Splitting the same audit into four specialists with narrow mandates and clear severity rubrics changed the output. The cleanup agent comes back with "zero items" when there is nothing, instead of inventing four items to look thorough. The security agent comes back with two findings, both real, both severity-tagged, both with file:line refs. The test integrity agent finds the tautology that nobody who wrote the test would ever see, because the human author is exactly the wrong person to ask whether the test would catch the bug.&lt;/p&gt;
&lt;h2&gt;
  
  
  What this is not
&lt;/h2&gt;

&lt;p&gt;This is not a code-review bot replacing human review. It runs after the human review and merge, on the change as it exists in &lt;code&gt;main&lt;/code&gt;. It is not a substitute for thinking about the diff while you write it. It is the second pass that catches what familiarity blinds you to, on the surface area the diff actually touches.&lt;/p&gt;

&lt;p&gt;If a human review took 30 minutes and missed the PyJWT thing, a four-agent audit that catches it is the cheapest security investment in the workflow. The cost is the agent budget. The value is the bug that would have been a year-long production leak.&lt;/p&gt;

&lt;p&gt;I will not be writing production code without the four-agent post-session audit again.&lt;/p&gt;



&lt;p&gt;Equip is open source under MIT at &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;github.com/ArVaViT/equip&lt;/a&gt;. PR #626 closes the bugs above and adds the defensive tests.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArVaViT" rel="noopener noreferrer"&gt;
        ArVaViT
      &lt;/a&gt; / &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;
        equip
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Free, open-source LMS for Bible schools, ministries, and nonprofit educational programs. React + FastAPI + Supabase.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/frontend/public/favicon.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2Ffrontend%2Fpublic%2Ffavicon.svg" width="80" alt="Equip logo"&gt;&lt;/a&gt;
&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Equip&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;
  A free, open-source learning management system built for Bible schools
  church ministries, and nonprofit educational programs
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://github.com/ArVaViT/equip/blob/main/LICENSE" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/fbd05efc647372434fa773c0393f29e76f8dfd729ea1b5e069038c6e8179f339/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f417256615669542f65717569703f7374796c653d666c61742d737175617265" alt="MIT License"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/backend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/659c778682ca665110e0873307d8412572e9b5a59ecf78cbd04e38dab4804200/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f6261636b656e642d63692e796d6c3f6c6162656c3d6261636b656e64267374796c653d666c61742d737175617265" alt="Backend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/frontend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a549c27eebbf23022773ad1017442dcaf20b27c7ef080b4f3f8ac3344872eb87/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f66726f6e74656e642d63692e796d6c3f6c6162656c3d66726f6e74656e64267374796c653d666c61742d737175617265" alt="Frontend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/7e02756ed49d82974d5e10b79c40680819b90f5f2076338b4f3812b1cb096ae2/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6973737565732f417256615669542f65717569702f676f6f64253230666972737425323069737375653f7374796c653d666c61742d73717561726526636f6c6f723d373035376666266c6162656c3d676f6f642532306669727374253230697373756573" alt="Good first issues"&gt;
  &lt;/a&gt;
  &lt;a href="https://app.codecov.io/gh/ArVaViT/equip" rel="nofollow noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a2082409068ba3266a009213fdfef36845f9ba34386f31c378b6d250482598fa/68747470733a2f2f696d672e736869656c64732e696f2f636f6465636f762f632f6769746875622f417256615669542f65717569703f7374796c653d666c61742d737175617265266c6162656c3d636f766572616765" alt="Code coverage"&gt;
  &lt;/a&gt;
  &lt;a href="https://scorecard.dev/viewer/?uri=github.com/ArVaViT/equip" rel="nofollow noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/63c1ae6c3a1ca1f1b29e7730e3c6ec8aab5caa520e104beaa3dcd66af5d66dc3/68747470733a2f2f696d672e736869656c64732e696f2f6f7373662d73636f7265636172642f6769746875622e636f6d2f417256615669542f65717569703f7374796c653d666c61742d737175617265266c6162656c3d6f70656e73736625323073636f726563617264" alt="OpenSSF Scorecard"&gt;
  &lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;Live demo&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/ROADMAP.md" rel="noopener noreferrer"&gt;Roadmap&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CONTRIBUTING.md" rel="noopener noreferrer"&gt;Contributing&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/SUPPORT.md" rel="noopener noreferrer"&gt;Support&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CHANGELOG.md" rel="noopener noreferrer"&gt;Changelog&lt;/a&gt;
&lt;/p&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Screenshots&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;&lt;/p&gt;&lt;div class="table-wrapper-paragraph"&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;table&gt;

  &lt;tbody&gt;

&lt;tr&gt;

    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/login-desktop.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Flogin-desktop.png" alt="Equip login page — two-column layout with scripture on the left and a clean sign-in form on the right"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Sign in (light)&lt;br&gt;
    &lt;/td&gt;

    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/login-desktop-dark.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Flogin-desktop-dark.png" alt="Equip login page in dark mode"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Sign in (dark)&lt;br&gt;
    &lt;/td&gt;

  &lt;/tr&gt;

  &lt;tr&gt;

    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/register-desktop.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Fregister-desktop.png" alt="Equip account creation with a Student or Teacher role chooser"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Account creation — student / teacher role picker&lt;br&gt;
    &lt;/td&gt;

    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/login-mobile.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Flogin-mobile.png" alt="Equip sign-in on a 390px mobile viewport" width="240"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Mobile (390px)&lt;br&gt;
    &lt;/td&gt;

  &lt;/tr&gt;

&lt;/tbody&gt;

&lt;/table&gt;&lt;/div&gt;&lt;p&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Live at &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;equipbible.com&lt;/a&gt;. Teacher and admin views (gradebook, course editor, analytics) are behind sign-in — create a free account to explore.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why this project?&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;Hundreds of small Bible schools, home churches, and missionary training
programs around the world still manage courses on paper, WhatsApp, or
spreadsheets. Commercial LMS platforms are expensive, overkill, or require
technical expertise that volunteer-run organizations simply don't have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Equip&lt;/strong&gt; is designed to change that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free forever&lt;/strong&gt; — MIT-licensed, no paywalls, no "premium" tiers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple to deploy&lt;/strong&gt; — one-click Vercel deploy with a free Supabase
database. No Docker, no servers to manage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built for small scale&lt;/strong&gt; — optimized for 20-100 students, not…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>security</category>
      <category>ai</category>
      <category>python</category>
      <category>testing</category>
    </item>
    <item>
      <title>There is no source language: a manifesto for symmetric multilingual content</title>
      <dc:creator>Vadym Arnaut</dc:creator>
      <pubDate>Thu, 28 May 2026 14:50:42 +0000</pubDate>
      <link>https://dev.to/arvavit/there-is-no-source-language-a-manifesto-for-symmetric-multilingual-content-6o7</link>
      <guid>https://dev.to/arvavit/there-is-no-source-language-a-manifesto-for-symmetric-multilingual-content-6o7</guid>
      <description>&lt;p&gt;Three weeks ago I shipped a bilingual LMS. The architecture modeled one language as source and the rest as overlay: a &lt;code&gt;source_locale&lt;/code&gt; column on every translatable entity, an MT pipeline reading source rows and writing overlay rows. It worked. Students used it. Certificates issued. Three published courses, fourteen real users, end-to-end.&lt;/p&gt;

&lt;p&gt;Last week it broke in a way that did not fit a bug fix.&lt;/p&gt;

&lt;p&gt;A teacher whose interface was set to English wrote a course entirely in Russian. The system saw &lt;code&gt;teacher.preferred_locale = 'en'&lt;/code&gt;, stamped &lt;code&gt;source_locale = 'en'&lt;/code&gt; on every course field, and then served Russian students a course labelled "Russian translation of an English source" — when no English source existed, ever. The fallback path showed &lt;code&gt;[translation missing]&lt;/code&gt; placeholders on a course that was, in fact, perfectly written in the language being requested.&lt;/p&gt;

&lt;p&gt;The first fix attempt was a heuristic: detect the actual character set of the content and derive &lt;code&gt;source_locale&lt;/code&gt; from that, not from the teacher's UI. Better. Still wrong, because per-entity source locale assumes the entity is monolingual — and there is no rule that says it has to be. A course with an English title and a Russian description is one entity with no single answer to "which locale is source?"&lt;/p&gt;

&lt;p&gt;The second attempt moved detection per-field. Better still. Still wrong, because per-field source locale assumes the field is monolingual — and there is no rule that says it has to be. A bilingual paragraph (a sentence in English, a Russian phrase set off in italics, a quoted scripture reference) has no single answer either.&lt;/p&gt;

&lt;p&gt;At that point the model is the bug, not the heuristic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Locale is just a column
&lt;/h2&gt;

&lt;p&gt;The new schema, shipped to &lt;code&gt;main&lt;/code&gt; last night across eight stacked PRs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;content_versions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;                 &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;entity_type&lt;/span&gt;        &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entity_type&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
                           &lt;span class="s1"&gt;'course'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'chapter_block'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'assignment'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'cohort'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                           &lt;span class="s1"&gt;'course_event'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'announcement'&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="n"&gt;entity_id&lt;/span&gt;          &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;field&lt;/span&gt;              &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;locale&lt;/span&gt;             &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;locale&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'en'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'ru'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt;               &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;origin&lt;/span&gt;             &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'human'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'mt'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="n"&gt;source_version_id&lt;/span&gt;  &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;content_versions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;superseded_by&lt;/span&gt;      &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;content_versions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;         &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;created_by&lt;/span&gt;         &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;content_versions_active_unique&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;content_versions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entity_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entity_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;superseded_by&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Every &lt;code&gt;(entity, field, locale)&lt;/code&gt; is its own row. There is no source locale and no overlay. There are rows that exist because a human wrote them, rows that exist because the MT pipeline produced them, and the lineage between them. The thing that says "this Russian title was machine-translated from that English title" is &lt;code&gt;source_version_id&lt;/code&gt;. The thing that says "an editor revised it" is &lt;code&gt;superseded_by&lt;/code&gt;. The thing that says "what language is this row" is &lt;code&gt;locale&lt;/code&gt;. No row outranks another by default.&lt;/p&gt;

&lt;p&gt;The model is symmetric. The data is the data. Adding a third language is &lt;code&gt;INSERT INTO content_versions ...&lt;/code&gt; plus appending a string to a Pydantic &lt;code&gt;Literal&lt;/code&gt;. Zero DDL. No schema deltas. No "let's refactor &lt;code&gt;source_locale&lt;/code&gt; to be nullable for the Spanish migration."&lt;/p&gt;
&lt;h2&gt;
  
  
  What this fixes that you can name
&lt;/h2&gt;

&lt;p&gt;The "Тайтл" bug class disappears entirely. There is no &lt;code&gt;source_locale&lt;/code&gt; column to be wrong, so it cannot be wrong. A teacher's UI preference no longer leaks into the content model.&lt;/p&gt;

&lt;p&gt;Mixed-language entities are first-class. A course with an English title and a Russian description is two &lt;code&gt;content_versions&lt;/code&gt; rows. Each one is correct in isolation. No "what's the source locale of this course" question to argue about, because no field on the entity claims to know.&lt;/p&gt;

&lt;p&gt;Translation history is preserved by construction. Editing a row creates a new row with &lt;code&gt;superseded_by&lt;/code&gt; pointing at the old one. The previous translation is not overwritten or archived in some side table; it is still in &lt;code&gt;content_versions&lt;/code&gt;, just with a non-null &lt;code&gt;superseded_by&lt;/code&gt;. The active row is the one with &lt;code&gt;superseded_by IS NULL&lt;/code&gt;, enforced by the partial unique index above.&lt;/p&gt;

&lt;p&gt;Cascade invalidation gets precise. When the English title is edited, the MT rows that were generated from it — and only those — get invalidated, via &lt;code&gt;source_version_id&lt;/code&gt;. The old code had to &lt;code&gt;purge_course_translations(course_id)&lt;/code&gt; and re-translate everything, because the lineage was not recorded anywhere readable. The new code knows exactly which rows depend on which.&lt;/p&gt;
&lt;h2&gt;
  
  
  What stays true from before
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://dev.to/arvavit/i-wrote-these-fields-are-translatable-in-five-different-files-then-i-stopped-gd7"&gt;registry pattern&lt;/a&gt; still holds. The fact that an entity has translatable fields is declared once, in &lt;code&gt;backend/app/services/translation/registry.py&lt;/code&gt;, and read by the orchestrator, the schemas, and the CI guard. The schema change did not move this; it sharpened it. The registry now declares "this entity has these fields in &lt;code&gt;content_versions&lt;/code&gt;" instead of "this entity has these &lt;code&gt;_ru&lt;/code&gt;/&lt;code&gt;_en&lt;/code&gt; overlay columns."&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dev.to/arvavit/dont-trust-the-llm-with-scripture-a-canonical-text-substitution-layer-for-bible-quotes-17cg"&gt;Bible substitution layer&lt;/a&gt; still holds. Canonical text — KJV, Synodal, anything where the wording is a contract with the reader — never goes through the LLM. The MT pipeline substitutes placeholders, translates the prose around them, restores canonical target-locale text. The new model did not touch this. It could not; canonical text is not translated content. It is content that happens to exist in multiple locales because the canon exists in multiple locales. The substitution layer just makes sure the canonical version is the one that ships.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dev.to/arvavit/the-3-i18n-mistakes-every-open-source-lms-makes-2lfk"&gt;three-layer split&lt;/a&gt; still holds. UI strings, user-generated content, canonical artifacts — three problems, three mechanisms, one product. The new model only touches the middle layer. The other two were already right.&lt;/p&gt;
&lt;h2&gt;
  
  
  The principle, generalized
&lt;/h2&gt;

&lt;p&gt;The "source language" idea is a residue of monolingual-first design. Pick almost any i18n library, almost any LMS, almost any CMS, and you will find the same implicit assumption: one language is canonical, the rest are derived. Defaults, fallbacks, base locales, primary languages, source-of-truth columns. They all encode the same idea — &lt;em&gt;somebody's language is real, everybody else's is a copy.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For documentation, that may be honest. The MDN reference is in English; the French version is a translation of the English version; calling English the source is correct because it is. For a product that serves bilingual or multilingual users as equally first-class — a faith community, an immigrant network, a multilingual school — the assumption is wrong, and it leaks. Every dropdown that says "EN (Original)", every &lt;code&gt;fallback_locale: 'en'&lt;/code&gt; in a config file, every test that asserts the English string and ignores the Russian one, is a cultural primacy choice masquerading as engineering simplicity.&lt;/p&gt;

&lt;p&gt;The cost of refusing the assumption is real but bounded. The backfill script is ~550 lines of dual-write reconciliation. The schema is symmetric, so there is no per-language DDL. Read paths read one table, not "canonical column union overlay table." The whole migration is in flight as a six-phase stacked PR — half shipped, the rest sequenced with &lt;code&gt;DO NOT auto-merge until previous live + 7 days, take pg_dump, confirm PITR window, schedule Tue/Wed UTC&lt;/code&gt;. Boring, careful, recoverable. The principle is sharp; the rollout is not.&lt;/p&gt;
&lt;h2&gt;
  
  
  How to verify you have the same bug
&lt;/h2&gt;

&lt;p&gt;Two checks. If your data model has a &lt;code&gt;source_locale&lt;/code&gt; (or &lt;code&gt;original_locale&lt;/code&gt;, or &lt;code&gt;default_locale&lt;/code&gt;) column on a translatable entity, you have a primary language. If your application config has a &lt;code&gt;fallback_locale&lt;/code&gt; and your read paths use it when a translation is missing, you have a primary language. Either is the residue. Both is the full version.&lt;/p&gt;

&lt;p&gt;The honest move is to model what is actually true: a course has content. Some of that content is in English. Some is in Russian. Some was written by a human. Some was generated by an MT pipeline from a specific human-written version. None of those facts make any language more real than any other.&lt;/p&gt;
&lt;h2&gt;
  
  
  Bigger than i18n
&lt;/h2&gt;

&lt;p&gt;The deeper read is that the "source plus overlay" pattern is a default reach for any time you have versions of the same thing in different shapes: translations, variants, A/B copy, accessibility re-writes, plain-language summaries, audio scripts. The same model — every variant is a row, lineage is explicit, supersession is preserved, no variant is privileged by schema — generalizes cleanly past locale.&lt;/p&gt;

&lt;p&gt;If you are building anything where multiple variants of the same content need to coexist as equals, the question is not &lt;em&gt;which one is the source.&lt;/em&gt; The question is &lt;em&gt;what is the lineage, and who or what wrote each row.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If your bilingual app has a fallback locale, you have already chosen which language is real.&lt;/p&gt;



&lt;p&gt;Equip is open source under MIT at &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;github.com/ArVaViT/equip&lt;/a&gt;. Live at &lt;a href="https://equipbible.com" rel="noopener noreferrer"&gt;equipbible.com&lt;/a&gt;. The &lt;code&gt;content_versions&lt;/code&gt; foundation migration is &lt;code&gt;supabase/migrations/20260527230000_content_versions_foundation.sql&lt;/code&gt;. The six-phase rollout spans PRs #531 through #553 — Phase 1 (dual-write) is fully merged across all eleven translatable entity types; Phases 2 through 5 (dual-read with comparator, backfill, cv-primary read behind a flag, delete legacy + drop &lt;code&gt;content_translations&lt;/code&gt; + drop the source text columns) are sequenced as an open stack.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArVaViT" rel="noopener noreferrer"&gt;
        ArVaViT
      &lt;/a&gt; / &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;
        equip
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Free, open-source LMS for Bible schools, ministries, and nonprofit educational programs. React + FastAPI + Supabase.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/frontend/public/favicon.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2Ffrontend%2Fpublic%2Ffavicon.svg" width="80" alt="Equip logo"&gt;&lt;/a&gt;
&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Equip&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;
  A free, open-source learning management system built for Bible schools
  church ministries, and nonprofit educational programs
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://github.com/ArVaViT/equip/blob/main/LICENSE" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/fbd05efc647372434fa773c0393f29e76f8dfd729ea1b5e069038c6e8179f339/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f417256615669542f65717569703f7374796c653d666c61742d737175617265" alt="MIT License"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/backend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/659c778682ca665110e0873307d8412572e9b5a59ecf78cbd04e38dab4804200/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f6261636b656e642d63692e796d6c3f6c6162656c3d6261636b656e64267374796c653d666c61742d737175617265" alt="Backend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/frontend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a549c27eebbf23022773ad1017442dcaf20b27c7ef080b4f3f8ac3344872eb87/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f66726f6e74656e642d63692e796d6c3f6c6162656c3d66726f6e74656e64267374796c653d666c61742d737175617265" alt="Frontend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/7e02756ed49d82974d5e10b79c40680819b90f5f2076338b4f3812b1cb096ae2/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6973737565732f417256615669542f65717569702f676f6f64253230666972737425323069737375653f7374796c653d666c61742d73717561726526636f6c6f723d373035376666266c6162656c3d676f6f642532306669727374253230697373756573" alt="Good first issues"&gt;
  &lt;/a&gt;
  &lt;a href="https://app.codecov.io/gh/ArVaViT/equip" rel="nofollow noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a2082409068ba3266a009213fdfef36845f9ba34386f31c378b6d250482598fa/68747470733a2f2f696d672e736869656c64732e696f2f636f6465636f762f632f6769746875622f417256615669542f65717569703f7374796c653d666c61742d737175617265266c6162656c3d636f766572616765" alt="Code coverage"&gt;
  &lt;/a&gt;
  &lt;a href="https://scorecard.dev/viewer/?uri=github.com/ArVaViT/equip" rel="nofollow noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/63c1ae6c3a1ca1f1b29e7730e3c6ec8aab5caa520e104beaa3dcd66af5d66dc3/68747470733a2f2f696d672e736869656c64732e696f2f6f7373662d73636f7265636172642f6769746875622e636f6d2f417256615669542f65717569703f7374796c653d666c61742d737175617265266c6162656c3d6f70656e73736625323073636f726563617264" alt="OpenSSF Scorecard"&gt;
  &lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;Live demo&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/ROADMAP.md" rel="noopener noreferrer"&gt;Roadmap&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CONTRIBUTING.md" rel="noopener noreferrer"&gt;Contributing&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/SUPPORT.md" rel="noopener noreferrer"&gt;Support&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CHANGELOG.md" rel="noopener noreferrer"&gt;Changelog&lt;/a&gt;
&lt;/p&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Screenshots&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;&lt;/p&gt;&lt;div class="table-wrapper-paragraph"&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;table&gt;

  &lt;tbody&gt;

&lt;tr&gt;

    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/login-desktop.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Flogin-desktop.png" alt="Equip login page — two-column layout with scripture on the left and a clean sign-in form on the right"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Sign in (light)&lt;br&gt;
    &lt;/td&gt;

    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/login-desktop-dark.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Flogin-desktop-dark.png" alt="Equip login page in dark mode"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Sign in (dark)&lt;br&gt;
    &lt;/td&gt;

  &lt;/tr&gt;

  &lt;tr&gt;

    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/register-desktop.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Fregister-desktop.png" alt="Equip account creation with a Student or Teacher role chooser"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Account creation — student / teacher role picker&lt;br&gt;
    &lt;/td&gt;

    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/login-mobile.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Flogin-mobile.png" alt="Equip sign-in on a 390px mobile viewport" width="240"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Mobile (390px)&lt;br&gt;
    &lt;/td&gt;

  &lt;/tr&gt;

&lt;/tbody&gt;

&lt;/table&gt;&lt;/div&gt;&lt;p&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Live at &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;equipbible.com&lt;/a&gt;. Teacher and admin views (gradebook, course editor, analytics) are behind sign-in — create a free account to explore.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why this project?&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;Hundreds of small Bible schools, home churches, and missionary training
programs around the world still manage courses on paper, WhatsApp, or
spreadsheets. Commercial LMS platforms are expensive, overkill, or require
technical expertise that volunteer-run organizations simply don't have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Equip&lt;/strong&gt; is designed to change that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free forever&lt;/strong&gt; — MIT-licensed, no paywalls, no "premium" tiers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple to deploy&lt;/strong&gt; — one-click Vercel deploy with a free Supabase
database. No Docker, no servers to manage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built for small scale&lt;/strong&gt; — optimized for 20-100 students, not…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>architecture</category>
      <category>i18n</category>
      <category>webdev</category>
      <category>postgres</category>
    </item>
    <item>
      <title>The auth_rls_initplan linter has a blind spot: SECURITY DEFINER bodies</title>
      <dc:creator>Vadym Arnaut</dc:creator>
      <pubDate>Wed, 27 May 2026 21:46:29 +0000</pubDate>
      <link>https://dev.to/arvavit/the-authrlsinitplan-linter-has-a-blind-spot-security-definer-bodies-3hn5</link>
      <guid>https://dev.to/arvavit/the-authrlsinitplan-linter-has-a-blind-spot-security-definer-bodies-3hn5</guid>
      <description>&lt;p&gt;If you've ever migrated a Supabase project to the wrapped &lt;code&gt;(SELECT auth.uid())&lt;/code&gt; pattern, you know the linter that flags the bare call. &lt;code&gt;auth_rls_initplan&lt;/code&gt; reads every RLS policy expression and warns whenever &lt;code&gt;auth.uid()&lt;/code&gt; is used directly instead of inside a scalar subselect. The wrap turns the call from a per-row evaluation into a single InitPlan. On a million-row table that is the difference between a 30ms read and a 30-second read.&lt;/p&gt;

&lt;p&gt;The linter is good. It is also incomplete.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't see
&lt;/h2&gt;

&lt;p&gt;The linter introspects the policy expression itself. It does not walk into the body of any function the policy calls. So this passes the linter cleanly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_owner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target_user&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt;
    &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt;
    &lt;span class="k"&gt;SECURITY&lt;/span&gt; &lt;span class="k"&gt;DEFINER&lt;/span&gt;
    &lt;span class="k"&gt;STABLE&lt;/span&gt;
&lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;target_user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;rows_owner&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;things&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_owner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Green linter. Slow query. The &lt;code&gt;(SELECT ...)&lt;/code&gt; at the policy level caches the return value of &lt;code&gt;is_owner(user_id)&lt;/code&gt;, which itself depends on the row's &lt;code&gt;user_id&lt;/code&gt; and runs every row. The auth call inside the function is back to per-row evaluation, and the InitPlan optimization the wrap was supposed to enable never kicks in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is the same wrap, one level deeper
&lt;/h2&gt;

&lt;p&gt;Move the scalar subselect inside the function body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_owner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target_user&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt;
    &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt;
    &lt;span class="k"&gt;SECURITY&lt;/span&gt; &lt;span class="k"&gt;DEFINER&lt;/span&gt;
    &lt;span class="k"&gt;STABLE&lt;/span&gt;
&lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;target_user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&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 function still runs per row, but the auth lookup inside it is the single InitPlan call the optimization was designed for.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to find them
&lt;/h2&gt;

&lt;p&gt;Grep the schema for &lt;code&gt;auth.uid()&lt;/code&gt; inside function bodies, not just policies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pg_dump &lt;span class="nt"&gt;--schema-only&lt;/span&gt; your_db &lt;span class="se"&gt;\&lt;/span&gt;
    | rg &lt;span class="nt"&gt;-B&lt;/span&gt; 5 &lt;span class="s1"&gt;'auth\.(uid|jwt)\(\)'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    | rg &lt;span class="nt"&gt;-A&lt;/span&gt; 2 &lt;span class="s1"&gt;'SECURITY DEFINER'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; the slow query and look for a subplan that fires once per row instead of once per query. If it scales with rows scanned, you have one of these.&lt;/p&gt;

&lt;p&gt;The linter is a good first pass. This is the second pass to run before you call your migration done.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>webdev</category>
      <category>database</category>
    </item>
    <item>
      <title>Equip: an open-source LMS for Bible schools — bilingual, scripture-aware, 4 weeks of real users</title>
      <dc:creator>Vadym Arnaut</dc:creator>
      <pubDate>Thu, 21 May 2026 00:30:31 +0000</pubDate>
      <link>https://dev.to/arvavit/equip-an-open-source-lms-for-bible-schools-bilingual-scripture-aware-4-weeks-of-real-users-bmo</link>
      <guid>https://dev.to/arvavit/equip-an-open-source-lms-for-bible-schools-bilingual-scripture-aware-4-weeks-of-real-users-bmo</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; Equip is a free, open-source LMS built for Bible schools and small ministries. MIT-licensed. Bilingual Russian↔English out of the box — the same course renders in either language depending on who's reading. Self-hostable end-to-end on free tiers. 4 weeks in production: 14 real users, 3 published courses, 3 issued certificates. This post is what's in it, what's wired in, and how it's built.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flzcsmuw8p04jgortir1o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flzcsmuw8p04jgortir1o.png" alt="Equip sign-in page — two-zone layout with a scripture quote on the left and a clean email + Google sign-in form on the right" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this exists
&lt;/h2&gt;

&lt;p&gt;Small Bible schools, home-church training programs, and missionary-prep tracks around the world run their coursework on paper, WhatsApp groups, or shared Google Sheets. The commercial LMSes either price out small ministries (Canvas, Blackboard), require an IT department to run (Moodle, Open edX), or fight the use case at every step. Most are built for K–12 or universities, not 30 students reading through Romans together.&lt;/p&gt;

&lt;p&gt;I wanted an LMS that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Costs &lt;strong&gt;zero&lt;/strong&gt; to run for a 20–100 student school&lt;/li&gt;
&lt;li&gt;Has &lt;strong&gt;no vendor lock-in&lt;/strong&gt; (your data, your hosting)&lt;/li&gt;
&lt;li&gt;Handles &lt;strong&gt;scripture correctly&lt;/strong&gt; — more on this below, it turned out to be the most interesting constraint&lt;/li&gt;
&lt;li&gt;Works in &lt;strong&gt;Russian and English&lt;/strong&gt; without manual per-string translation&lt;/li&gt;
&lt;li&gt;Is small enough that one nontechnical ministry admin can run it end-to-end&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;Equip&lt;/a&gt;. Live at &lt;a href="https://equipbible.com" rel="noopener noreferrer"&gt;equipbible.com&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bilingual content, end-to-end
&lt;/h2&gt;

&lt;p&gt;This is the feature that does the most work, so it goes first.&lt;/p&gt;

&lt;p&gt;Russian and English UI ship together, but the more interesting decision is &lt;strong&gt;content&lt;/strong&gt;. A teacher writes a course in their own language. When a student of the other language enrolls, every text field on every entity (course name, module title, chapter content, quiz question, quiz option, quiz explanation, assignment prompt) is auto-translated via Gemini Flash Lite and cached per &lt;code&gt;(entity_type, entity_id, field, locale)&lt;/code&gt;. The translation runs once per field and reads from cache thereafter. Cost per course is measured in cents.&lt;/p&gt;

&lt;p&gt;The author always sees their own source-language content. The student always sees their target-language content. No "switch language to see English" toggle. No leaking source-locale strings. No &lt;code&gt;[translation missing]&lt;/code&gt; placeholders.&lt;/p&gt;

&lt;p&gt;Same &lt;code&gt;/register&lt;/code&gt; page, two locales, rendered live by Equip:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkyl7c8ngsfyzh3qgu8g2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkyl7c8ngsfyzh3qgu8g2.png" alt="Side-by-side English and Russian register pages: same URL, same component tree, locale-driven content swap including the scripture passage in the left rail" width="800" height="290"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Cyrillic side isn't a different file or a different route. It's the same React tree with &lt;code&gt;useTranslation()&lt;/code&gt;, plus the scripture passage in the left rail swapped from KJV (1769) to Synodal (1876) by a server-side substitution layer — see next section.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scripture-preservation constraint
&lt;/h2&gt;

&lt;p&gt;Most translation APIs (Gemini, GPT-4, DeepL) will happily paraphrase a Bible verse if you feed it into them as part of a longer paragraph. For a course where the exact wording of John 3:16 matters, that's a non-starter. "For God so loved the world" and "Because God loved the world this much" are not interchangeable in a Bible-study context, no matter how close the meaning.&lt;/p&gt;

&lt;p&gt;Equip solves this outside the LLM:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pre-process incoming HTML, detect &lt;code&gt;&amp;lt;blockquote&amp;gt;&lt;/code&gt; + scripture-reference pairs.&lt;/li&gt;
&lt;li&gt;Swap the verse text for an opaque ASCII placeholder (&lt;code&gt;VERSE_&amp;lt;hex&amp;gt;&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Send the prose-with-placeholders to Gemini Flash Lite with a system prompt that lists "preserve placeholders verbatim" as a hard rule.&lt;/li&gt;
&lt;li&gt;After the model returns, restore each placeholder with the canonical text from bundled translations: &lt;strong&gt;KJV 1769&lt;/strong&gt; for English, &lt;strong&gt;Synodal 1876&lt;/strong&gt; for Russian.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The model never sees the verse text. The translation never paraphrases scripture. Implementation lives in &lt;code&gt;app/services/bible/substitution.py&lt;/code&gt; and is about 200 lines.&lt;/p&gt;

&lt;p&gt;There's a fallback in the system prompt for paraphrased quotes that the substitution layer can't confidently match (similarity &amp;lt; 0.80 to canonical) — "leave the original verse text untouched." That covers content where the substitution layer can't reliably do its job.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's wired in
&lt;/h2&gt;

&lt;p&gt;Equip is small in scope but talks to a few external services where it makes sense to. Each one is a deliberate choice, not a default:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;What it does in Equip&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;YouVersion Platform API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Verse-of-the-Day card on the home dashboard. Reads the YV verse-of-the-day endpoint, caches the result per (locale, date). YV registration is on the &lt;strong&gt;non-commercial&lt;/strong&gt; track, which is what shipped with our values from day one.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Gemini Flash Lite&lt;/strong&gt; (Google AI)&lt;/td&gt;
&lt;td&gt;Bilingual content translation pipeline (above). Flash Lite is overkill on quality and underkill on cost for surface translation; this is the sweet spot.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;KJV 1769 + Synodal 1876&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Bundled in the backend as JSON, served as the canonical scripture for the substitution layer. Public-domain, no API call.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Supabase Auth&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Google OAuth + email/password. JWT verification server-side. The &lt;code&gt;auth.uid()&lt;/code&gt; value flows through every RLS policy.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Datadog&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Browser-RUM on the SPA, log intake from the FastAPI backend, four synthetics on the public surfaces. Free tier.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Resend&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Transactional email (verify, password reset, certificate-ready notifications) from a verified &lt;code&gt;equipbible.com&lt;/code&gt; domain. Free tier.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Sentry-class error attribution&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Not Sentry itself — Datadog's RUM + log correlation does the same job for our scale, single vendor.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern across all of these: pick a single best-fit service per layer, stay on free tiers as long as possible, never hide the integration behind a paywall.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does (the standard LMS surface)
&lt;/h2&gt;

&lt;p&gt;Courses → modules → chapters, rich content via TipTap (callouts, audio, embedded YouTube, images), quizzes (multiple choice, true/false, short-answer, essay), assignments with a teacher grading queue, certificates with teacher approval, gradebook, cohorts, calendar, announcements.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture, briefly
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Stack&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Frontend&lt;/td&gt;
&lt;td&gt;React 18, TypeScript, Vite, Tailwind, shadcn/ui, TipTap, motion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend&lt;/td&gt;
&lt;td&gt;FastAPI, Python 3.12, SQLAlchemy 2, Pydantic 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;PostgreSQL on Supabase, RLS on every public table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;Supabase Storage (avatars, course materials)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy&lt;/td&gt;
&lt;td&gt;Vercel — static SPA at equipbible.com, Python serverless at api.equipbible.com&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI/CD&lt;/td&gt;
&lt;td&gt;GitHub Actions: lint, typecheck, 700+ backend tests, 220+ frontend tests, npm audit, pip-audit, Postgres schema smoke, CodeQL, OpenSSF Scorecard, Dependabot auto-merge for patch/minor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monitoring&lt;/td&gt;
&lt;td&gt;Datadog (RUM, logs, synthetics)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Total infra cost at current usage: &lt;strong&gt;$0&lt;/strong&gt;. Vercel, Supabase, Datadog, and Resend free tiers carry the whole thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mobile, too
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3uuj1fs6p07jd1sko9d7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3uuj1fs6p07jd1sko9d7.png" alt="Equip sign-in on a 390px mobile viewport — single-column layout, same brand surface" width="390" height="844"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;No native app; responsive web from 360px wide. A native app is months of work that buys little at this scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  State of production, 4 weeks in
&lt;/h2&gt;

&lt;p&gt;Real numbers from the database as of this morning, not aspirations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;14 users&lt;/strong&gt; (11 students, 2 teachers, 1 admin)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3 published courses&lt;/strong&gt;, 1 in draft&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;10 modules, 28 chapters&lt;/strong&gt; of actual content&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;21 enrollments&lt;/strong&gt; across the 3 courses&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;6 chapters completed&lt;/strong&gt; by active students&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3 certificates issued&lt;/strong&gt; (teacher-approved, full course completion)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;9 quiz attempts&lt;/strong&gt; submitted and graded&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each user signed up with a real email, enrolled in a real course, and in three cases finished one and received a teacher-signed certificate. End-to-end flow runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's intentionally NOT there
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No "Discover" feed or social layer.&lt;/strong&gt; Students enroll because a teacher invited them, not because an algorithm recommended a course.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No live video conferencing.&lt;/strong&gt; Use Zoom or Jitsi separately. Equip stores course-event entries with a meeting-URL field. That's the integration surface.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No mobile app.&lt;/strong&gt; Responsive web works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No monetization features.&lt;/strong&gt; No paywalls, no premium tier, no "powered by" splash. MIT-licensed. Use it, fork it, deploy it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No "AI assistant" widget.&lt;/strong&gt; AI is in the translation pipeline as infrastructure, not in the UI as a feature. Bible-study students aren't asking for a chatbot.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to actually use it
&lt;/h2&gt;

&lt;p&gt;Two paths, depending on what you want:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hosted at &lt;a href="https://equipbible.com" rel="noopener noreferrer"&gt;equipbible.com&lt;/a&gt;.&lt;/strong&gt; Free. Sign up, you're in. Anyone can enroll in any published course. Teaching on the hosted instance is approval-based — the self-host path below has no such gate. No credit card.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Self-host your own instance.&lt;/strong&gt; Clone &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;the repo&lt;/a&gt;, point at your own Supabase project, deploy to Vercel. Bible schools that want their own URL, full data sovereignty, and isolation from other tenants get this. Total cost still $0 on the free tiers.&lt;/p&gt;

&lt;p&gt;If you're a Bible school or ministry running coursework on Google Sheets and WhatsApp groups, this is for you. Either path works. Pick the one that matches how much technical setup you want to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Near-term on the published roadmap: teacher onboarding flow (the first-login &lt;code&gt;/teacher&lt;/code&gt; page is too quiet right now), a completion-celebration moment (students don't know clearly when they finish a course), and a 15-minute self-host walkthrough for schools without a dev on staff.&lt;/p&gt;

&lt;p&gt;Longer-term: more language pairs (Spanish + Portuguese for missionary contexts) via &lt;a href="https://scripture.api.bible/" rel="noopener noreferrer"&gt;api.bible&lt;/a&gt;, in-platform certificate verification via signed JSON-LD, and a contribution path for translators to refine AI-translated content where wording matters most.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I want to hear back
&lt;/h2&gt;

&lt;p&gt;Especially from three groups:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Anyone running a small Bible school or ministry training program:&lt;/strong&gt; what's blocking you from moving off your current setup? Particularly curious about Russian-speaking schools in CIS countries where the Synodal canonical-text handling matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anyone who has built or contributed to a niche-domain LMS:&lt;/strong&gt; what's the one constraint you wish you'd designed for from day one? I'd rather steal your lessons than learn them again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anyone considering forking Equip for a different niche&lt;/strong&gt; (Sunday-school curriculum, a different religious tradition, secular adult-ed): tell me what'd need to change. Easier to plan the abstractions now than retrofit them later.&lt;/li&gt;
&lt;/ul&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArVaViT" rel="noopener noreferrer"&gt;
        ArVaViT
      &lt;/a&gt; / &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;
        equip
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Free, open-source LMS for Bible schools, ministries, and nonprofit educational programs. React + FastAPI + Supabase.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/frontend/public/favicon.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2Ffrontend%2Fpublic%2Ffavicon.svg" width="80" alt="Equip logo"&gt;&lt;/a&gt;
&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Equip&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;
  A free, open-source learning management system built for Bible schools
  church ministries, and nonprofit educational programs
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://github.com/ArVaViT/equip/blob/main/LICENSE" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/fbd05efc647372434fa773c0393f29e76f8dfd729ea1b5e069038c6e8179f339/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f417256615669542f65717569703f7374796c653d666c61742d737175617265" alt="MIT License"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/backend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/659c778682ca665110e0873307d8412572e9b5a59ecf78cbd04e38dab4804200/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f6261636b656e642d63692e796d6c3f6c6162656c3d6261636b656e64267374796c653d666c61742d737175617265" alt="Backend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/frontend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a549c27eebbf23022773ad1017442dcaf20b27c7ef080b4f3f8ac3344872eb87/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f66726f6e74656e642d63692e796d6c3f6c6162656c3d66726f6e74656e64267374796c653d666c61742d737175617265" alt="Frontend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/7e02756ed49d82974d5e10b79c40680819b90f5f2076338b4f3812b1cb096ae2/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6973737565732f417256615669542f65717569702f676f6f64253230666972737425323069737375653f7374796c653d666c61742d73717561726526636f6c6f723d373035376666266c6162656c3d676f6f642532306669727374253230697373756573" alt="Good first issues"&gt;
  &lt;/a&gt;
  &lt;a href="https://app.codecov.io/gh/ArVaViT/equip" rel="nofollow noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a2082409068ba3266a009213fdfef36845f9ba34386f31c378b6d250482598fa/68747470733a2f2f696d672e736869656c64732e696f2f636f6465636f762f632f6769746875622f417256615669542f65717569703f7374796c653d666c61742d737175617265266c6162656c3d636f766572616765" alt="Code coverage"&gt;
  &lt;/a&gt;
  &lt;a href="https://scorecard.dev/viewer/?uri=github.com/ArVaViT/equip" rel="nofollow noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/63c1ae6c3a1ca1f1b29e7730e3c6ec8aab5caa520e104beaa3dcd66af5d66dc3/68747470733a2f2f696d672e736869656c64732e696f2f6f7373662d73636f7265636172642f6769746875622e636f6d2f417256615669542f65717569703f7374796c653d666c61742d737175617265266c6162656c3d6f70656e73736625323073636f726563617264" alt="OpenSSF Scorecard"&gt;
  &lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;Live demo&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/ROADMAP.md" rel="noopener noreferrer"&gt;Roadmap&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CONTRIBUTING.md" rel="noopener noreferrer"&gt;Contributing&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/SUPPORT.md" rel="noopener noreferrer"&gt;Support&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CHANGELOG.md" rel="noopener noreferrer"&gt;Changelog&lt;/a&gt;
&lt;/p&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Screenshots&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;&lt;/p&gt;&lt;div class="table-wrapper-paragraph"&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;table&gt;

  &lt;tbody&gt;

&lt;tr&gt;

    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/login-desktop.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Flogin-desktop.png" alt="Equip login page — two-column layout with scripture on the left and a clean sign-in form on the right"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Sign in (light)&lt;br&gt;
    &lt;/td&gt;

    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/login-desktop-dark.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Flogin-desktop-dark.png" alt="Equip login page in dark mode"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Sign in (dark)&lt;br&gt;
    &lt;/td&gt;

  &lt;/tr&gt;

  &lt;tr&gt;

    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/register-desktop.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Fregister-desktop.png" alt="Equip account creation with a Student or Teacher role chooser"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Account creation — student / teacher role picker&lt;br&gt;
    &lt;/td&gt;

    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/login-mobile.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Flogin-mobile.png" alt="Equip sign-in on a 390px mobile viewport" width="240"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Mobile (390px)&lt;br&gt;
    &lt;/td&gt;

  &lt;/tr&gt;

&lt;/tbody&gt;

&lt;/table&gt;&lt;/div&gt;&lt;p&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Live at &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;equipbible.com&lt;/a&gt;. Teacher and admin views (gradebook, course editor, analytics) are behind sign-in — create a free account to explore.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why this project?&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;Hundreds of small Bible schools, home churches, and missionary training
programs around the world still manage courses on paper, WhatsApp, or
spreadsheets. Commercial LMS platforms are expensive, overkill, or require
technical expertise that volunteer-run organizations simply don't have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Equip&lt;/strong&gt; is designed to change that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free forever&lt;/strong&gt; — MIT-licensed, no paywalls, no "premium" tiers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple to deploy&lt;/strong&gt; — one-click Vercel deploy with a free Supabase
database. No Docker, no servers to manage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built for small scale&lt;/strong&gt; — optimized for 20-100 students, not…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>opensource</category>
      <category>showdev</category>
      <category>webdev</category>
      <category>supabase</category>
    </item>
    <item>
      <title>76 RLS policies rewritten in one migration: the auth.uid() init-plan trap in Supabase</title>
      <dc:creator>Vadym Arnaut</dc:creator>
      <pubDate>Wed, 20 May 2026 23:48:33 +0000</pubDate>
      <link>https://dev.to/arvavit/76-rls-policies-rewritten-in-one-migration-the-authuid-init-plan-trap-in-supabase-4hg</link>
      <guid>https://dev.to/arvavit/76-rls-policies-rewritten-in-one-migration-the-authuid-init-plan-trap-in-supabase-4hg</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; If your Supabase RLS policies call &lt;code&gt;auth.uid()&lt;/code&gt; directly inside &lt;code&gt;USING(...)&lt;/code&gt; or &lt;code&gt;WITH CHECK(...)&lt;/code&gt;, Postgres re-evaluates the function once per row. Wrap it as &lt;code&gt;(SELECT auth.uid())&lt;/code&gt; and the planner hoists the call to a single init plan that runs once per query. Same logical query, different plan, different cost at scale. We had 76 policies doing this wrong on Equip. Supabase's own &lt;code&gt;auth_rls_initplan&lt;/code&gt; advisor lint found every one of them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;Equip&lt;/a&gt; is an open-source LMS we run on &lt;a href="https://equipbible.com" rel="noopener noreferrer"&gt;equipbible.com&lt;/a&gt; — FastAPI backend, React frontend, Supabase Postgres with RLS on every public table. Migrations live in the repo as plain &lt;code&gt;.sql&lt;/code&gt; files. Standard setup, nothing exotic.&lt;/p&gt;

&lt;p&gt;A while back we ran Supabase's database advisor as part of a pre-deploy sweep and one warning stopped us:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;auth_rls_initplan: Detects RLS policies that re-evaluate
auth functions for each row.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;It listed 76 of our policies. Same warning every time, same root cause.&lt;/p&gt;
&lt;h2&gt;
  
  
  What &lt;code&gt;auth.uid()&lt;/code&gt; actually does per row
&lt;/h2&gt;

&lt;p&gt;A typical Equip policy at the time:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- BEFORE: auth.uid() called for every row scanned&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"assignments_update_teacher"&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assignments&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
&lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;profiles&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'teacher'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'admin'&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;That &lt;code&gt;auth.uid()&lt;/code&gt; looks like a constant. It isn't. It's a Postgres function marked &lt;code&gt;STABLE&lt;/code&gt;, which means within one query it returns the same value, but the planner has to be told it can hoist the call out of the per-row loop. By default it doesn't. Every row scanned during the policy check fires the function again, allocates the JWT context, reads the claim, returns it.&lt;/p&gt;

&lt;p&gt;For a 50-row dev table you'd never notice. For a &lt;code&gt;chapter_progress&lt;/code&gt; table with one row per (student × chapter), or a &lt;code&gt;quiz_answers&lt;/code&gt; table that grows every time a student submits anything, it adds up fast.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix that takes ten characters
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- AFTER: auth.uid() runs once, planner caches the result&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"assignments_update_teacher"&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assignments&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
&lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;profiles&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
      &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'teacher'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'admin'&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;Wrapping the call in a subquery isn't stylistic. The planner treats &lt;code&gt;(SELECT auth.uid())&lt;/code&gt; as a subquery returning one row of one value, and pulls it out into an &lt;code&gt;InitPlan&lt;/code&gt; that runs exactly once at the start of the query. Every row check then sees a literal, not a function call.&lt;/p&gt;

&lt;p&gt;You can confirm this on your own database. &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; on the bare version shows a function call inside the filter. On the wrapped version it shows &lt;code&gt;InitPlan 1&lt;/code&gt; at the top and the filter references &lt;code&gt;$0&lt;/code&gt;. Same query, different plan.&lt;/p&gt;

&lt;p&gt;Supabase covers this under &lt;a href="https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select" rel="noopener noreferrer"&gt;RLS performance recommendations&lt;/a&gt;, but it's easy to miss when you're writing policies by hand at the same pace you're shipping features.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why we missed it for months
&lt;/h2&gt;

&lt;p&gt;Honest moment: we shipped most of Equip's policies with the raw &lt;code&gt;auth.uid()&lt;/code&gt; pattern. They worked. Every test passed. Production users (students, teachers, admins) couldn't tell the difference. We were nowhere near the row count where the per-row overhead would have shown up as user-visible latency.&lt;/p&gt;

&lt;p&gt;That's the actual trap. The warning sign isn't "your app is slow." By the time it's slow you've already shipped a pile more policies in the same shape, because everything you wrote between then and now had no reason to fail. The trap is that this pattern works fine in dev and on a small prod, then quietly compounds when one table grows.&lt;/p&gt;

&lt;p&gt;Two patterns reliably hit the cliff first in our experience:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Per-user write tables&lt;/strong&gt; like &lt;code&gt;chapter_progress&lt;/code&gt; or &lt;code&gt;quiz_attempts&lt;/code&gt;. One row per (student × content), grows linearly with engagement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Many-to-many junctions&lt;/strong&gt; like enrollments. Scanned during nearly every authenticated read.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Anything else (small lookup tables, course catalogs, taxonomy) stays fast for a long time even with the bad pattern. So the early "we don't see it yet" reading is correct, but it's not informative. The pattern is structurally wrong, you just haven't hit the row count that exposes it.&lt;/p&gt;
&lt;h2&gt;
  
  
  How we caught it: the database advisor
&lt;/h2&gt;

&lt;p&gt;The Supabase project dashboard has an &lt;strong&gt;Advisors&lt;/strong&gt; tab. Open it on any Postgres project and one of the performance lints is &lt;code&gt;auth_rls_initplan&lt;/code&gt;. It scans &lt;code&gt;pg_policies&lt;/code&gt;, parses the &lt;code&gt;qual&lt;/code&gt; and &lt;code&gt;with_check&lt;/code&gt; expressions, and flags any direct &lt;code&gt;auth.&amp;lt;function&amp;gt;()&lt;/code&gt; call that isn't wrapped.&lt;/p&gt;

&lt;p&gt;Two ways to run it:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Supabase CLI (v2.81.3+)&lt;/span&gt;
supabase db advisors

&lt;span class="c"&gt;# Or via the dashboard:&lt;/span&gt;
&lt;span class="c"&gt;# https://supabase.com/dashboard/project/&amp;lt;ref&amp;gt;/advisors/performance&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;If you've wired the &lt;a href="https://supabase.com/docs/guides/getting-started/mcp" rel="noopener noreferrer"&gt;Supabase MCP server&lt;/a&gt; into your dev environment, &lt;code&gt;get_advisors&lt;/code&gt; returns the same list as a tool call you can run from an agent. Either way the output is a flat list of policy names. The migration itself is mechanical:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- For each flagged policy: DROP + recreate with the wrapped call&lt;/span&gt;
&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"&amp;lt;name&amp;gt;"&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;table&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"&amp;lt;name&amp;gt;"&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;table&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;original&lt;/span&gt; &lt;span class="n"&gt;expression&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Our &lt;code&gt;20260421015755_rls_perf_cleanup_016_policies.sql&lt;/code&gt; did this 76 times in a single migration. Mostly find-replace with care to keep the original semantics intact.&lt;/p&gt;
&lt;h2&gt;
  
  
  The PL/pgSQL footgun nobody mentions
&lt;/h2&gt;

&lt;p&gt;While you're already in there, one more thing: if you've abstracted any of this into a helper function (e.g. &lt;code&gt;is_admin()&lt;/code&gt; to keep policies DRY), check the language declaration.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- BAD: planner can't inline a plpgsql function, every call is opaque&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_admin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt;
&lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt; &lt;span class="k"&gt;STABLE&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;profiles&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'admin'&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- GOOD: SQL functions get inlined into the policy at plan time&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_admin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt;
&lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt; &lt;span class="k"&gt;STABLE&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;profiles&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'admin'&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;LANGUAGE sql&lt;/code&gt; + &lt;code&gt;STABLE&lt;/code&gt; + a single SELECT lets Postgres inline the function body into the policy at plan time. &lt;code&gt;EXPLAIN&lt;/code&gt; looks identical to inline EXISTS. Switch to &lt;code&gt;LANGUAGE plpgsql&lt;/code&gt; and the planner treats it as an opaque call. Can't push predicates in, can't reorder joins, every row hits the function.&lt;/p&gt;

&lt;p&gt;We don't use helpers in Equip yet, but multiple people in the Supabase community have hit this when extracting &lt;code&gt;is_member_of(school_id)&lt;/code&gt; for multi-tenant setups. The helper looks right, the policy looks DRY, the EXPLAIN looks wrong.&lt;/p&gt;
&lt;h2&gt;
  
  
  What to do today, in order
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open your project's &lt;strong&gt;Advisors → Performance&lt;/strong&gt; tab. Look for &lt;code&gt;auth_rls_initplan&lt;/code&gt;. If the list is non-empty, you have the trap.&lt;/li&gt;
&lt;li&gt;Write a single migration that DROPs and recreates the flagged policies with &lt;code&gt;(SELECT auth.uid())&lt;/code&gt; everywhere &lt;code&gt;auth.uid()&lt;/code&gt; appears bare. Don't try to refactor the policies' logic at the same time. Pure mechanical change.&lt;/li&gt;
&lt;li&gt;While you're in the advisor, glance at &lt;code&gt;multiple_permissive_policies&lt;/code&gt; and &lt;code&gt;policy_exists_rls_disabled&lt;/code&gt;. Both compound the same per-row cost. Multiple permissive policies on the same role + action each run separately, so two bad policies double the trap.&lt;/li&gt;
&lt;li&gt;If you have helper functions in policies, verify each is &lt;code&gt;LANGUAGE sql STABLE&lt;/code&gt; with a single SELECT body. Convert any &lt;code&gt;plpgsql&lt;/code&gt; ones if you can keep the logic in pure SQL.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The advisor keeps flagging this on every new policy you write, so once you wire it into pre-merge checks (CI step, pre-deploy hook, or a habit on PR review) the trap doesn't come back.&lt;/p&gt;



&lt;p&gt;What I want to hear back, especially from people running Supabase in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Has this lint caught anything for you &lt;em&gt;besides&lt;/em&gt; &lt;code&gt;auth.uid()&lt;/code&gt;? &lt;code&gt;auth.jwt() -&amp;gt;&amp;gt; 'role'&lt;/code&gt; should hit the same code path but I haven't traced it directly.&lt;/li&gt;
&lt;li&gt;If you've written &lt;code&gt;is_member_of(...)&lt;/code&gt; helpers for multi-tenant RLS, did you keep them in SQL or move to plpgsql? Curious about the tradeoff at scale.&lt;/li&gt;
&lt;li&gt;For anyone who left these unwrapped on purpose — what does that constraint look like? I can imagine a few cases where you'd want the per-row eval, but I can't think of any in policy code specifically.&lt;/li&gt;
&lt;/ul&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArVaViT" rel="noopener noreferrer"&gt;
        ArVaViT
      &lt;/a&gt; / &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;
        equip
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Free, open-source LMS for Bible schools, ministries, and nonprofit educational programs. React + FastAPI + Supabase.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/frontend/public/favicon.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2Ffrontend%2Fpublic%2Ffavicon.svg" width="80" alt="Equip logo"&gt;&lt;/a&gt;
&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Equip&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;
  A free, open-source learning management system built for Bible schools
  church ministries, and nonprofit educational programs
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://github.com/ArVaViT/equip/blob/main/LICENSE" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/fbd05efc647372434fa773c0393f29e76f8dfd729ea1b5e069038c6e8179f339/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f417256615669542f65717569703f7374796c653d666c61742d737175617265" alt="MIT License"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/backend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/659c778682ca665110e0873307d8412572e9b5a59ecf78cbd04e38dab4804200/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f6261636b656e642d63692e796d6c3f6c6162656c3d6261636b656e64267374796c653d666c61742d737175617265" alt="Backend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/frontend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a549c27eebbf23022773ad1017442dcaf20b27c7ef080b4f3f8ac3344872eb87/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f66726f6e74656e642d63692e796d6c3f6c6162656c3d66726f6e74656e64267374796c653d666c61742d737175617265" alt="Frontend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/7e02756ed49d82974d5e10b79c40680819b90f5f2076338b4f3812b1cb096ae2/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6973737565732f417256615669542f65717569702f676f6f64253230666972737425323069737375653f7374796c653d666c61742d73717561726526636f6c6f723d373035376666266c6162656c3d676f6f642532306669727374253230697373756573" alt="Good first issues"&gt;
  &lt;/a&gt;
  &lt;a href="https://app.codecov.io/gh/ArVaViT/equip" rel="nofollow noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a2082409068ba3266a009213fdfef36845f9ba34386f31c378b6d250482598fa/68747470733a2f2f696d672e736869656c64732e696f2f636f6465636f762f632f6769746875622f417256615669542f65717569703f7374796c653d666c61742d737175617265266c6162656c3d636f766572616765" alt="Code coverage"&gt;
  &lt;/a&gt;
  &lt;a href="https://scorecard.dev/viewer/?uri=github.com/ArVaViT/equip" rel="nofollow noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/63c1ae6c3a1ca1f1b29e7730e3c6ec8aab5caa520e104beaa3dcd66af5d66dc3/68747470733a2f2f696d672e736869656c64732e696f2f6f7373662d73636f7265636172642f6769746875622e636f6d2f417256615669542f65717569703f7374796c653d666c61742d737175617265266c6162656c3d6f70656e73736625323073636f726563617264" alt="OpenSSF Scorecard"&gt;
  &lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;Live demo&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/ROADMAP.md" rel="noopener noreferrer"&gt;Roadmap&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CONTRIBUTING.md" rel="noopener noreferrer"&gt;Contributing&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/SUPPORT.md" rel="noopener noreferrer"&gt;Support&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CHANGELOG.md" rel="noopener noreferrer"&gt;Changelog&lt;/a&gt;
&lt;/p&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Screenshots&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;&lt;/p&gt;&lt;div class="table-wrapper-paragraph"&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;table&gt;

  &lt;tbody&gt;

&lt;tr&gt;

    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/login-desktop.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Flogin-desktop.png" alt="Equip login page — two-column layout with scripture on the left and a clean sign-in form on the right"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Sign in (light)&lt;br&gt;
    &lt;/td&gt;

    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/login-desktop-dark.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Flogin-desktop-dark.png" alt="Equip login page in dark mode"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Sign in (dark)&lt;br&gt;
    &lt;/td&gt;

  &lt;/tr&gt;

  &lt;tr&gt;

    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/register-desktop.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Fregister-desktop.png" alt="Equip account creation with a Student or Teacher role chooser"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Account creation — student / teacher role picker&lt;br&gt;
    &lt;/td&gt;

    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/login-mobile.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Flogin-mobile.png" alt="Equip sign-in on a 390px mobile viewport" width="240"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Mobile (390px)&lt;br&gt;
    &lt;/td&gt;

  &lt;/tr&gt;

&lt;/tbody&gt;

&lt;/table&gt;&lt;/div&gt;&lt;p&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Live at &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;equipbible.com&lt;/a&gt;. Teacher and admin views (gradebook, course editor, analytics) are behind sign-in — create a free account to explore.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why this project?&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;Hundreds of small Bible schools, home churches, and missionary training
programs around the world still manage courses on paper, WhatsApp, or
spreadsheets. Commercial LMS platforms are expensive, overkill, or require
technical expertise that volunteer-run organizations simply don't have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Equip&lt;/strong&gt; is designed to change that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free forever&lt;/strong&gt; — MIT-licensed, no paywalls, no "premium" tiers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple to deploy&lt;/strong&gt; — one-click Vercel deploy with a free Supabase
database. No Docker, no servers to manage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built for small scale&lt;/strong&gt; — optimized for 20-100 students, not…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
    <item>
      <title>4 footguns integrating YouVersion's new platform API — and a clean Verse of the Day</title>
      <dc:creator>Vadym Arnaut</dc:creator>
      <pubDate>Fri, 15 May 2026 18:39:44 +0000</pubDate>
      <link>https://dev.to/arvavit/4-footguns-integrating-youversions-new-platform-api-and-a-clean-verse-of-the-day-2087</link>
      <guid>https://dev.to/arvavit/4-footguns-integrating-youversions-new-platform-api-and-a-clean-verse-of-the-day-2087</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; YouVersion opened its API in April 2026. We wired it into our open-source Bible LMS as a Verse of the Day card on the dashboard. Four small things in their API surface cost us time. Writing them up so you don't repeat them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Equip is an open-source LMS for Bible schools (FastAPI + React + Supabase, MIT). A dashboard verse-of-the-day card is one of those quietly load-bearing features for a faith-shaped product — it gives the page a pastoral anchor on the right while keeping the actionable column primary, and creates a daily-return habit without screaming for engagement.&lt;/p&gt;

&lt;p&gt;YouVersion just opened &lt;strong&gt;YouVersion Platform&lt;/strong&gt;: 1,474 Bibles, 1,283 languages, REST API plus SDKs (Swift, Kotlin, React, React Native). Free for non-commercial use. So we plugged it in.&lt;/p&gt;

&lt;p&gt;The endpoints are simple. The friction is in the small print.&lt;/p&gt;

&lt;h2&gt;
  
  
  Footgun #1: &lt;code&gt;/v1/bibles&lt;/code&gt; returns 422 without &lt;code&gt;language_ranges[]&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;First instinct after &lt;code&gt;pip install httpx&lt;/code&gt;:&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="c1"&gt;# Wrong. Will not work.
&lt;/span&gt;&lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.youversion.com/v1/bibles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-YVP-App-Key&lt;/span&gt;&lt;span class="sh"&gt;"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Response:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;HTTP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;422&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Unprocessable&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Entity&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"detail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"missing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
             &lt;/span&gt;&lt;span class="nl"&gt;"loc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"language_ranges[]"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
             &lt;/span&gt;&lt;span class="nl"&gt;"msg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Field required"&lt;/span&gt;&lt;span class="p"&gt;}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The listing endpoint requires at least one ISO 639-3 language filter. We found that out the way everyone does — by reading the 422 body. Correct form:&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;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.youversion.com/v1/bibles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;language_ranges[]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eng&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;   &lt;span class="c1"&gt;# eng, rus, ukr, etc.
&lt;/span&gt;    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-YVP-App-Key&lt;/span&gt;&lt;span class="sh"&gt;"&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="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;If you're calling from a JS client and reach for &lt;code&gt;URLSearchParams&lt;/code&gt;, the brackets matter — &lt;code&gt;language_ranges[]&lt;/code&gt; with the literal brackets works, &lt;code&gt;language_ranges&lt;/code&gt; without them does not.&lt;/p&gt;

&lt;p&gt;Side note while you're here: the auth header is &lt;code&gt;X-YVP-App-Key&lt;/code&gt;, not &lt;code&gt;Authorization: Bearer&lt;/code&gt;. The 401 you get from forgetting is generic enough that you'll sit there comparing your key character-by-character before going back to the docs. Pin the header name in the integration's docstring on day one.&lt;/p&gt;
&lt;h2&gt;
  
  
  Footgun #2: &lt;code&gt;all_available=true&lt;/code&gt; reveals the real catalog
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;language_ranges[]=eng,rus,ukr&lt;/code&gt; we got &lt;strong&gt;11 Bibles back&lt;/strong&gt; — all English (ASV, BSB, Geneva, CPDV, etc.). Zero Russian. Zero Ukrainian.&lt;/p&gt;

&lt;p&gt;The marketing number is 1,474 translations. Where are they?&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;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.youversion.com/v1/bibles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;language_ranges[]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rus&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;all_available&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-YVP-App-Key&lt;/span&gt;&lt;span class="sh"&gt;"&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="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That returns 5 Russian Bibles: NRT (Новый Русский Перевод — the modern Russian we needed), three CARS variants, and CSLAV (church Slavonic). For Ukrainian: just UKRK 1905 — pre-revolutionary, hard for modern students. No modern Огієнко or UBIO in the platform yet.&lt;/p&gt;

&lt;p&gt;The mental model: &lt;strong&gt;the listing endpoint without &lt;code&gt;all_available&lt;/code&gt; shows what is enabled for your app key&lt;/strong&gt;, not what exists in the platform. Fetching a passage via &lt;code&gt;/bibles/{id}/passages/{ref}&lt;/code&gt; works for the broader catalog regardless of listing visibility — at least for non-commercial use as of this writing.&lt;/p&gt;

&lt;p&gt;Worth two minutes verifying for your own keys before you build a UI that says "no Russian translations available." We almost did.&lt;/p&gt;
&lt;h2&gt;
  
  
  Footgun #3: &lt;code&gt;content&lt;/code&gt; is HTML, even when you didn't ask
&lt;/h2&gt;

&lt;p&gt;The passage endpoint is the cleanest part of the API. You hand it a Bible ID and a USFM ref:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;GET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/v&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;/bibles/&lt;/span&gt;&lt;span class="mi"&gt;143&lt;/span&gt;&lt;span class="err"&gt;/passages/JHN.&lt;/span&gt;&lt;span class="mf"&gt;3.16&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"JHN.3.16"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;p&amp;gt;Ведь Бог так полюбил этот мир...&amp;lt;/p&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"reference"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"От Иоанна 3:16"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Two things to notice. First, &lt;code&gt;reference&lt;/code&gt; is &lt;strong&gt;localized to the Bible's language&lt;/strong&gt; — "От Иоанна 3:16" for NRT, "John 3:16" for BSB, automatically. That is free i18n you would otherwise build yourself.&lt;/p&gt;

&lt;p&gt;Second, &lt;code&gt;content&lt;/code&gt; is HTML. Usually a thin &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; wrapper, sometimes with &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; markers, occasionally a chunk of whitespace bleeding from the source USFM. If you're rendering scripture in a plain-prose sidebar card, you have to strip:&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;_strip_html&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&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="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;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;[^&amp;gt;]+&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# Some responses pack newlines inside &amp;lt;p&amp;gt; from the source markup —
&lt;/span&gt;    &lt;span class="c1"&gt;# collapse whitespace so the card renders as a single tidy line.
&lt;/span&gt;    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\s+&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&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;text&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;YouVersion never injects user-supplied markup into &lt;code&gt;content&lt;/code&gt; (it is their licensed text — they control the source), so a regex strip is safe here. Do not copy this pattern verbatim to places where the content origin is untrusted.&lt;/p&gt;
&lt;h2&gt;
  
  
  Footgun #4: their VOTD endpoint exists, but you cannot pick the verses
&lt;/h2&gt;

&lt;p&gt;YouVersion ships &lt;code&gt;/v1/verse_of_the_days/{day}&lt;/code&gt; that returns &lt;em&gt;their&lt;/em&gt; curated verse for that day of year:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;GET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/v&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;/verse_of_the_days/&lt;/span&gt;&lt;span class="mi"&gt;135&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"day"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;135&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"passage_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ISA.12.2"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Useful if you want to mirror the YouVersion app behavior exactly. But it is their selection — you cannot pass a tag, theme, or audience filter. For an LMS aimed at Bible-school students we wanted curated passages that bias toward &lt;strong&gt;doctrinally-neutral, evergreen, ecumenical&lt;/strong&gt; scripture: no denomination-specific proof texts, nothing that translates badly into Russian or Ukrainian.&lt;/p&gt;

&lt;p&gt;So we rolled our own list — 250 references covering salvation, hope, comfort, love, faith, wisdom, prayer, and perseverance. Grouped by canon section: Gospels, Acts, Pauline epistles, Hebrews and General epistles, Revelation, Psalms, Proverbs, Wisdom and Prophets, Torah and Historical books, Job and Minor prophets.&lt;/p&gt;
&lt;h2&gt;
  
  
  The shape that worked: deterministic by day, cached by day
&lt;/h2&gt;

&lt;p&gt;Selection is a one-liner:&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;_pick_reference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;date&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Everyone, everywhere, sees the same verse on a given UTC date.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_VERSES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toordinal&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_VERSES&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;toordinal()&lt;/code&gt; gives a globally monotonic integer. Modulo the catalog size, you get a cycle that takes about eight months to repeat — long enough that no student sees the same verse twice in a school term. No randomness, no per-user state, no cache invalidation drama. The verse on a given date is the same in every browser on every device.&lt;/p&gt;

&lt;p&gt;Cache is a process-local dict keyed on &lt;code&gt;(date_iso, locale)&lt;/code&gt;. Evict any entry whose date is not today on every write — the cache never holds more than &lt;code&gt;len(supported_locales)&lt;/code&gt; entries. We support &lt;code&gt;en&lt;/code&gt; and &lt;code&gt;ru&lt;/code&gt;, so at most two entries deep:&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;with&lt;/span&gt; &lt;span class="n"&gt;_CACHE_LOCK&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;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_CACHE&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;k&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;date_iso&lt;/span&gt;&lt;span class="p"&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;pop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;_CACHE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cache_key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;verse&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Two YouVersion calls per Python process per day. If the API key is unset (CI, fresh local dev), the service raises a typed exception and the route returns 404. The frontend treats 404 as "hide the card silently."&lt;/p&gt;

&lt;p&gt;Never block the dashboard on a third party. Same playbook as every other optional widget — but worth restating, because Bible-school students will not forgive a broken homepage on a Sunday.&lt;/p&gt;
&lt;h2&gt;
  
  
  A few things to weigh before you adopt this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Non-commercial lock-in is real.&lt;/strong&gt; You agree at app registration. Future paywalls, ads, or paid tiers revoke API access. For an open-source LMS this is fine. For a commercial product, model around it on day one — your &lt;code&gt;BibleProvider&lt;/code&gt; should be an adapter, not a hard import.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Russian coverage is solid, Ukrainian is sparse.&lt;/strong&gt; Modern Russian is there (NRT). Ukrainian audiences still need a fallback layer for modern translations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The SDKs ship UI components&lt;/strong&gt;, not just data clients. If you're using their React SDK you may not need most of the integration code above. We chose the REST API because Verse of the Day in our app is server-cached and renders in a slot we already control.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  What I'd ask the room
&lt;/h2&gt;

&lt;p&gt;If you have integrated YouVersion Platform yourself — how are you handling the &lt;code&gt;locale → bible_id&lt;/code&gt; mapping when there are multiple translations in the same language? We pick one per locale (&lt;code&gt;en&lt;/code&gt; → BSB, &lt;code&gt;ru&lt;/code&gt; → NRT) because Verse of the Day is a one-card, one-rendering surface. For a reading-plan flow where users want to switch translations on the fly the design space is wider — curious what others have done.&lt;/p&gt;

&lt;p&gt;For anyone building Christian software in 2026 — does the non-commercial lock-in change how you architect, or do you accept it and never plan to monetize? Genuinely curious whether anyone has built around the constraint.&lt;/p&gt;

&lt;p&gt;And for the YouVersion Platform team if you read this: an &lt;code&gt;Accept: text/plain&lt;/code&gt; toggle on &lt;code&gt;/passages&lt;/code&gt; would let consumers skip the strip-HTML dance. Probably trivial on your side, ergonomic win on ours.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArVaViT" rel="noopener noreferrer"&gt;
        ArVaViT
      &lt;/a&gt; / &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;
        equip
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Free, open-source LMS for Bible schools, ministries, and nonprofit educational programs. React + FastAPI + Supabase.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/frontend/public/favicon.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2Ffrontend%2Fpublic%2Ffavicon.svg" width="80" alt="Equip logo"&gt;&lt;/a&gt;
&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Equip&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;
  A free, open-source learning management system built for Bible schools
  church ministries, and nonprofit educational programs
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://github.com/ArVaViT/equip/blob/main/LICENSE" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/fbd05efc647372434fa773c0393f29e76f8dfd729ea1b5e069038c6e8179f339/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f417256615669542f65717569703f7374796c653d666c61742d737175617265" alt="MIT License"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/backend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/659c778682ca665110e0873307d8412572e9b5a59ecf78cbd04e38dab4804200/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f6261636b656e642d63692e796d6c3f6c6162656c3d6261636b656e64267374796c653d666c61742d737175617265" alt="Backend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/frontend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a549c27eebbf23022773ad1017442dcaf20b27c7ef080b4f3f8ac3344872eb87/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f66726f6e74656e642d63692e796d6c3f6c6162656c3d66726f6e74656e64267374796c653d666c61742d737175617265" alt="Frontend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/7e02756ed49d82974d5e10b79c40680819b90f5f2076338b4f3812b1cb096ae2/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6973737565732f417256615669542f65717569702f676f6f64253230666972737425323069737375653f7374796c653d666c61742d73717561726526636f6c6f723d373035376666266c6162656c3d676f6f642532306669727374253230697373756573" alt="Good first issues"&gt;
  &lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;Live demo&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/ROADMAP.md" rel="noopener noreferrer"&gt;Roadmap&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CONTRIBUTING.md" rel="noopener noreferrer"&gt;Contributing&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CHANGELOG.md" rel="noopener noreferrer"&gt;Changelog&lt;/a&gt;
&lt;/p&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why this project?&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;Hundreds of small Bible schools, home churches, and missionary training
programs around the world still manage courses on paper, WhatsApp, or
spreadsheets. Commercial LMS platforms are expensive, overkill, or require
technical expertise that volunteer-run organizations simply don't have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Equip&lt;/strong&gt; is designed to change that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free forever&lt;/strong&gt; — MIT-licensed, no paywalls, no "premium" tiers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple to deploy&lt;/strong&gt; — one-click Vercel deploy with a free Supabase
database. No Docker, no servers to manage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built for small scale&lt;/strong&gt; — optimized for 20-100 students, not enterprise
pricing models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contributor-friendly&lt;/strong&gt; — clear docs, conventional commits, issue
templates, and a welcoming community.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Features&lt;/h2&gt;

&lt;/div&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Course authoring&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Courses, modules, chapters, rich content blocks (TipTap editor with images, YouTube, callouts, audio)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Assessments&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multiple-choice, true/false, short-answer, and essay&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>opensource</category>
      <category>fastapi</category>
      <category>react</category>
      <category>api</category>
    </item>
    <item>
      <title>Two days lost to PGRST116: when Supabase RLS hides a successful write</title>
      <dc:creator>Vadym Arnaut</dc:creator>
      <pubDate>Wed, 13 May 2026 19:36:01 +0000</pubDate>
      <link>https://dev.to/arvavit/two-days-lost-to-pgrst116-when-supabase-rls-hides-a-successful-write-4nch</link>
      <guid>https://dev.to/arvavit/two-days-lost-to-pgrst116-when-supabase-rls-hides-a-successful-write-4nch</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; Our Supabase upsert wrote the row. The chained &lt;code&gt;.select().single()&lt;/code&gt; returned PGRST116. The wrapper read that as a failed write. The frontend retried. The retry was wrong. Two days to find why.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We've been running an LMS on Supabase for the past several months — auth, RLS on every table, FastAPI talking to Postgres. The bug below cost us two days last quarter, and the fix changed how we read PGRST116 in our wrapper.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setting
&lt;/h2&gt;

&lt;p&gt;We have a &lt;code&gt;quiz_attempts&lt;/code&gt; table. Each attempt gets created on quiz start, updated as the student progresses. The update is an upsert because a retry of the same &lt;code&gt;attempt_id&lt;/code&gt; should patch the existing row, not insert a duplicate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;quiz_attempts&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;upsert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;student_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quiz_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;attempt_count&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;single&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The &lt;code&gt;.select().single()&lt;/code&gt; chain returns the upserted row so we can show the student their new state.&lt;/p&gt;

&lt;p&gt;RLS policies, simplified:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"students write their own attempts"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;quiz_attempts&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;student_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;student_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"students read their own attempts"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;quiz_attempts&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;student_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;is_visible&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The SELECT policy carries an extra &lt;code&gt;is_visible&lt;/code&gt; check that the write policy doesn't. That asymmetry is the seam the bug walks through.&lt;/p&gt;
&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;A trigger on &lt;code&gt;quiz_attempts&lt;/code&gt; flips &lt;code&gt;is_visible&lt;/code&gt; to false under a corner case (timing relative to a parallel write from an admin tool — the specifics aren't the point). The student's upsert committed: their fields were written, the row is theirs.&lt;/p&gt;

&lt;p&gt;Then &lt;code&gt;.select().single()&lt;/code&gt; runs. The SELECT policy applies. &lt;code&gt;is_visible = false&lt;/code&gt;. PostgREST returns:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PGRST116"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"details"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Results contain 0 rows"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"JSON object requested, multiple (or no) rows returned"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Our runtime wrapper auto-threw on any &lt;code&gt;.error&lt;/code&gt;. To the upstream code, this looked identical to a failed upsert. The frontend retried with the same payload. The retry hit the same trigger. The user state diverged from the DB state. Etc.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why this was hard
&lt;/h2&gt;

&lt;p&gt;PGRST116 has two completely different meanings when it comes after a mutation:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The upsert truly failed&lt;/strong&gt; — constraint violation, missing required field, RLS denied the write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The upsert succeeded&lt;/strong&gt; — RLS just hid the returned row from the caller.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The wrapper conflated them. The PostgrestError code is identical. The HTTP status is identical (406). The only difference lives in the database, which the client can't see.&lt;/p&gt;

&lt;p&gt;Two days of logs because we kept treating "no row returned" as "no row written."&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Three changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Don't auto-throw on PGRST116 from a &lt;code&gt;.select().single()&lt;/code&gt; chained off a mutation.&lt;/strong&gt; Treat it as ambiguous and route to a separate branch:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;safeUpsertReturning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;query&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;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PGRST116&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="c1"&gt;// The mutation may have succeeded; we just can't read the row.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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="nx"&gt;error&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;&lt;strong&gt;2. Service-role verification.&lt;/strong&gt; When the wrapper returns &lt;code&gt;hidden: true&lt;/code&gt;, the backend does a service-role read by &lt;code&gt;id&lt;/code&gt; to confirm whether the row actually exists. Yes → success-without-readback (treat as written). No → real failure, propagate. The client never branches on this directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Observability tying client errors to DB state.&lt;/strong&gt; Every PGRST116 from the wrapper emits a structured event with the query shape and request id. Server-side, we log the same id with the actual row state visible to service role. Correlating the two would have surfaced the mismatch on day one.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I'd tell past-us
&lt;/h2&gt;

&lt;p&gt;PGRST116 is not a write error. It's a &lt;strong&gt;visibility&lt;/strong&gt; error.&lt;/p&gt;

&lt;p&gt;Your wrapper, your error handler, your retry logic — each should know which one it's seeing. If your stack can't tell the difference, you're going to retry successful writes. The kind of bug that produces is the kind where the symptom and the cause are twelve layers apart.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I want to hear back
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Do you separate "write failed" from "write succeeded but I can't read it" in your Supabase code? What does the wrapper look like?&lt;/li&gt;
&lt;li&gt;Has anyone built a Postgres-side audit trigger that captures "row was written but RLS hid it from the writer"? Curious about the shape.&lt;/li&gt;
&lt;li&gt;For service-role verification — anything safer than &lt;code&gt;select(*).eq('id', id)&lt;/code&gt; you've used?&lt;/li&gt;
&lt;/ul&gt;



&lt;p&gt;The project that runs this stack is open source:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArVaViT" rel="noopener noreferrer"&gt;
        ArVaViT
      &lt;/a&gt; / &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;
        equip
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Free, open-source LMS for Bible schools, ministries, and nonprofit educational programs. React + FastAPI + Supabase.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/frontend/public/favicon.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2Ffrontend%2Fpublic%2Ffavicon.svg" width="80" alt="Equip logo"&gt;&lt;/a&gt;
&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Equip&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;
  A free, open-source learning management system built for Bible schools
  church ministries, and nonprofit educational programs
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://github.com/ArVaViT/equip/blob/main/LICENSE" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/fbd05efc647372434fa773c0393f29e76f8dfd729ea1b5e069038c6e8179f339/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f417256615669542f65717569703f7374796c653d666c61742d737175617265" alt="MIT License"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/backend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/659c778682ca665110e0873307d8412572e9b5a59ecf78cbd04e38dab4804200/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f6261636b656e642d63692e796d6c3f6c6162656c3d6261636b656e64267374796c653d666c61742d737175617265" alt="Backend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/frontend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a549c27eebbf23022773ad1017442dcaf20b27c7ef080b4f3f8ac3344872eb87/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f66726f6e74656e642d63692e796d6c3f6c6162656c3d66726f6e74656e64267374796c653d666c61742d737175617265" alt="Frontend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/7e02756ed49d82974d5e10b79c40680819b90f5f2076338b4f3812b1cb096ae2/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6973737565732f417256615669542f65717569702f676f6f64253230666972737425323069737375653f7374796c653d666c61742d73717561726526636f6c6f723d373035376666266c6162656c3d676f6f642532306669727374253230697373756573" alt="Good first issues"&gt;
  &lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;Live demo&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/ROADMAP.md" rel="noopener noreferrer"&gt;Roadmap&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CONTRIBUTING.md" rel="noopener noreferrer"&gt;Contributing&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CHANGELOG.md" rel="noopener noreferrer"&gt;Changelog&lt;/a&gt;
&lt;/p&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why this project?&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;Hundreds of small Bible schools, home churches, and missionary training
programs around the world still manage courses on paper, WhatsApp, or
spreadsheets. Commercial LMS platforms are expensive, overkill, or require
technical expertise that volunteer-run organizations simply don't have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Equip&lt;/strong&gt; is designed to change that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free forever&lt;/strong&gt; — MIT-licensed, no paywalls, no "premium" tiers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple to deploy&lt;/strong&gt; — one-click Vercel deploy with a free Supabase
database. No Docker, no servers to manage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built for small scale&lt;/strong&gt; — optimized for 20-100 students, not enterprise
pricing models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contributor-friendly&lt;/strong&gt; — clear docs, conventional commits, issue
templates, and a welcoming community.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Features&lt;/h2&gt;

&lt;/div&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Course authoring&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Courses, modules, chapters, rich content blocks (TipTap editor with images, YouTube, callouts, audio)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Assessments&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multiple-choice, true/false, short-answer, and essay&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>debugging</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The 3 i18n mistakes every open-source LMS makes</title>
      <dc:creator>Vadym Arnaut</dc:creator>
      <pubDate>Wed, 13 May 2026 03:21:32 +0000</pubDate>
      <link>https://dev.to/arvavit/the-3-i18n-mistakes-every-open-source-lms-makes-2lfk</link>
      <guid>https://dev.to/arvavit/the-3-i18n-mistakes-every-open-source-lms-makes-2lfk</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; Every open-source LMS treats internationalization as one problem. It's three. UI strings, user-generated content, and canonical artifacts each need a different mechanism. Most codebases collapse them into one — that's the bug.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We've been building an open-source Bible school LMS for about 5 months. It runs in Russian and English (Ukrainian coming), with auto-translated user content and canonical-text preservation for scripture quotes. Building this forced me to look at how Moodle, Open edX, Canvas LMS, and Chamilo handle the same problem.&lt;/p&gt;

&lt;p&gt;The pattern is the same in all of them — and was the same in ours when we started.&lt;/p&gt;




&lt;h2&gt;
  
  
  Mistake #1: User-generated content treated like UI strings
&lt;/h2&gt;

&lt;p&gt;Every LMS has solid gettext-style infrastructure for UI strings. "Sign in", "Course catalog", "Submit assignment" — these live in &lt;code&gt;.po&lt;/code&gt; files (Moodle), Transifex (Open edX), &lt;code&gt;i18n-js&lt;/code&gt; (Canvas), YAML (Rails-based). A translator translates the file once. Done.&lt;/p&gt;

&lt;p&gt;User-generated content is a different problem entirely. When a teacher authors a course in Russian, the title — &lt;em&gt;"Введение в Послание к Римлянам"&lt;/em&gt; — is a row in &lt;code&gt;courses.title&lt;/code&gt;. An English-speaking student opens the catalog and sees Cyrillic. &lt;strong&gt;The UI is translated. The content isn't.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;UI strings&lt;/th&gt;
&lt;th&gt;User-generated content&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Examples&lt;/td&gt;
&lt;td&gt;"Sign in", "Submit"&lt;/td&gt;
&lt;td&gt;Course title, lesson body, quiz question&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Source&lt;/td&gt;
&lt;td&gt;Fixed catalog of values&lt;/td&gt;
&lt;td&gt;Unbounded user input&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool&lt;/td&gt;
&lt;td&gt;gettext / Transifex / YAML&lt;/td&gt;
&lt;td&gt;Runtime translation (Google, DeepL, Gemini)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;When&lt;/td&gt;
&lt;td&gt;Build / release time&lt;/td&gt;
&lt;td&gt;Runtime (lazy or eager)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;Locale file&lt;/td&gt;
&lt;td&gt;Separate cache table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Common bug&lt;/td&gt;
&lt;td&gt;None major&lt;/td&gt;
&lt;td&gt;Treated like UI strings → only one language ever shown&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Moodle's workaround is the multilang filter — you wrap content in &lt;code&gt;&amp;lt;span lang="ru"&amp;gt;…&amp;lt;/span&amp;gt;&amp;lt;span lang="en"&amp;gt;…&amp;lt;/span&amp;gt;&lt;/code&gt; and a filter shows the matching one. This is a 2008 solution. It puts the entire burden on the teacher: they must author every piece of content twice, in every language. Most don't, and the platform falls back to "show whatever the teacher wrote first."&lt;/p&gt;

&lt;p&gt;The shape of the right answer is a separate translation cache:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;content_translations&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;entity_type&lt;/span&gt;  &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;-- 'course', 'lesson', 'quiz_question'&lt;/span&gt;
  &lt;span class="n"&gt;entity_id&lt;/span&gt;    &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;field&lt;/span&gt;        &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;-- 'title', 'description', 'body'&lt;/span&gt;
  &lt;span class="n"&gt;locale&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;-- 'ru', 'en', 'uk', 'es'&lt;/span&gt;
  &lt;span class="n"&gt;content&lt;/span&gt;      &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;source&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;-- 'human' | 'machine' | 'canonical'&lt;/span&gt;
  &lt;span class="n"&gt;cached_at&lt;/span&gt;    &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entity_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entity_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;locale&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;Teacher authors in one language. A translation worker fills in the others lazily (first request) or eagerly (on publish). The course-detail endpoint joins on &lt;code&gt;(entity_type, entity_id, field, viewer_locale)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The architectural decision: &lt;strong&gt;content is not a UI string and cannot live in the same system.&lt;/strong&gt; If your LMS uses gettext for "Submit assignment" and the same mechanism for course titles, that's the bug.&lt;/p&gt;


&lt;h2&gt;
  
  
  Mistake #2: No accommodation for length variance
&lt;/h2&gt;

&lt;p&gt;English is dense. Russian runs ~25–30% longer for the same meaning. German is comparable. Finnish is worse. Arabic is shorter — and right-to-left, its own category.&lt;/p&gt;

&lt;p&gt;Most LMS UIs are designed in English. Buttons that fit "Save" don't fit "Сохранить". Nav tabs that fit "Courses" wrap to two lines for "Курсы и обучение". Mobile breaks first.&lt;/p&gt;

&lt;p&gt;Part of the fix is CSS:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* Reserve space against the longer-language baseline */&lt;/span&gt;
&lt;span class="nc"&gt;.action-button&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;min-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;white-space&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;nowrap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;text-overflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ellipsis&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* Tabular content: don't let translation reflow the grid */&lt;/span&gt;
&lt;span class="nc"&gt;.lesson-list-cell&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;min-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c"&gt;/* fits 2-line Russian titles without shift */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;But the real fix is &lt;strong&gt;testing every screen in your longest-content language&lt;/strong&gt;, not just English. Most teams hire designers who only see the English mock and don't realize their "Continue" button is broken in Russian until a user reports it.&lt;/p&gt;

&lt;p&gt;What helped us: a Storybook locale switcher defaulting to Russian (not English), and a Playwright snapshot suite that screenshots both locales. The first commit that breaks Russian layout is caught in CI, not by a user.&lt;/p&gt;


&lt;h2&gt;
  
  
  Mistake #3: Canonical content forced through translation
&lt;/h2&gt;

&lt;p&gt;This is the bug that motivated our entire content-translation rewrite.&lt;/p&gt;

&lt;p&gt;A teacher in Russian writes: &lt;em&gt;"Послание к Римлянам 8:28 говорит, что все содействует ко благу"&lt;/em&gt;. The course gets auto-translated to English. Naively you send the whole string to Google Translate or DeepL, and you get back: &lt;em&gt;"Romans 8:28 says that everything works together for good."&lt;/em&gt; That's almost a real Bible verse — but it isn't quoting any actual translation. It's a translation of a translation.&lt;/p&gt;

&lt;p&gt;You don't want that. You want the actual KJV (or NIV, or ESV) text of Romans 8:28 spliced in.&lt;/p&gt;

&lt;p&gt;Same problem exists everywhere there's canonical content:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Programming courses with code samples (don't translate the variables!)&lt;/li&gt;
&lt;li&gt;Math curricula with formulas&lt;/li&gt;
&lt;li&gt;Legal courses citing statutes&lt;/li&gt;
&lt;li&gt;Medical courses citing clinical-trial registries&lt;/li&gt;
&lt;li&gt;Literature courses with original-language quotes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most LMSes don't separate "translatable prose" from "canonical artifact" — so when they auto-translate, the canonical content gets mangled or invented.&lt;/p&gt;

&lt;p&gt;Our pattern: parse the source for canonical references, replace each with a placeholder token, translate the surrounding prose, then substitute the canonical text back from a separate lookup.&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;translate_with_canonical_preservation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;text&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;source_lang&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;target_lang&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="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="c1"&gt;# 1. Find canonical references in either language
&lt;/span&gt;    &lt;span class="n"&gt;refs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_bible_refs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lang&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;source_lang&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# [{"raw": "Послание к Римлянам 8:28", "book": "ROM", "chapter": 8, "verses": [28]}]
&lt;/span&gt;
    &lt;span class="c1"&gt;# 2. Replace each with a unique placeholder
&lt;/span&gt;    &lt;span class="n"&gt;placeholders&lt;/span&gt; &lt;span class="o"&gt;=&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;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;refs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;⟦CANON_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;⟧&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;placeholders&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;raw&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# 3. Translate the token-bearing string
&lt;/span&gt;    &lt;span class="n"&gt;translated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;translate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_lang&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_lang&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# 4. Substitute the canonical text back, looked up in the target translation
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;placeholders&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="n"&gt;canonical_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lookup_canonical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_lang&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# KJV for en, Synodal for ru
&lt;/span&gt;        &lt;span class="n"&gt;translated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;translated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;canonical_text&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;translated&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  How &lt;code&gt;extract_bible_refs&lt;/code&gt; handles the cross-language book-name matrix
&lt;/h3&gt;

&lt;p&gt;Detection is a regex over a normalized book-name dictionary. Each canonical book has an entry like:&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;BOOKS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ROM&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Romans&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Rom&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Rom.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ru&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Послание к Римлянам&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Римлянам&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Рим&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Рим.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;uk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Послання до Римлян&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Римлян&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Рим&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="c1"&gt;# ... 65 more books
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The regex is built per request as &lt;code&gt;(book_alias_1|book_alias_2|...)\s+(\d+):(\d+)(?:[-–](\d+))?&lt;/code&gt; so it accepts: &lt;code&gt;Romans 8:28&lt;/code&gt;, &lt;code&gt;Послание к Римлянам 8:28&lt;/code&gt;, &lt;code&gt;Рим 8:28&lt;/code&gt;, &lt;code&gt;Рим. 8:28&lt;/code&gt;, &lt;code&gt;Romans 8:28-29&lt;/code&gt;, &lt;code&gt;Romans 8:28a&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Sundry edge cases: chapter-only refs (&lt;code&gt;Romans 8&lt;/code&gt; — whole-chapter), letter suffixes (&lt;code&gt;8:28a&lt;/code&gt; — first half of verse), em-dash vs hyphen, non-breaking spaces. Each lives in unit tests.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;lookup_canonical&lt;/code&gt; pulls from a canonical text table keyed by &lt;code&gt;(book, chapter, verse_start, verse_end, translation)&lt;/code&gt;. Cache the final translated string keyed by &lt;code&gt;(entity_id, field, locale)&lt;/code&gt; per Mistake #1.&lt;/p&gt;

&lt;p&gt;This is ~400 lines we wouldn't have written if any of the LMSes we looked at had solved this for us.&lt;/p&gt;


&lt;h2&gt;
  
  
  What I want to hear back
&lt;/h2&gt;

&lt;p&gt;These are the three patterns I keep seeing. They're not the only ones (cache invalidation on edits, RTL layout, Slavic plural forms — separate posts), but they're the ones every general-purpose LMS skips.&lt;/p&gt;

&lt;p&gt;If you've shipped i18n in an LMS, education platform, or any content product:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Do you separate UI strings from content?&lt;/strong&gt; What's your storage shape?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How do you handle length variance?&lt;/strong&gt; Do you test the long-language layout in CI?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do you have canonical content that mustn't be translated?&lt;/strong&gt; Code samples, equations, citations? How are you handling it?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Curious where teams have landed. The patterns above are our current best, not our last word.&lt;/p&gt;



&lt;p&gt;The project that drove all of this is open source:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ArVaViT" rel="noopener noreferrer"&gt;
        ArVaViT
      &lt;/a&gt; / &lt;a href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;
        equip
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Free, open-source LMS for Bible schools, ministries, and nonprofit educational programs. React + FastAPI + Supabase.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/frontend/public/favicon.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2Ffrontend%2Fpublic%2Ffavicon.svg" width="80" alt="Equip logo"&gt;&lt;/a&gt;
&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Equip&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;
  A free, open-source learning management system built for Bible schools
  church ministries, and nonprofit educational programs
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://github.com/ArVaViT/equip/blob/main/LICENSE" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/fbd05efc647372434fa773c0393f29e76f8dfd729ea1b5e069038c6e8179f339/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f417256615669542f65717569703f7374796c653d666c61742d737175617265" alt="MIT License"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/backend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/659c778682ca665110e0873307d8412572e9b5a59ecf78cbd04e38dab4804200/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f6261636b656e642d63692e796d6c3f6c6162656c3d6261636b656e64267374796c653d666c61742d737175617265" alt="Backend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/actions/workflows/frontend-ci.yml" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a549c27eebbf23022773ad1017442dcaf20b27c7ef080b4f3f8ac3344872eb87/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f417256615669542f65717569702f66726f6e74656e642d63692e796d6c3f6c6162656c3d66726f6e74656e64267374796c653d666c61742d737175617265" alt="Frontend CI"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/ArVaViT/equip/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/7e02756ed49d82974d5e10b79c40680819b90f5f2076338b4f3812b1cb096ae2/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6973737565732f417256615669542f65717569702f676f6f64253230666972737425323069737375653f7374796c653d666c61742d73717561726526636f6c6f723d373035376666266c6162656c3d676f6f642532306669727374253230697373756573" alt="Good first issues"&gt;
  &lt;/a&gt;
  &lt;a href="https://app.codecov.io/gh/ArVaViT/equip" rel="nofollow noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/a2082409068ba3266a009213fdfef36845f9ba34386f31c378b6d250482598fa/68747470733a2f2f696d672e736869656c64732e696f2f636f6465636f762f632f6769746875622f417256615669542f65717569703f7374796c653d666c61742d737175617265266c6162656c3d636f766572616765" alt="Code coverage"&gt;
  &lt;/a&gt;
  &lt;a href="https://scorecard.dev/viewer/?uri=github.com/ArVaViT/equip" rel="nofollow noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/63c1ae6c3a1ca1f1b29e7730e3c6ec8aab5caa520e104beaa3dcd66af5d66dc3/68747470733a2f2f696d672e736869656c64732e696f2f6f7373662d73636f7265636172642f6769746875622e636f6d2f417256615669542f65717569703f7374796c653d666c61742d737175617265266c6162656c3d6f70656e73736625323073636f726563617264" alt="OpenSSF Scorecard"&gt;
  &lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;Live demo&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/ROADMAP.md" rel="noopener noreferrer"&gt;Roadmap&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CONTRIBUTING.md" rel="noopener noreferrer"&gt;Contributing&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/SUPPORT.md" rel="noopener noreferrer"&gt;Support&lt;/a&gt; ·
  &lt;a href="https://github.com/ArVaViT/equip/CHANGELOG.md" rel="noopener noreferrer"&gt;Changelog&lt;/a&gt;
&lt;/p&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Screenshots&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;&lt;/p&gt;&lt;div class="table-wrapper-paragraph"&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;table&gt;

  &lt;tbody&gt;

&lt;tr&gt;

    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/login-desktop.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Flogin-desktop.png" alt="Equip login page — two-column layout with scripture on the left and a clean sign-in form on the right"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Sign in (light)&lt;br&gt;
    &lt;/td&gt;

    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/login-desktop-dark.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Flogin-desktop-dark.png" alt="Equip login page in dark mode"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Sign in (dark)&lt;br&gt;
    &lt;/td&gt;

  &lt;/tr&gt;

  &lt;tr&gt;

    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/register-desktop.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Fregister-desktop.png" alt="Equip account creation with a Student or Teacher role chooser"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Account creation — student / teacher role picker&lt;br&gt;
    &lt;/td&gt;

    &lt;td width="50%"&gt;
&lt;br&gt;
      &lt;a rel="noopener noreferrer" href="https://github.com/ArVaViT/equip/.github/assets/screenshots/login-mobile.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FArVaViT%2Fequip%2FHEAD%2F.github%2Fassets%2Fscreenshots%2Flogin-mobile.png" alt="Equip sign-in on a 390px mobile viewport" width="240"&gt;&lt;/a&gt;&lt;br&gt;
      &lt;br&gt;Mobile (390px)&lt;br&gt;
    &lt;/td&gt;

  &lt;/tr&gt;

&lt;/tbody&gt;

&lt;/table&gt;&lt;/div&gt;&lt;p&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Live at &lt;a href="https://equipbible.com" rel="nofollow noopener noreferrer"&gt;equipbible.com&lt;/a&gt;. Teacher and admin views (gradebook, course editor, analytics) are behind sign-in — create a free account to explore.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why this project?&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;Hundreds of small Bible schools, home churches, and missionary training
programs around the world still manage courses on paper, WhatsApp, or
spreadsheets. Commercial LMS platforms are expensive, overkill, or require
technical expertise that volunteer-run organizations simply don't have.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Equip&lt;/strong&gt; is designed to change that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free forever&lt;/strong&gt; — MIT-licensed, no paywalls, no "premium" tiers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple to deploy&lt;/strong&gt; — one-click Vercel deploy with a free Supabase
database. No Docker, no servers to manage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built for small scale&lt;/strong&gt; — optimized for 20-100 students, not…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ArVaViT/equip" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>i18n</category>
      <category>architecture</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
