<?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: Kornel Maraz</title>
    <description>The latest articles on DEV Community by Kornel Maraz (@kornel_maraz_5e66a3e4e27d).</description>
    <link>https://dev.to/kornel_maraz_5e66a3e4e27d</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%2F2501031%2F292ca222-73f0-4722-a39b-2263145e78ef.jpg</url>
      <title>DEV Community: Kornel Maraz</title>
      <link>https://dev.to/kornel_maraz_5e66a3e4e27d</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kornel_maraz_5e66a3e4e27d"/>
    <language>en</language>
    <item>
      <title>SSO Without Giving the Server Your Keys</title>
      <dc:creator>Kornel Maraz</dc:creator>
      <pubDate>Wed, 09 Sep 2026 18:26:21 +0000</pubDate>
      <link>https://dev.to/kornel_maraz_5e66a3e4e27d/sso-without-giving-the-server-your-keys-lm0</link>
      <guid>https://dev.to/kornel_maraz_5e66a3e4e27d/sso-without-giving-the-server-your-keys-lm0</guid>
      <description>&lt;p&gt;Single sign-on is a solved problem. You redirect to an identity provider, it tells you who the person is, you mint a session. Every framework has a library for it.&lt;/p&gt;

&lt;p&gt;Then you try it on an app that encrypts everything in the browser, and the whole thing falls over.&lt;/p&gt;

&lt;h2&gt;
  
  
  The claim that breaks SSO
&lt;/h2&gt;

&lt;p&gt;MindMapVault derives your encryption key from your passphrase, in your browser, and never sends it anywhere:&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="nx"&gt;master_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Argon2id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;passphrase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;salt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;auth_token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;HKDF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;master_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;crypt-mind-auth-v1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// the only part the server sees&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything else hangs off &lt;code&gt;master_key&lt;/code&gt;. Your private keys are encrypted under it. Your vaults are encrypted under those. The server stores ciphertext and a hash of &lt;code&gt;auth_token&lt;/code&gt;, and that is the entire extent of what it knows.&lt;/p&gt;

&lt;p&gt;Now add an identity provider. Entra says &lt;em&gt;this is &lt;a href="mailto:alice@example.com"&gt;alice@example.com&lt;/a&gt;&lt;/em&gt;. Google says the same. Both are telling the truth, and neither one helps, because an OIDC token is an assertion about identity — it is not, and cannot be, the key that decrypts Alice's data.&lt;/p&gt;

&lt;p&gt;The key has to come from somewhere. There are exactly three somewheres:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Something the user types&lt;/strong&gt; — a passphrase.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Something a device holds&lt;/strong&gt; — a passkey, or a key in browser storage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Something your organisation holds&lt;/strong&gt; — escrow.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Option 3 is the one that makes SSO a one-click experience, and it is the one that quietly ends zero-knowledge: if the org can unwrap the key, so can the server, so can a subpoena, so can whoever gets into your KMS. Most products that advertise "SSO with end-to-end encryption" have picked option 3 without saying so.&lt;/p&gt;

&lt;p&gt;We picked 1, then made it rare with 2.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the user sees
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;First sign-in through the provider.&lt;/strong&gt; Redirect, consent, back. Then one screen: choose a username and a vault passphrase.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every later sign-in.&lt;/strong&gt; Straight through. No passphrase — because the browser already holds a key that can unwrap it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A new laptop.&lt;/strong&gt; Passphrase once, then that machine is remembered too.&lt;/p&gt;

&lt;p&gt;So the passphrase is typed roughly as often as you buy a computer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The flow, end to end
&lt;/h2&gt;

&lt;p&gt;The OIDC half is ordinary — authorization code with PKCE, a single-use &lt;code&gt;state&lt;/code&gt;, a &lt;code&gt;nonce&lt;/code&gt; bound to the request. The interesting part is where it stops:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;browser                our server              identity provider
   |                        |                          |
   |  click "Continue"      |                          |
   |-----------------------&amp;gt;|  store state+nonce+PKCE  |
   |         307 redirect   |                          |
   |&amp;lt;-----------------------|                          |
   |                        |                          |
   |  authenticate ------------------------------------&amp;gt;
   |  &amp;lt;------------------------------ redirect + code   
   |                        |                          |
   |  code + state          |                          |
   |-----------------------&amp;gt;|  exchange code ---------&amp;gt;|
   |                        |&amp;lt;-------- id_token (JWT)  |
   |                        |                          |
   |                        |  verify: signature,      |
   |                        |  issuer, audience,       |
   |                        |  expiry, nonce           |
   |                        |  find or create account  |
   |    tokens in fragment  |                          |
   |&amp;lt;-----------------------|                          |
   |                        |                          |
   +-- the server is done here. the vault is unlocked
       locally, with a key the server has never seen.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in that picture are easy to get wrong. The ID token is verified on &lt;strong&gt;signature, issuer, audience, expiry and nonce&lt;/strong&gt; — skip any one and the flow becomes a way to sign in as anybody. And the tokens come back in the URL &lt;em&gt;fragment&lt;/em&gt;, not the query string, because browsers never send a fragment to a server: it stays out of access logs, proxy logs and &lt;code&gt;Referer&lt;/code&gt; headers, and the page clears it from history immediately.&lt;/p&gt;

&lt;p&gt;The account created at the end of that has no password and no keys — it cannot open anything yet. That is what the enrolment screen is nor, and it is why the username is asked for there rather than lifted from a provider claim: a name taken from an IdP can collide with an existing local account, and resolving that collision by merging is an account takeover with extra steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  The insight that makes it cheap
&lt;/h2&gt;

&lt;p&gt;Here is the part worth stealing for your own app.&lt;/p&gt;

&lt;p&gt;We nearly redesigned the whole key hierarchy for this. We did not need to. Look again at what the system actually depends on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;aesKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;deriveMasterAesKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;masterKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;classicalPriv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;aesDecrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;aesKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bundle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classical_priv_encrypted&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pqPriv&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;aesDecrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;aesKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bundle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pq_priv_encrypted&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing here cares &lt;strong&gt;how&lt;/strong&gt; you got those 32 bytes. &lt;code&gt;deriveMasterAesKey&lt;/code&gt; just imports them as an AES key. The Argon2 derivation is one way to obtain them, not the definition of them.&lt;/p&gt;

&lt;p&gt;So instead of changing the derivation, we store &lt;strong&gt;additional encrypted copies of the same master key&lt;/strong&gt;, one per unlock method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                  master_key  (32 bytes)
                never leaves the browser
                           ^
         +-----------------+-----------------+
         |                 |                 |
    derived from      unwrapped by      unwrapped by
         |                 |                 |
  +------------+    +-------------+   +-------------+
  | passphrase |    |   device    |   |   passkey   |
  |  (Argon2)  |    |     key     |   |  (WebAuthn  |
  |            |    | (IndexedDB) |   |     PRF)    |
  +------------+    +-------------+   +-------------+
    nothing           server holds      server holds
    stored            ciphertext        ciphertext
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One table:&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;unlock_methods&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;                 &lt;span class="nb"&gt;TEXT&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;user_id&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;REFERENCES&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="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;kind&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;-- 'device' | 'webauthn-prf'&lt;/span&gt;
    &lt;span class="n"&gt;label&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;-- "Chrome on Windows"&lt;/span&gt;
    &lt;span class="n"&gt;wrapped_master_key&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;-- ciphertext the server cannot open&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="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;last_used_at&lt;/span&gt;       &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No migration. No vault re-encrypted. The passphrase path is untouched — it&lt;br&gt;
still derives, exactly as before. Everything else is additive, which is also why adding passkeys later is a second value in &lt;code&gt;kind&lt;/code&gt; rather than a rewrite.&lt;/p&gt;
&lt;h2&gt;
  
  
  Trusting a device
&lt;/h2&gt;

&lt;p&gt;The wrapping key is generated in the browser and never becomes bytes anyone can read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&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;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AES-GCM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                       &lt;span class="c1"&gt;// extractable: false - this is the whole trick&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;encrypt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;decrypt&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;iv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRandomValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ciphertext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encrypt&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AES-GCM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;iv&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;masterKey&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;methodId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;   &lt;span class="c1"&gt;// IndexedDB holds the CryptoKey&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;extractable: false&lt;/code&gt; means &lt;code&gt;crypto.subtle.exportKey&lt;/code&gt; throws. The key can&lt;br&gt;
encrypt and decrypt, and that is all — it cannot be serialised, logged, or&lt;br&gt;
posted to an endpoint, even by code running on the page.&lt;/p&gt;

&lt;p&gt;What goes to the server is &lt;code&gt;iv || ciphertext&lt;/code&gt;. It is opaque. We assert that in&lt;br&gt;
a test rather than merely believing it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&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="nx"&gt;wrapped_master_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// nonce + key + GCM tag&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exactly nonce, key and tag — no room for anything in the clear.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unlocking silently
&lt;/h2&gt;

&lt;p&gt;On load, before showing any prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;loadDeviceRecord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&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;record&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="c1"&gt;// ask for the passphrase&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;wrapped_master_key&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;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getWrappedMasterKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;methodId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;masterKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;unwrapWithDevice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wrapped_master_key&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;masterKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;forgetDevice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                         &lt;span class="c1"&gt;// stale - drop it&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                                             &lt;span class="c1"&gt;// ask for the passphrase&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;openWithMasterKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;masterKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every failure falls through to the prompt. Not being remembered is the normal case, not an error, and a device that cannot unwrap is a device whose local key is now useless — so it is deleted rather than retried on every load.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two things that will bite you
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Rotation invalidates every stored copy.&lt;/strong&gt; When someone changes their&lt;br&gt;
passphrase, &lt;code&gt;master_key&lt;/code&gt; changes, and every wrapped copy becomes undecryptable noise. If you leave those rows, a device trusted yesterday fails today with nothing on screen to explain why. So rotation deletes them all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The rotation has already committed, so this cannot abort it. A stale row&lt;/span&gt;
&lt;span class="c1"&gt;// costs one failed silent unlock and a passphrase prompt - the safe direction.&lt;/span&gt;
&lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="py"&gt;.db&lt;/span&gt;&lt;span class="nf"&gt;.delete_all_unlock_methods&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="na"&gt;.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&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="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;removed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;tracing&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;info!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;removed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"cleared trusted devices after rotation"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;tracing&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;warn!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"could not clear unlock methods"&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 is also the right behaviour when the rotation happened &lt;em&gt;because&lt;/em&gt; the old passphrase leaked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never link accounts on email.&lt;/strong&gt; An OIDC token carries &lt;code&gt;sub&lt;/code&gt; and usually&lt;br&gt;
&lt;code&gt;email&lt;/code&gt;. It is tempting to match on the address — it is human-readable and&lt;br&gt;
already in your users table. Do not. An address gets reassigned when someone leaves a company, and the next holder of &lt;code&gt;alice@example.com&lt;/code&gt; would inherit Alice's vaults. Link on &lt;code&gt;(provider_id, sub)&lt;/code&gt;, and store the email only when the provider says it verified it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;verified_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;IdTokenClaims&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&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;match&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="py"&gt;.email&lt;/span&gt;&lt;span class="nf"&gt;.as_deref&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="py"&gt;.email_verified&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// a provider that says nothing has not checked it either&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;h2&gt;
  
  
  What this is not
&lt;/h2&gt;

&lt;p&gt;A trusted device is &lt;strong&gt;convenience, not security&lt;/strong&gt;. Anyone who can use that&lt;br&gt;
browser profile can open the vaults. That is the honest trade, it is why the checkbox is off by default and says so in plain words, and it is no weaker than leaving a session signed in — which is what people do anyway.&lt;/p&gt;

&lt;p&gt;And there is no recovery. No passphrase, no admin reset, no escrow. Lose every device and forget the passphrase, and the vaults are gone. That is not a gap we have failed to close; it is the same sentence as "the server cannot read your data", said from the other side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it leaves us
&lt;/h2&gt;

&lt;p&gt;Federated sign-in that authenticates the session, a passphrase that unlocks the data, and a device key that means you rarely type it. Three secrets doing three different jobs, none of them held by the server.&lt;/p&gt;

&lt;p&gt;Next is WebAuthn PRF — deriving the wrapping key from a passkey instead of browser storage. With a syncing passkey provider that is genuinely passwordless across devices, and thanks to the table above it is one more row, not another redesign.&lt;/p&gt;




&lt;p&gt;&lt;a href="https://www.mindmapvault.com/" rel="noopener noreferrer"&gt;MindMapVault&lt;/a&gt; is an end-to-end encrypted mind mapping tool. Everything described here — federated sign-in, the enrolment step and trusted devices — ships in the next release of the &lt;a href="https://www.mindmapvault.com/self-hosted-mind-map/" rel="noopener noreferrer"&gt;self-hosted server&lt;/a&gt;, which is open source under AGPL-3, so you can read the parts this article only summarises.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>authentication</category>
      <category>privacy</category>
      <category>security</category>
    </item>
    <item>
      <title>Real-time collaboration on a server that cannot read the document</title>
      <dc:creator>Kornel Maraz</dc:creator>
      <pubDate>Sat, 05 Sep 2026 10:54:57 +0000</pubDate>
      <link>https://dev.to/kornel_maraz_5e66a3e4e27d/real-time-collaboration-on-a-server-that-cannot-read-the-document-4p9a</link>
      <guid>https://dev.to/kornel_maraz_5e66a3e4e27d/real-time-collaboration-on-a-server-that-cannot-read-the-document-4p9a</guid>
      <description>&lt;p&gt;&lt;a href="https://www.mindmapvault.com" rel="noopener noreferrer"&gt;MindMapVault&lt;/a&gt; is an end-to-end encrypted mind mapping tool. Maps are encrypted in the browser with a key derived from the user's password; the server stores ciphertext and has never held a key.&lt;/p&gt;

&lt;p&gt;That design makes one feature genuinely hard: two people editing the same map at the same time. This is how we built it anyway, on Cloudflare, and the parts where the obvious approach had to be thrown away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the standard approach does not work
&lt;/h2&gt;

&lt;p&gt;For collaborative editing, the CRDT you reach for is &lt;a href="https://yjs.dev/" rel="noopener noreferrer"&gt;Yjs&lt;/a&gt;.&lt;br&gt;
Two people edit concurrently, both documents converge, no locks and no operational transform server. Yjs has ready-made backends — &lt;code&gt;y-websocket&lt;/code&gt;, and &lt;code&gt;y-durableobjects&lt;/code&gt; for Cloudflare specifically. Wire one up and you have collaboration in an afternoon.&lt;/p&gt;

&lt;p&gt;We could not use any of them, and the reason is one sentence in the Yjs sync protocol: &lt;strong&gt;the server computes state vectors and diffs.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When a client connects, the standard protocol has it send a state vector — a summary of what it already has — and the server replies with exactly the updates that client is missing. That is an efficient design and it requires the server to &lt;em&gt;parse the updates&lt;/em&gt;. A server that can parse Yjs updates is a server that can read the document.&lt;/p&gt;

&lt;p&gt;For most products that is fine. For this one it defeats the entire premise. So the sync protocol had to be replaced with one where the server understands nothing.&lt;/p&gt;
&lt;h2&gt;
  
  
  The protocol
&lt;/h2&gt;

&lt;p&gt;Every frame is one type byte and an opaque payload. The payload is AES-256-GCM&lt;br&gt;
ciphertext produced with a key the server has never seen.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UPDATE          client → room → everyone else   an encrypted Yjs update, appended to a log
UPDATE_TAGGED   room → everyone else            the same, with the sender's id in front
SNAPSHOT        client → room                   an encrypted consolidated state; replaces the log
AWARENESS       client → room → everyone else   cursors and selections; relayed, never stored
SYNC_REQUEST    client → room                   "I am listening, send me the log"
SYNCED          room → client                   "that is all of it"
RESYNC          room → client                   "somebody compacted; drop what you have"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The room appends &lt;code&gt;UPDATE&lt;/code&gt; payloads to a log and fans them out. It never merges anything, because it cannot. A client joining receives &lt;strong&gt;the entire log&lt;/strong&gt; and replays it locally; Yjs converges regardless of order, so this works, but the log obviously cannot grow forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compaction is therefore client-side.&lt;/strong&gt; Periodically a connected client encodes the whole document as one update, encrypts it, and sends &lt;code&gt;SNAPSHOT&lt;/code&gt;. The room replaces its log with that single blob in one transaction, keeps a copy as a checkpoint, and tells everyone else to resync. The server has performed a garbage collection on data it cannot read, by being told the answer.&lt;/p&gt;

&lt;p&gt;This is the central trade. The client does the reconciling that a normal collaborative server would do for you. In exchange the server is a dumb pipe that a breach would yield nothing useful from.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cloudflare parts, and why each one earns its place
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Durable Objects: one room per map
&lt;/h3&gt;

&lt;p&gt;A &lt;a href="https://developers.cloudflare.com/durable-objects/" rel="noopener noreferrer"&gt;Durable Object&lt;/a&gt; is a single-threaded, globally-unique, addressable object with its own storage. One per map, named by the map id:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;room&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MINDMAP_ROOM&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mapId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every client for a given map reaches the same instance, anywhere in the world. That single sentence removes an entire category of infrastructure: no Redis for fan-out, no lock service, no leader election, no sticky sessions. The room &lt;em&gt;is&lt;/em&gt; the coordination point, and it is the only one.&lt;/p&gt;

&lt;p&gt;The object holds its own SQLite database, so the encrypted update log lives with the object that serves it rather than in a shared database everything contends for.&lt;/p&gt;

&lt;h3&gt;
  
  
  WebSocket hibernation: idle rooms cost nothing
&lt;/h3&gt;

&lt;p&gt;The natural worry with an object-per-map is cost. A team leaves a map open over lunch and you are paying for an idle process for an hour.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://developers.cloudflare.com/durable-objects/best-practices/websockets/" rel="noopener noreferrer"&gt;Hibernation&lt;/a&gt;&lt;br&gt;
solves it: the runtime evicts the object from memory while the WebSockets stay open, and revives it on the next frame. You are billed for work, not for waiting.&lt;/p&gt;

&lt;p&gt;The catch is real and shapes the code: &lt;strong&gt;instance state does not survive hibernation.&lt;/strong&gt; Anything in a class field is gone between frames. So per-connection state lives on the socket's own attachment:&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="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;serializeAttachment&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;mapId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;colo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                             &lt;span class="na"&gt;windowStart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;framesInWindow&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Our per-connection frame budget lives there for exactly this reason. A rate limiter kept in a field would silently reset every time the room went to sleep — which is to say, it would not be a rate limiter.&lt;/p&gt;

&lt;h3&gt;
  
  
  D1: identity, membership, key envelopes
&lt;/h3&gt;

&lt;p&gt;Content is unreadable, but there is metadata a server necessarily has: who exists, who is a member of which map, and each member's &lt;strong&gt;wrapped key&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The map key is wrapped separately per member using a hybrid KEM — X25519 and ML-KEM-768 together, so a recording-now-decrypting-later attacker has to break both. D1 stores the envelope; only the member's own private key opens it, and that private key is itself wrapped under a key derived from their password with Argon2id.&lt;/p&gt;

&lt;h3&gt;
  
  
  R2: durability that needs no key
&lt;/h3&gt;

&lt;p&gt;The room copies its log to R2 hourly. There is nothing to encrypt on the way out — the log already is ciphertext, and the object has no key to add another layer with even if that helped.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rate Limiting, Analytics Engine, Cron
&lt;/h3&gt;

&lt;p&gt;Native rate limiting bindings on sign-in and invitations. Analytics Engine for delivery metrics — event names, ids, durations, never content. A nightly cron to purge soft-deleted maps.&lt;/p&gt;

&lt;p&gt;Each of these would be a service to run. Here they are four lines of config.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five things that were not obvious
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Sync has to be client-initiated.&lt;/strong&gt; The first version pushed the backlog the moment a socket opened. Across a service binding, frames sent before the client's own listeners are attached are simply lost. One extra round trip — &lt;code&gt;SYNC_REQUEST&lt;/code&gt; — removes the race entirely and lets the client decide when it is&lt;br&gt;
ready.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A Yjs type can be integrated in exactly one place.&lt;/strong&gt; Duplicating a node copied its fields, and one of those fields was a &lt;code&gt;Y.Text&lt;/code&gt; holding the node's notes. Setting the &lt;em&gt;source's own instance&lt;/em&gt; onto the copy produced a document that looked perfectly correct locally and an update that &lt;strong&gt;threw inside Yjs on every other machine.&lt;/strong&gt; The local editor was the one place the bug was invisible. The fix is&lt;br&gt;
one word — &lt;code&gt;value.clone()&lt;/code&gt; — and the lesson is the test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;remote&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Y&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Doc&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;Y&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;applyUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;remote&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Y&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encodeStateAsUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// must not throw&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Assert that an update still applies on a &lt;em&gt;peer&lt;/em&gt;, not just that your own document looks right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compaction that counts the wrong events never fires.&lt;/strong&gt; Ours triggered on updates &lt;em&gt;received&lt;/em&gt;. A room with one person in it receives nothing, so it never compacted, and a solo editing session grew its log without bound until somebody else happened to join. Own edits count now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A fixed backup key is not a backup.&lt;/strong&gt; Writing every hourly snapshot to &lt;code&gt;latest.log&lt;/code&gt; means a corrupted log is faithfully copied over the last good copy within the hour. Date the key, keep N, prune.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Authorization checked at connect is not checked again.&lt;/strong&gt; Removing a member deleted their row and their key envelope, and their still-open WebSocket carried on receiving every edit until they closed the tab. Removal has to close the socket explicitly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this architecture genuinely gives up
&lt;/h2&gt;

&lt;p&gt;Being honest about this is more useful than a list of wins.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Metadata is visible.&lt;/strong&gt; The server knows who is connected to which map, when, and how much traffic there is. It cannot see a title or a single character of content, but the shape of a collaboration is not hidden.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The client does more work.&lt;/strong&gt; Joining means replaying a log rather than receiving a computed diff. Compaction keeps that log short, but a client that joins a busy room does more than it would against a server that could merge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No server-side search, no server-side anything.&lt;/strong&gt; Every feature that would normally be "add an endpoint" is either a client feature or does not exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Removal is not retroactive.&lt;/strong&gt; No re-keying on member removal in v1. What someone already decrypted, they have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it worth it
&lt;/h2&gt;

&lt;p&gt;The feature is indistinguishable from an ordinary collaborative editor when you use it. Cursors move, text merges character by character, someone joins late and sees everything.&lt;/p&gt;

&lt;p&gt;The difference only shows when something goes wrong somewhere else. What an operator can hand over is what an operator holds, and here that is encrypted blocks and the knowledge that a session happened.&lt;/p&gt;

&lt;p&gt;Cloudflare's primitives are what made the trade affordable. A Durable Object per map with hibernation gave a single coordination point per document that costs nothing while idle — which is precisely the shape a per-document collaboration server wants, and precisely the thing that is tedious to build and operate yourself.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://www.mindmapvault.com" rel="noopener noreferrer"&gt;MindMapVault&lt;/a&gt; is an end-to-end encrypted mind mapping tool.&lt;br&gt;
Live collaboration is in testing and ships to Cloud accounts alongside SSO — &lt;a href="https://www.mindmapvault.com/blog/live-collaboration-the-server-cannot-read/" rel="noopener noreferrer"&gt;the&lt;br&gt;
announcement for people who use it, rather than build it&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>webdev</category>
      <category>architecture</category>
      <category>security</category>
    </item>
    <item>
      <title>One Backend, Every Screen: I Built a Cross-Platform Audio App the 'Wrong' Way</title>
      <dc:creator>Kornel Maraz</dc:creator>
      <pubDate>Fri, 04 Sep 2026 14:58:02 +0000</pubDate>
      <link>https://dev.to/kornel_maraz_5e66a3e4e27d/one-backend-every-screen-i-built-a-cross-platform-audio-app-the-wrong-way-28mf</link>
      <guid>https://dev.to/kornel_maraz_5e66a3e4e27d/one-backend-every-screen-i-built-a-cross-platform-audio-app-the-wrong-way-28mf</guid>
      <description>&lt;p&gt;Every "build in public" thread and every YouTube growth guru tells you the same thing: pick the smallest possible surface, ship the lowest-effort thing that gets the biggest effect, validate, iterate. It's good advice. For this project I did almost the exact opposite — on purpose — and I want to be honest about why, and what it actually cost.&lt;/p&gt;

&lt;p&gt;I'm building own.audio: a private home for the audiobooks, music, and podcasts a family already owns, playable on every device they actually use, with a real management story for the one person in the house who ends up being the admin. That product — &lt;em&gt;every device, plus admin&lt;/em&gt; — is the reason the low-effort path was the wrong path. This is the long way round, and I'd take it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The false start: a web app for a thing nobody uses on the web
&lt;/h2&gt;

&lt;p&gt;I started where a web developer starts: a backend in Rust with Axum, and a React front end on top of it. Clean, familiar, fast to move.&lt;/p&gt;

&lt;p&gt;Then it hit me that I was building the wrong surface. This whole product lives on &lt;strong&gt;phones and speakers&lt;/strong&gt; — music and audiobooks get consumed on a mobile device, or at least &lt;em&gt;controlled&lt;/em&gt; from one while they play on some speaker across the room. The web app was the thing I knew how to build, not the thing people would actually live in. And as I kept pushing features into the backend, the React front end just fell further and further behind. It was a tax I was paying for a surface that wasn't the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sprawl trap
&lt;/h2&gt;

&lt;p&gt;So I tried to do the sensible unified thing: one app that handled books,&lt;br&gt;
podcasts, and music together. But keeping that coherent turned into a mess — the backend started sprouting separate combinations for books, separate for podcasts, separate for music, and the whole thing got &lt;em&gt;bushy&lt;/em&gt;. Too many branches, too much special-casing, no clean spine. That wasn't the way either. I could feel it turning into the kind of codebase you dread opening.&lt;/p&gt;

&lt;p&gt;That's the moment I made the call that shaped everything after: &lt;strong&gt;do the&lt;br&gt;
thorough thing, not the clever-minimal thing&lt;/strong&gt; — because a family audio product that has to work on every screen and give the household admin real control doesn't have a lowest-effort version that's actually good.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mac first — the admin's control room
&lt;/h2&gt;

&lt;p&gt;I started with the &lt;strong&gt;Mac app&lt;/strong&gt;, and starting there was deliberate. It's the control room for the person who manages the family's library: a place to play everything — music, audiobooks, playlists, all of it — and, crucially, to &lt;em&gt;manage&lt;/em&gt; it.&lt;/p&gt;

&lt;p&gt;That management story is where the interesting work was. The Mac app is also a &lt;strong&gt;bridge to the self-hosted world&lt;/strong&gt;: it connects to Navidrome and Audiobookshelf — play from them, download from them, and upload into the cloud — so someone who has been running their own server, and is tired of it, has a real path to move their collection over instead of starting from zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hard part: the sync client
&lt;/h2&gt;

&lt;p&gt;The centerpiece of the Mac app, and the single hardest thing I've built for this project, is the &lt;strong&gt;sync client&lt;/strong&gt;. The home admin can sync the entire collection on their own terms, and "on their own terms" is doing a lot of work in that sentence. It meant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bandwidth throttling&lt;/strong&gt;, so syncing a large library doesn't strangle the   household's connection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resumable uploads&lt;/strong&gt;, so a transfer picks up where it left off instead of starting over.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Robustness across reconnects&lt;/strong&gt; — the whole thing hardened so that when the connection drops and comes back, it recovers and keeps going rather than giving up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On top of transfer, it had to &lt;em&gt;understand&lt;/em&gt; the files: &lt;strong&gt;metadata lookup&lt;/strong&gt; for music, &lt;strong&gt;identification&lt;/strong&gt; for audiobooks, &lt;strong&gt;artist images pulled from Wikipedia&lt;/strong&gt; (with proper sourcing), and finally the AI pieces — narrating a book, or translating a podcast episode into a narrated version. None of that is glamorous, and all of it is the difference between "a folder of files" and "a library."&lt;/p&gt;

&lt;p&gt;Here's the honest bit: sync was miserable until it wasn't. Once it actually worked — reliably, resumably, politely — &lt;strong&gt;everything after it was a joy.&lt;/strong&gt; The hard foundation is what made the rest fun.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why backend-first paid off
&lt;/h2&gt;

&lt;p&gt;I didn't touch iOS until the Mac client was done and &lt;em&gt;every&lt;/em&gt; feature — including full offline playback — worked against the Rust/Axum backend. That patience was the whole point. Because when I finally started the iPhone apps, I could &lt;strong&gt;reuse the shared backend code I'd already written for the Mac app.&lt;/strong&gt; The iOS apps ride on the same backend brain. The thorough, slow foundation turned the second platform from "build it again" into "put a native face on what already works."&lt;/p&gt;

&lt;h2&gt;
  
  
  One brain, different faces — on purpose
&lt;/h2&gt;

&lt;p&gt;This is the design decision I'm most sure about: the shared backend is one brain, but &lt;strong&gt;each app wears a different face, and it should.&lt;/strong&gt; &lt;br&gt;
You don't listen to music the way you listen to an audiobook, so the apps don't pretend you do. I ended up building &lt;strong&gt;two entirely different kinds of player&lt;/strong&gt;: one for podcasts and audiobooks, and a separate one for music — because music needs FLAC and lossless playback, an advanced equalizer, and crossfade that mixes the last five seconds of one track into the first five of the next. That's not a skin on the same player; it's a different player. Across the three audio apps — books, podcasts, music — the interface genuinely differs, and only the brand ties them together.&lt;/p&gt;

&lt;p&gt;That's the opposite of forcing one uniform UI everywhere, and I think it's the honest choice: consistent identity, deliberately native and content-appropriate interfaces.&lt;/p&gt;

&lt;h2&gt;
  
  
  iPad and iOS: where the platform gives back
&lt;/h2&gt;

&lt;p&gt;Extending to &lt;strong&gt;iPad&lt;/strong&gt; was, thanks to SwiftUI and native components, mostly a matter of fine-tuning details rather than rebuilding. And the advanced testing and automation you get in the Swift world genuinely make this a pleasure — for a developer, it's a treat. Apple's platform &lt;em&gt;gives back&lt;/em&gt; the effort you put into learning it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Android: everything works, and I don't love it
&lt;/h2&gt;

&lt;p&gt;Then Android. And I'll be candid, because pretending otherwise helps no one: the fragmentation — of the libraries and of the language itself — is its own extra adventure. Building and compiling feel, to me, a couple of generations behind iOS. Everything &lt;em&gt;can&lt;/em&gt; be done, it just takes longer, and I don't enjoy it as much.&lt;/p&gt;

&lt;p&gt;Keeping the design consistent with iOS using standard libraries is genuinely hard, and I refused to go into exotic-library territory to force it. So the Android UI ends up different from the iOS one. I don't love that either — but I believe Android users will appreciate what they get in exchange: &lt;strong&gt;reliability, native libraries, and consistency with the other Android apps already on their phone.&lt;/strong&gt; Fighting the platform to match iOS pixel-for-pixel would have served my sense of tidiness, not the person holding the phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The chapter that wrote itself: cars and voice assistants
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;CarPlay and Android Auto&lt;/strong&gt; deserve their own chapter, except the chapter is short: Apple and Google have caught this so beautifully that the developer does almost nothing. The same goes for &lt;strong&gt;Siri and Gemini&lt;/strong&gt;. Every so often the platform just hands you a hard problem, already solved. Good job, both of you — genuinely.&lt;/p&gt;

&lt;h2&gt;
  
  
  And then the web app was easy
&lt;/h2&gt;

&lt;p&gt;Having built all of that, I came back around to a &lt;strong&gt;React app on the Rust&lt;br&gt;
backend&lt;/strong&gt; — and after everything else, it was basically child's play. The surface I started with, and abandoned as the wrong first move, became the easy last one once the brain behind it was solid.&lt;/p&gt;

&lt;p&gt;That's where I stand now. The plan is to get it into production before the end of the year.&lt;/p&gt;

&lt;h2&gt;
  
  
  Would I do it the "wrong" way again?
&lt;/h2&gt;

&lt;p&gt;Yes. For a lot of products, the guru advice is right — find the smallest surface, ship it, learn. But when the product's &lt;em&gt;whole promise&lt;/em&gt; is "your audio, on every screen your family uses, managed properly," there isn't a lowest-effort version of that promise worth shipping. The thorough path — one shared Rust backend, a Mac control room first, a hard-won sync client, and native, deliberately different faces on every platform — wasn't the efficient way to a demo. It was the only honest way to the actual product.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm building &lt;a href="https://www.own.audio/" rel="noopener noreferrer"&gt;own.audio&lt;/a&gt; solo — a private home for the audiobooks, music, and podcasts you actually own. It's pre-launch; I'm aiming to ship before year-end, and writing the build up as I go.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>rust</category>
      <category>mobile</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Translating any podcast episode for about a dollar: the architecture, and the one rule that shaped it</title>
      <dc:creator>Kornel Maraz</dc:creator>
      <pubDate>Thu, 03 Sep 2026 17:05:15 +0000</pubDate>
      <link>https://dev.to/kornel_maraz_5e66a3e4e27d/translating-any-podcast-episode-for-about-a-dollar-the-architecture-and-the-one-rule-that-shaped-81a</link>
      <guid>https://dev.to/kornel_maraz_5e66a3e4e27d/translating-any-podcast-episode-for-about-a-dollar-the-architecture-and-the-one-rule-that-shaped-81a</guid>
      <description>&lt;p&gt;A friend of mine doesn't speak English. I do most of my podcast listening in English. Every so often I hit an episode worth passing on — an interview, some strange little history segment — and I want to send it to her, and I can't. A summary isn't the episode. A translated transcript isn't something you listen to on a walk.&lt;/p&gt;

&lt;p&gt;It turned out I'd been sitting on the pieces to fix this for months. This is the architecture I landed on, and the one design rule that shaped everything else. No code — just the shape of the thing and the decisions that mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  The insight: it wasn't a new feature
&lt;/h2&gt;

&lt;p&gt;I'm building a personal audio library as a side project. One thing already shipped was &lt;strong&gt;AI-narrated audiobooks&lt;/strong&gt;: take a manuscript, translate it if needed, run it through a neural text-to-speech voice, stitch the result into a real audio file. Two managed services doing the heavy lifting — machine translation on one side, neural TTS on the other — with my own orchestration in between.&lt;/p&gt;

&lt;p&gt;A podcast episode is, structurally, &lt;strong&gt;a much shorter audiobook&lt;/strong&gt;. That's the whole thing. I didn't need to build "podcast translation." I needed to point a pipeline I'd already written at a different kind of input.&lt;/p&gt;

&lt;p&gt;That reframing is the most useful architectural move I know: before building a feature, check whether it's a new &lt;em&gt;shape&lt;/em&gt; or just a new &lt;em&gt;input&lt;/em&gt; to a shape you already have. This was the second.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline, end to end
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Podcast RSS feed
      │
      ▼
 Find &amp;lt;podcast:transcript&amp;gt;  ──►  no transcript?  ──►  feature not offered
      │                                                (hard stop — see below)
      ▼
 Fetch the podcaster's own transcript text
      │
      ▼
 Machine translation  (per-character, managed service)
      │
      ▼
 Neural TTS  (per-character, managed voice)
      │
      ▼
 Stitch → single playable audio file
      │
      ▼
 Store in the requester's own library (household-scoped)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything interesting is in two places: the &lt;strong&gt;hard stop&lt;/strong&gt; near the top, and the &lt;strong&gt;scope&lt;/strong&gt; at the bottom.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one rule: never run speech-to-text
&lt;/h2&gt;

&lt;p&gt;The obvious architecture is: transcribe the audio with speech-to-text, translate that, narrate the result. I deliberately did &lt;strong&gt;not&lt;/strong&gt; do that, and it's the single decision I actually agonized over.&lt;/p&gt;

&lt;p&gt;Speech-to-text makes mistakes, and translation &lt;strong&gt;compounds&lt;/strong&gt; them. A mis-heard word becomes a mistranslated sentence becomes a confidently narrated line that is simply wrong — with nothing in the output pointing back at where it broke. For a toy, that's an annoyance. For something a person might rely on to understand what someone actually said, it's a trust problem, and it gets worse the more the feature is used.&lt;/p&gt;

&lt;p&gt;So the rule is: &lt;strong&gt;this only works on episodes that publish a transcript.&lt;/strong&gt; Podcasting 2.0 added a &lt;code&gt;&amp;lt;podcast:transcript&amp;gt;&lt;/code&gt; tag to the RSS spec a few years ago, and a meaningful and growing minority of shows already attach one. If a transcript exists, I translate the podcaster's &lt;em&gt;own published words&lt;/em&gt;. If it doesn't, the feature isn't offered for that episode. No guessing, ever.&lt;/p&gt;

&lt;p&gt;Architecturally this is a &lt;strong&gt;quality gate placed at the input, not the output.&lt;/strong&gt; Instead of building elaborate confidence-scoring and error-handling downstream to cope with bad STT, I refuse the bad input at the door. The system can only be asked to do the thing it can do well. That single constraint deleted an entire category of downstream complexity — and it's why I trust the output enough to ship it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost model, and why it's quoted up front
&lt;/h2&gt;

&lt;p&gt;Managed translation and TTS both bill per character, so the unit economics are legible: cost is translation-per-character plus narration-per-character, marked up enough to be sustainable, and &lt;strong&gt;capped so it never exceeds the up-front quote.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A real measurement, not a round marketing number: one episode of a (faintly ridiculous) British royal-gossip podcast that happened to be in my test feed — 8,570 characters of transcript, English to German — landed at &lt;strong&gt;90 cents&lt;/strong&gt;. A typical 5–15 minute episode comes out around a dollar.&lt;/p&gt;

&lt;p&gt;The architectural point: because the price is derived from the transcript length &lt;em&gt;before&lt;/em&gt; any paid API call runs, I can &lt;strong&gt;show the user the exact cost and get consent before spending anything.&lt;/strong&gt; No surprise bills, no "estimated." The quote is the cap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Latency, measured honestly
&lt;/h2&gt;

&lt;p&gt;I timed it from the database row's own timestamps rather than a stopwatch, because I wanted a number I could stand behind: a &lt;strong&gt;9-minute episode, translated, narrated, and assembled into a playable file, in 93 seconds.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That number changes the UX category. At 93 seconds, "translate this episode" stops being a batch job you plan around and becomes something you kick off while still looking at the episode list.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boundary is part of the design
&lt;/h2&gt;

&lt;p&gt;Two scope decisions are deliberately baked into the architecture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's one voice.&lt;/strong&gt; Host, guest, narrator — all the same TTS voice. It won't catch tone shifts, and niche names occasionally come out wrong. It is not a dub and was never meant to be.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's strictly personal.&lt;/strong&gt; The translated audio is a derivative work of someone else's content, generated on request, and it stays in the requester's own library — scoped to their household, not redistributed to the world. (An early version had a scoping bug that locked it to just the one requester; the fix widened it to the household, which is the correct boundary — not wider.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That personal scope isn't a limitation I'm apologizing for; it's the line that makes the feature defensible to build at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take to the next feature
&lt;/h2&gt;

&lt;p&gt;Four things generalize well beyond this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;New input, not new system.&lt;/strong&gt; The cheapest powerful feature is an existing pipeline pointed somewhere new. Look for that before building.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put the quality gate at the input.&lt;/strong&gt; Refusing bad input is often simpler and more trustworthy than tolerating it downstream. "We don't do that case" beats "we do it badly."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Derive the price before you spend.&lt;/strong&gt; If you can quote the cost up front from cheap inputs, you can get real consent and cap the risk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure the real numbers.&lt;/strong&gt; "A few minutes" is a hand-wave; "93 seconds, from the timestamps" is something you can design a UX around.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result is what I've started calling an &lt;strong&gt;80% solution, on purpose&lt;/strong&gt;: one voice, transcript-only, personal-scope. If the alternative is &lt;em&gt;not understanding the episode at all&lt;/em&gt;, 80% is a genuinely good outcome — and it's the difference between hearing something today and never hearing it, because nobody commissions a real translation for one podcast episode.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about building &lt;a href="https://www.own.audio/blog/hear-any-podcast-episode-in-your-language/" rel="noopener noreferrer"&gt;own.audio&lt;/a&gt;, a personal library for audiobooks, music, and podcasts you actually own. It's a pre-launch side project; the fuller write-up of this feature, with screenshots, is on the blog. The product-design side of this same decision — why shipping something deliberately imperfect was the right call — is a separate piece.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>sideprojects</category>
    </item>
    <item>
      <title>What maintaining react dependencies costs</title>
      <dc:creator>Kornel Maraz</dc:creator>
      <pubDate>Sat, 08 Aug 2026 15:11:59 +0000</pubDate>
      <link>https://dev.to/kornel_maraz_5e66a3e4e27d/what-maintaining-react-dependencies-costs-1844</link>
      <guid>https://dev.to/kornel_maraz_5e66a3e4e27d/what-maintaining-react-dependencies-costs-1844</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Serious software doesn’t collapse because of a missing feature.&lt;br&gt;
It collapses because someone skipped a year of boring dependency maintenance.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A CI job called &lt;strong&gt;Security Guard&lt;/strong&gt; went red this week. It runs &lt;code&gt;pnpm audit&lt;/code&gt; on every push, and it had found seven vulnerabilities in one of our frontends. That sounds like a small problem. It took most of a working day to close properly, and the shape of that day is worth describing, because it is the recurring cost of building on the React ecosystem and almost nobody budgets for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the audit actually said
&lt;/h2&gt;

&lt;p&gt;The first surprise was that the failing job was not the whole picture. The report I was looking at came from our admin frontend and listed seven findings. The main application, audited the same way, had &lt;strong&gt;thirty&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Thirty sounds alarming until you split them by where they live:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Build toolchain&lt;/strong&gt;: &lt;code&gt;vite&lt;/code&gt;, &lt;code&gt;postcss&lt;/code&gt;, &lt;code&gt;nanoid&lt;/code&gt;, &lt;code&gt;@babel/core&lt;/code&gt;, &lt;code&gt;brace-expansion&lt;/code&gt;, &lt;code&gt;fast-uri&lt;/code&gt;. These run on my machine and in CI. They never reach a user's browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runtime&lt;/strong&gt;: &lt;code&gt;dompurify&lt;/code&gt;, &lt;code&gt;pdfjs-dist&lt;/code&gt;, &lt;code&gt;react-router&lt;/code&gt;. These are compiled into the bundle that every user downloads and executes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That distinction matters more than the severity labels. &lt;code&gt;pnpm audit&lt;/code&gt; reported four "high" findings in the build toolchain and ten "low" and "moderate" ones in &lt;code&gt;dompurify&lt;/code&gt;. But &lt;code&gt;dompurify&lt;/code&gt; is the HTML sanitizer we use when rendering notes. A sanitizer bypass in a note-taking application is cross-site scripting in the user's own content. Ten moderate findings there deserved more of my attention than four high ones in a dev server I run locally.&lt;/p&gt;

&lt;p&gt;An audit tool cannot make that judgement for you. It does not know which packages you ship. That first triage is human work, and it is the part that decides whether the rest of the day is spent well.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap of upgrading to latest
&lt;/h2&gt;

&lt;p&gt;My first instinct was the obvious one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm up vite@latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That moved Vite from 6.4.2 to 8.2.1. The build still passed, which was almost the worst outcome, because it made the change look safe. It was not: the patched version I actually needed was &lt;strong&gt;6.4.3&lt;/strong&gt;. I had accepted two major versions of behavioural change to fix a security advisory, and I would have found out what broke somewhere later, in a context with no obvious connection to this afternoon.&lt;/p&gt;

&lt;p&gt;I reverted it and moved to minimum patched versions instead. Slower, and much easier to reason about when something misbehaves next month.&lt;/p&gt;

&lt;p&gt;The same trap caught me again a step later. &lt;code&gt;@vitejs/plugin-react@latest&lt;/code&gt; resolved to 6.0.5, which declares &lt;code&gt;vite@^8.0.0&lt;/code&gt; as a peer dependency. With Vite pinned back to 6.4.3, the install produced an unmet peer warning. The fix was version archaeology: version 5.2.0 of the plugin supports &lt;code&gt;vite@^4 || ^5 || ^6 || ^7 || ^8&lt;/code&gt;. Not the newest, but the one that fits the rest of the tree.&lt;/p&gt;

&lt;p&gt;This is the actual work. Not "upgrade the packages" but "find the combination of versions that are simultaneously patched, mutually compatible, and stable for a while".&lt;/p&gt;

&lt;h2&gt;
  
  
  The migration that was not a migration
&lt;/h2&gt;

&lt;p&gt;The finding I dreaded was &lt;code&gt;react-router&lt;/code&gt;. The advisory said the patched version was &lt;code&gt;&amp;gt;=7.18.0&lt;/code&gt;, and we were on 6.30.4. There is no patched release in the 6 line, so the only route to a clean audit was a major version upgrade — the kind of change that usually means a day of testing on its own.&lt;/p&gt;

&lt;p&gt;Two checks turned that around in about ten minutes.&lt;/p&gt;

&lt;p&gt;First, the peer dependencies of &lt;code&gt;react-router@7&lt;/code&gt;:&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="err"&gt;react:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'&amp;gt;=&lt;/span&gt;&lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'react-dom':&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'&amp;gt;=&lt;/span&gt;&lt;span class="mi"&gt;18&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;React 7 does not require React 19. We could stay on 18.3.1. No React upgrade, no ripple through every component.&lt;/p&gt;

&lt;p&gt;Second, our own codebase already contained the answer. Our open source desktop build had been moved to &lt;code&gt;react-router-dom@7&lt;/code&gt; earlier, and it still imports from the same package name with the same API surface:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BrowserRouter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Navigate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Routes&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-router-dom&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All twelve call sites in the application use &lt;code&gt;BrowserRouter&lt;/code&gt;, &lt;code&gt;Routes&lt;/code&gt;, &lt;code&gt;Route&lt;/code&gt;, &lt;code&gt;Navigate&lt;/code&gt;, &lt;code&gt;Link&lt;/code&gt;, &lt;code&gt;useNavigate&lt;/code&gt;, &lt;code&gt;useParams&lt;/code&gt;, &lt;code&gt;useSearchParams&lt;/code&gt; and &lt;code&gt;Outlet&lt;/code&gt;. Every one of those is unchanged between v6 and v7. The "migration" was a single line in &lt;code&gt;package.json&lt;/code&gt;. Type check passed, build passed, three advisories disappeared.&lt;/p&gt;

&lt;p&gt;I want to be honest about the lesson here, because it cuts both ways. The upgrade was easy &lt;strong&gt;because we had done it once already elsewhere and because we had never reached for the exotic parts of the router&lt;/strong&gt;. Projects that use data loaders, deferred routes or the framework mode would have had a real migration. Restraint in what you adopt is what makes upgrades cheap later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dependencies you never chose
&lt;/h2&gt;

&lt;p&gt;That left ten findings in packages nobody on the team had ever typed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.&amp;gt;vite-plugin-pwa&amp;gt;workbox-build&amp;gt;glob&amp;gt;minimatch&amp;gt;brace-expansion
.&amp;gt;vite-plugin-pwa&amp;gt;workbox-build&amp;gt;ajv&amp;gt;fast-uri
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You cannot upgrade these directly. You either wait for the maintainers above them to release, or you override the resolution yourself. We already had a small &lt;code&gt;pnpm.overrides&lt;/code&gt; block for exactly this, so I extended it.&lt;/p&gt;

&lt;p&gt;The subtlety is in the ranges. &lt;code&gt;brace-expansion&lt;/code&gt; had &lt;strong&gt;two&lt;/strong&gt; versions installed at once, 2.1.0 and 5.0.6, needed by different parts of the tree. A blanket override to the newest would have forced 5.x onto packages written against 1.x. The override has to name the range:&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="nl"&gt;"brace-expansion@&amp;lt;2.1.4"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.1.4"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"brace-expansion@&amp;gt;=5.0.0 &amp;lt;5.0.9"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"5.0.9"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"fast-uri@&amp;gt;=3.0.0 &amp;lt;3.1.5"&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.1.5"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Overrides are a debt of their own. Each one is a note saying "I know better than the resolver", and each needs removing when the upstream package catches up. They should be narrow and few.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ledger
&lt;/h2&gt;

&lt;p&gt;At the end of it: thirty findings to zero in the main app, seven to zero in the admin app, both verified the way CI does it, with &lt;code&gt;rm -rf node_modules&lt;/code&gt; and a frozen lockfile install. Type check and build green in both.&lt;/p&gt;

&lt;p&gt;Time spent: roughly half a day, most of it reading advisories, checking peer dependency tables and undoing one upgrade that went too far.&lt;/p&gt;

&lt;p&gt;And here is the part I keep coming back to. Almost everything in that day was given to me for free. I did not write a router, a PDF renderer, an HTML sanitizer or a build tool. Someone else maintains all of them. Someone else runs the infrastructure that discovered these vulnerabilities, wrote them up, assigned severities and published them in a machine-readable form that my CI could consume without me configuring anything. The advisory database, the audit tooling, the packages themselves — all of it arrives at no cost.&lt;/p&gt;

&lt;p&gt;What is not free is the fit. The ecosystem hands you a pile of excellent, independently maintained parts, and the work of holding a particular combination of them together, on your project, on a version of React you have chosen, remains yours. It is not hard work. It is not even especially interesting work. But it is real, it does not go away, and it lands about once a quarter.&lt;/p&gt;

&lt;p&gt;Half a day every three months is a genuinely good deal for what you get back. It stops being a good deal the moment you decide it is not work, skip it for a year, and then face twelve months of accumulated major versions in a single sitting — usually because something has already gone wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  It could have gone worse, and elsewhere it has
&lt;/h2&gt;

&lt;p&gt;I should be careful not to make this sound universal, because the same job on our Rust backend is open on my machine as I write this, and it is not going to be an install command.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cargo audit&lt;/code&gt; flagged &lt;code&gt;quick-xml&lt;/code&gt; (RUSTSEC-2026-0195 and RUSTSEC-2026-0194, both denial of service) and &lt;code&gt;rgb&lt;/code&gt;. We do not depend on any of them. The chain is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pprof (feature "flamegraph") → inferno → quick-xml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We use &lt;code&gt;pprof&lt;/code&gt; to profile the backend. Its &lt;code&gt;flamegraph&lt;/code&gt; feature pulls in &lt;code&gt;inferno&lt;/code&gt; to render the SVG, and &lt;code&gt;inferno&lt;/code&gt; uses an XML writer to produce it. The vulnerability is three levels below anything we chose, in a component whose only job is writing the tags of a picture.&lt;/p&gt;

&lt;p&gt;There is no version to bump here. &lt;code&gt;quick-xml&lt;/code&gt; had not published a fix we could take, and the crate was only present because of one feature flag. So the fix was to stop pulling that branch of the tree in at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="py"&gt;-pprof&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0.15"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;features&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"flamegraph"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="py"&gt;+pprof&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0.15"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;features&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"protobuf-codec"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The version did not change. Nine crates left the lockfile. And that is where the cost showed up, because the feature does not just add a renderer, it decides which API exists. &lt;code&gt;report.flamegraph(&amp;amp;mut file)&lt;/code&gt; was simply gone. The profiler now hands back a protobuf message that you serialise yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;File&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/tmp/backend-flamegraph.svg"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="o"&gt;-&lt;/span&gt;    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="nf"&gt;.flamegraph&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="nf"&gt;.pprof&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="nf"&gt;.write_to_bytes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;File&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/tmp/backend-profile.pb"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt;            &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="nf"&gt;.write_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eleven lines rewritten, the imports changed, and the output is no longer an SVG I can open in a browser. It is a &lt;code&gt;.pb&lt;/code&gt; file for &lt;code&gt;go tool pprof&lt;/code&gt; or speedscope. A working habit changed because of a denial-of-service bug in an XML writer we never asked for.&lt;/p&gt;

&lt;p&gt;That is the difference in one picture. On the JavaScript side, closing thirty findings meant editing a manifest and choosing versions carefully. On the Rust side, closing three meant reading someone else's feature flags, understanding why a crate was there at all, and then rewriting our own code and changing how we profile.&lt;/p&gt;

&lt;p&gt;The compiler does at least tell you. In JavaScript the upgrade usually installs cleanly and the surprise arrives later, at runtime, in front of a user. In Rust it arrives immediately, as a wall of red text, and nothing ships until it is dealt with. I would rather have the wall of red text. But it is not half a day, and it is worth knowing which of the two you are signing up for.&lt;/p&gt;

&lt;p&gt;Put it in the calendar. Ninety minutes of triage, deciding what actually ships to users. A couple of hours of finding versions that agree with each other. A clean install and a real build to prove it. Then close the laptop and go back to the product.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Mobile-First Mind Mapping in 2026: PWA, Dark Mode, and Leaving Java Desktop Behind</title>
      <dc:creator>Kornel Maraz</dc:creator>
      <pubDate>Sat, 11 Jul 2026 17:20:13 +0000</pubDate>
      <link>https://dev.to/kornel_maraz_5e66a3e4e27d/mobile-first-mind-mapping-in-2026-pwa-dark-mode-and-leaving-java-desktop-behind-2m0j</link>
      <guid>https://dev.to/kornel_maraz_5e66a3e4e27d/mobile-first-mind-mapping-in-2026-pwa-dark-mode-and-leaving-java-desktop-behind-2m0j</guid>
      <description>&lt;p&gt;The first mind mapping tool I ever used was FreeMind. I wrote about this in &lt;a href="https://dev.to/kornel_maraz_5e66a3e4e27d/privacy-first-mind-mapping-app-part-0-motivations-and-mind-maps-4k0m"&gt;Part 0&lt;/a&gt; — it left a strong impression. Keyboard-first, offline, fast. But it ran on Java, on a desktop, and only on a desktop.&lt;/p&gt;

&lt;p&gt;In 2026, that is not enough.&lt;/p&gt;

&lt;p&gt;This part is about how MindMapVault handles the shift from desktop to everywhere. The mobile experience, the PWA implementation, the dark/light mode, and what we learned from building it with React rather than reaching for a native mobile SDK.&lt;/p&gt;




&lt;h2&gt;
  
  
  The honest picture first
&lt;/h2&gt;

&lt;p&gt;Desktop is not dead. If you work on a large mind map for an hour, you want a big screen and a real keyboard. That has not changed.&lt;/p&gt;

&lt;p&gt;But the way people use apps has changed. The majority of internet usage is now on mobile. For productivity apps specifically, the pattern is: think on the train, elaborate on the laptop, review on the phone, act wherever. A product that only exists on a desktop loses people at every gap.&lt;/p&gt;

&lt;p&gt;FreeMind and Freeplane — tools I have genuine respect for — have no mobile story. They are Java desktop applications. You cannot install them on an iPhone. You cannot open your maps on an Android device. The &lt;code&gt;.mm&lt;/code&gt; file on your laptop is invisible to your phone unless you sync the file manually to some cloud folder, and even then you have no editor to open it.&lt;/p&gt;

&lt;p&gt;WiseMapping solved the desktop part — it is browser-based — but it has no PWA and no mobile-optimized layout. It also runs server-readable (the database holds plaintext maps), which is a different kind of problem.&lt;/p&gt;

&lt;p&gt;The requirement we set for MindMapVault: &lt;strong&gt;it has to work, without compromise, on desktop and mobile, with the same encryption model on every platform.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Why PWA, not native apps
&lt;/h2&gt;

&lt;p&gt;The first question in most mobile discussions is: should you build native apps? App Store listing, native UI components, push notifications, the whole ecosystem.&lt;/p&gt;

&lt;p&gt;Our answer was no, not yet, and here is why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PWA solves the real gap for most users.&lt;/strong&gt; The main friction is not "I need a native feel." It is "I need to be able to open my mind map on my phone." A Progressive Web App — installable from the browser with "Add to Home Screen" — solves that completely. No store review. No 200 MB download. No update lag.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;App stores add overhead that does not benefit the user.&lt;/strong&gt; Every release goes through a review queue. Approval can take days. You cannot ship a critical encryption fix on a Friday afternoon without waiting. With a PWA served from your own infrastructure, the user gets the update the next time the service worker refreshes — typically within 24 hours, silently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The codebase stays unified.&lt;/strong&gt; One React codebase, one set of components, one encryption implementation. The alternative — maintaining a web app, an iOS app, and an Android app — would triple the surface where encryption bugs could live. For a security-sensitive product, that is a serious risk.&lt;/p&gt;




&lt;h2&gt;
  
  
  The PWA implementation
&lt;/h2&gt;

&lt;p&gt;MindMapVault's frontend is built with React 18, TypeScript, Vite 6, and Tailwind CSS. The PWA layer uses &lt;strong&gt;vite-plugin-pwa&lt;/strong&gt; and &lt;strong&gt;Workbox&lt;/strong&gt;.&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="c1"&gt;// main.tsx — PWA registration&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;registerSW&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;virtual:pwa-register&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;registerSW&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one line, plus the vite-plugin-pwa configuration, handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Generating the &lt;code&gt;manifest.webmanifest&lt;/code&gt; with the correct app name, icons, and display mode&lt;/li&gt;
&lt;li&gt;Generating a &lt;code&gt;sw.js&lt;/code&gt; service worker via Workbox&lt;/li&gt;
&lt;li&gt;Caching the app shell, assets, and pre-cached routes&lt;/li&gt;
&lt;li&gt;Background sync and update checking&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;manifest.webmanifest&lt;/code&gt; controls how the app appears after installation: name, icon, theme color, whether it opens with a browser chrome or full-screen. Users who tap "Add to Home Screen" on iOS Safari or Chrome on Android get a full-screen experience identical to a native app.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Workbox does for you
&lt;/h3&gt;

&lt;p&gt;Workbox is Google's service worker library. It handles the parts that are genuinely complex to do by hand:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Precaching&lt;/strong&gt; the built assets (JS bundles, CSS, icons) with content hashes. The service worker knows exactly which files are stale after a new build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runtime caching&lt;/strong&gt; for API calls and dynamic resources.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Background sync&lt;/strong&gt; so actions that happen offline are retried when connectivity returns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Update flow&lt;/strong&gt; notifying the running app when a new service worker is ready to activate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a security-sensitive app, one thing matters: the service worker should &lt;strong&gt;not&lt;/strong&gt; cache authentication credentials or decryption keys. The MindMapVault encryption model already handles this correctly — the server only ever sends ciphertext, and decryption happens entirely in JavaScript. No sensitive plaintext ever passes through the service worker cache.&lt;/p&gt;




&lt;h2&gt;
  
  
  Dark/light mode — the right way
&lt;/h2&gt;

&lt;p&gt;Every modern application in 2026 must support dark and light mode. This is not aesthetic preference. It is accessibility and system integration.&lt;/p&gt;

&lt;p&gt;Here is how we implement it:&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="c1"&gt;// main.tsx — applied synchronously before first render&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;applyStoredTheme&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mindmapvault-theme&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;raw&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;light&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;light&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ignore */&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;This runs before &lt;code&gt;createRoot&lt;/code&gt; — before React renders anything. That is the key. If you apply the theme inside a React effect or even a component, you get a &lt;strong&gt;Flash of Unstyled Content&lt;/strong&gt; (FOUC): the page loads dark, flickers white, then goes dark again. That is jarring on any platform and is particularly bad on mobile OLED screens where the flash literally causes a brightness spike.&lt;/p&gt;

&lt;p&gt;The pattern: read from &lt;code&gt;localStorage&lt;/code&gt;, synchronously apply a class to &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt;, then let React render with the correct theme already set.&lt;/p&gt;

&lt;p&gt;The CSS side uses class-based switching:&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;/* Default: dark */&lt;/span&gt;
&lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--bg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#0f172a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#f8fafc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* Light override */&lt;/span&gt;
&lt;span class="nd"&gt;:root&lt;/span&gt;&lt;span class="nc"&gt;.light&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--bg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#f8fafc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#0f172a&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;This cooperates naturally with Tailwind: dark utilities (&lt;code&gt;dark:bg-slate-900&lt;/code&gt;) work alongside the class-based approach via Tailwind's &lt;code&gt;darkMode: 'class'&lt;/code&gt; config.&lt;/p&gt;

&lt;p&gt;The user can also set a custom accent color, which is applied the same way — as a CSS custom property set synchronously before render.&lt;/p&gt;




&lt;h2&gt;
  
  
  Responsive layout with Tailwind
&lt;/h2&gt;

&lt;p&gt;The mind map canvas itself is &lt;code&gt;@xyflow/react&lt;/code&gt; — a React component for node-based interactive diagrams. It handles pan and zoom natively on both mouse (wheel + drag) and touch (pinch + drag). No extra touch event handling needed for the canvas.&lt;/p&gt;

&lt;p&gt;The surrounding UI — vault list, toolbar, settings, node editing panels — is built with Tailwind responsive utilities:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Sidebar: full width on mobile, fixed width on desktop --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;aside&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"w-full md:w-64 lg:w-72"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  ...
&lt;span class="nt"&gt;&amp;lt;/aside&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;window.matchMedia('(max-width: 900px)')&lt;/code&gt; check we use in the FOSS demo (to adjust the initial canvas zoom and pan position for small screens) is the one place where we make a JavaScript decision based on screen size rather than a CSS decision. On a phone, the initial canvas view zooms out and centers so the root node and first level of branches are visible without scrolling.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this means for product builders in 2026
&lt;/h2&gt;

&lt;p&gt;A Java desktop application in 2026 is not wrong — it is just narrow. It reaches exactly one kind of user in exactly one context.&lt;/p&gt;

&lt;p&gt;The React + PWA stack we describe here reaches the same user on their desktop, their phone, and their tablet, with the same code, the same encryption, and the same zero-knowledge model. The overhead of maintaining one additional layer (vite-plugin-pwa + Workbox) is low. The reach multiplier is real.&lt;/p&gt;

&lt;p&gt;The concrete lesson:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ship PWA before native apps.&lt;/strong&gt; It covers 90% of the mobile use case with 10% of the cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apply dark/light mode synchronously, before first render.&lt;/strong&gt; FOUC is a worse experience on mobile than on desktop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not cache sensitive data in the service worker.&lt;/strong&gt; Review what Workbox precaches and what it does at runtime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let the responsive CSS framework handle most of the layout adaptation.&lt;/strong&gt; Reserve JavaScript media query checks for canvas-specific adjustments.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Desktop is not dead; it just cannot be the only target anymore.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&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%2F29uzpery3dnebaswpl5a.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%2F29uzpery3dnebaswpl5a.png" alt=" " width="579" height="1260"&gt;&lt;/a&gt;&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%2Fqs8pxk97zg2qi055hy5q.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%2Fqs8pxk97zg2qi055hy5q.png" alt=" " width="800" height="464"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;MindMapVault is a privacy-first mind mapping application with zero-knowledge encryption. Try it at  &lt;a href="https://www.mindmapvault.com" rel="noopener noreferrer"&gt;https://www.mindmapvault.com&lt;/a&gt;.&lt;br&gt;
The FOSS offline only edition is open-source at &lt;a href="https://github.com/mindmapvault/mindmapvault-foss" rel="noopener noreferrer"&gt;https://github.com/mindmapvault/mindmapvault-foss&lt;/a&gt;&lt;br&gt;
Server self hosted edition is open sourced at &lt;a href="https://github.com/mindmapvault/mindmapvault-server" rel="noopener noreferrer"&gt;https://github.com/mindmapvault/mindmapvault-server&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Want to try the mobile canvas on your phone right now? Open&lt;br&gt;
&lt;a href="https://mindmapvault.github.io/mindmapvault-foss/mobile-demo/" rel="noopener noreferrer"&gt;https://mindmapvault.github.io/mindmapvault-foss/mobile-demo/&lt;/a&gt;&lt;br&gt;
 - no install, no account, works as a PWA.&lt;/p&gt;

</description>
      <category>ux</category>
      <category>pwa</category>
      <category>mobile</category>
      <category>ui</category>
    </item>
    <item>
      <title>Modernize or Fade Away: A Builder's Field Guide to Core Rewrites</title>
      <dc:creator>Kornel Maraz</dc:creator>
      <pubDate>Sat, 30 May 2026 08:43:16 +0000</pubDate>
      <link>https://dev.to/kornel_maraz_5e66a3e4e27d/modernize-or-fade-away-a-builders-field-guide-to-core-rewrites-me</link>
      <guid>https://dev.to/kornel_maraz_5e66a3e4e27d/modernize-or-fade-away-a-builders-field-guide-to-core-rewrites-me</guid>
      <description>&lt;p&gt;If you have worked on a long-lived product, you probably know this feeling:&lt;/p&gt;

&lt;p&gt;"We can still ship, but every release hurts more than the last one."&lt;/p&gt;

&lt;p&gt;That is usually not a team problem. It is a core architecture problem.&lt;/p&gt;

&lt;p&gt;At some point, modernization stops being a nice-to-have refactor and becomes a survival decision. Not because rewrites are fun. Because the current system keeps multiplying cost in every direction: infra, feature delivery, onboarding, incident response, and roadmap confidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pattern You Have Seen Before
&lt;/h2&gt;

&lt;p&gt;Across open source and enterprise software, the same sequence repeats:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The product grows feature by feature and accumulates &lt;strong&gt;architecture debt&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The core becomes a risk hotspot where simple changes require heavy ceremony.&lt;/li&gt;
&lt;li&gt;New requirements arrive: scale, integrations, cloud-native ops, lower latency.&lt;/li&gt;
&lt;li&gt;The old core can handle them, but only by adding expensive layers.&lt;/li&gt;
&lt;li&gt;A cleaner alternative appears and moves faster with fewer people.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Postponing modernization does not avoid cost. It delays and compounds it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nextcloud as a Real-World Example
&lt;/h2&gt;

&lt;p&gt;Nextcloud is successful, useful, and widely adopted. That is exactly why it is a useful case study.&lt;/p&gt;

&lt;p&gt;It also carries the weight of a mature PHP monolith, strict backward compatibility pressure, and assumptions built for older infrastructure eras (POSIX-first storage, WebDAV-centricity, heavier metadata paths).&lt;/p&gt;

&lt;p&gt;The project has mostly evolved by adding layers instead of replacing core boundaries. That is understandable, but it creates a familiar tradeoff:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Short term&lt;/strong&gt;: feature continuity and ecosystem stability.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Medium term&lt;/strong&gt;: rising operational complexity and slower delivery.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long term&lt;/strong&gt;: exposure to competitors built around modern primitives from day one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is not "monolith bad, microservices good." It is about architecture-requirement mismatch over time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "Not Modernizing" Really Costs
&lt;/h2&gt;

&lt;p&gt;Avoiding deep core work can look roadmap-friendly, but the bill shows up everywhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ops overhead&lt;/strong&gt;: more RAM, more sidecars, more tuning, more runbooks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dev velocity drag&lt;/strong&gt;: each feature collides with legacy constraints and migration baggage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integration friction&lt;/strong&gt;: modern API/event/object-storage patterns are awkward to bolt on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UX degradation&lt;/strong&gt;: latency and responsiveness drift under real traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strategic risk&lt;/strong&gt;: enterprise buyers compare TCO and reliability, not nostalgia.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If roadmap items keep being split, delayed, or overstaffed because of architecture constraints, that is your modernization signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  RAM Comparison Snapshot
&lt;/h2&gt;

&lt;p&gt;Below is a practical comparison for small production deployments. Numbers are representative ranges from real setups; exact values vary by workload and enabled modules.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Component&lt;/th&gt;
      &lt;th&gt;Nextcloud Typical Small Deployment&lt;/th&gt;
      &lt;th&gt;ownCloud Infinite Scale Typical Small Deployment&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;th&gt;Web / PHP processes&lt;/th&gt;
      &lt;td&gt;500–1500 MB&lt;/td&gt;
      &lt;td&gt;40–80 MB&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;th&gt;Database server&lt;/th&gt;
      &lt;td&gt;300–800 MB&lt;/td&gt;
      &lt;td&gt;not required (KV stores / embedded)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;th&gt;Cache (Redis)&lt;/th&gt;
      &lt;td&gt;50–200 MB&lt;/td&gt;
      &lt;td&gt;optional, low&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;th&gt;Background jobs / cron&lt;/th&gt;
      &lt;td&gt;50–150 MB&lt;/td&gt;
      &lt;td&gt;30–60 MB per service&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;th&gt;Storage service (S3/MinIO)&lt;/th&gt;
      &lt;td&gt;external; variable&lt;/td&gt;
      &lt;td&gt;100–300 MB for MinIO&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;th&gt;Total small deployment&lt;/th&gt;
      &lt;td&gt;~1.2–3 GB RAM&lt;/td&gt;
      &lt;td&gt;~0.25–0.6 GB RAM&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; core architecture decisions directly shape hosting cost, performance predictability, and scaling headroom.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Practical Modernization Playbook
&lt;/h2&gt;

&lt;p&gt;You do not need a big-bang rewrite to modernize seriously. You need staged execution and explicit boundaries.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Replace one core boundary first&lt;/strong&gt;
Pick the area with the highest pain and lowest coupling (for example sync path, metadata indexer, storage adapter).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put compatibility ahead of replacement&lt;/strong&gt;
Keep old and new paths running in parallel while migrating traffic gradually.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instrument before and after&lt;/strong&gt;
Track p95 latency, memory per tenant/user, error rates, and lead time to production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design migration as product work&lt;/strong&gt;
Build migration tooling with rollback checkpoints and observable progress.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invest in dev ergonomics early&lt;/strong&gt;
Cleaner runtime architecture that is harder to work on will still fail adoption internally.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Five Rewrite Triggers to Watch
&lt;/h2&gt;

&lt;p&gt;If several of these persist for 2+ quarters, you are likely beyond "incremental cleanup":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Infra spend grows faster than user value delivered.&lt;/li&gt;
&lt;li&gt;"Simple" features repeatedly need cross-team firefighting.&lt;/li&gt;
&lt;li&gt;Performance regressions return after partial fixes.&lt;/li&gt;
&lt;li&gt;New engineers need months to safely touch core areas.&lt;/li&gt;
&lt;li&gt;Competitive gap is architecture-driven, not feature-driven.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Product Angle Most Teams Miss
&lt;/h2&gt;

&lt;p&gt;Modernization is not only engineering hygiene. It is a trust and product decision.&lt;/p&gt;

&lt;p&gt;Users feel architecture through:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;speed,&lt;/li&gt;
&lt;li&gt;reliability,&lt;/li&gt;
&lt;li&gt;migration friction,&lt;/li&gt;
&lt;li&gt;and confidence the product will still be viable in 3–5 years.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If modernization is framed as cleanup, it is deprioritized.&lt;br&gt;
If it is framed as delivery capacity + reliability + user trust, it gets funded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Note
&lt;/h2&gt;

&lt;p&gt;The market rarely kills products overnight. It slowly rewards teams that modernize before they are forced to.&lt;/p&gt;

&lt;p&gt;Modernize early enough and you keep optionality.&lt;br&gt;
Modernize too late and the rewrite still happens, just under pressure.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>softwareengineering</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>Part 8: Free Self-Hosted Mind Mapping for a Home Lab: Why I Put a Significant Part of MindMapVault on GitHub</title>
      <dc:creator>Kornel Maraz</dc:creator>
      <pubDate>Fri, 29 May 2026 20:07:55 +0000</pubDate>
      <link>https://dev.to/kornel_maraz_5e66a3e4e27d/part-8-free-self-hosted-mind-mapping-for-a-home-lab-why-i-put-a-significant-part-of-mindmapvault-3mfh</link>
      <guid>https://dev.to/kornel_maraz_5e66a3e4e27d/part-8-free-self-hosted-mind-mapping-for-a-home-lab-why-i-put-a-significant-part-of-mindmapvault-3mfh</guid>
      <description>&lt;p&gt;When people say they build a privacy-first product, I ask one simple question:&lt;/p&gt;

&lt;p&gt;How much of the implementation story is publicly verifiable?&lt;/p&gt;

&lt;p&gt;For MindMapVault, I decided to put a significant part of the project in public repositories because I do not want trust to depend only on polished copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Short background
&lt;/h2&gt;

&lt;p&gt;Since my university studies, free and open-source software shaped how I learn and build.&lt;/p&gt;

&lt;p&gt;I learned from maintainers I never met. I used products I could inspect, fork, and improve. That openness gave me a practical education I could never get from closed products.&lt;/p&gt;

&lt;p&gt;Open sourcing this project is also a thank-you to that ecosystem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is important for private-thinking software
&lt;/h2&gt;

&lt;p&gt;Mind maps are not harmless doodles.&lt;/p&gt;

&lt;p&gt;They often contain strategy, unfinished research, priorities, and personal structure. If a product claims privacy, users should be able to evaluate whether boundaries are real.&lt;/p&gt;

&lt;p&gt;Open repositories allow people to inspect:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;architecture decisions&lt;/li&gt;
&lt;li&gt;release history&lt;/li&gt;
&lt;li&gt;deployment options&lt;/li&gt;
&lt;li&gt;public issue and maintenance patterns&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That does not guarantee perfection. But it does remove the black box.&lt;/p&gt;

&lt;p&gt;For developers and operators, this also answers a practical question: can you run a self-hosted mind mapping app for free in a home lab and still evaluate the trust boundaries? In this case, yes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Public links
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Repositories
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;MindMapVault FOSS: &lt;a href="https://github.com/mindmapvault/mindmapvault-foss" rel="noopener noreferrer"&gt;https://github.com/mindmapvault/mindmapvault-foss&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;MindMapVault Server: &lt;a href="https://github.com/mindmapvault/mindmapvault-server" rel="noopener noreferrer"&gt;https://github.com/mindmapvault/mindmapvault-server&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Container images
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;GitHub Container Registry (MindMapVault Server): &lt;a href="https://github.com/mindmapvault/mindmapvault-server/pkgs/container/mindmapvault-server" rel="noopener noreferrer"&gt;https://github.com/mindmapvault/mindmapvault-server/pkgs/container/mindmapvault-server&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docker Hub image: &lt;a href="https://hub.docker.com/r/kornelko2/mindmapvault-server" rel="noopener noreferrer"&gt;https://hub.docker.com/r/kornelko2/mindmapvault-server&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why home lab users care about this
&lt;/h2&gt;

&lt;p&gt;When you run a free self-hosted mind mapping app in a home lab, you are not only looking for features.&lt;/p&gt;

&lt;p&gt;You are also checking:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;can I inspect the code path I am deploying?&lt;/li&gt;
&lt;li&gt;can I follow release history and rollout changes safely?&lt;/li&gt;
&lt;li&gt;can I keep infrastructure ownership without hidden lock-in?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Open-source repositories and public image links make those checks possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical outcomes of building in public
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Better engineering hygiene&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Public changes force clearer commits, documentation, and compatibility handling.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Better technical conversations&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Feedback can be concrete because people can reference real files and real history.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Better trust posture&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Users and teams can evaluate what is implemented instead of trusting only messaging.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Better self-hosted operations&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Home lab and self-hosted users can review deployment docs, compare tags, and plan upgrades with fewer surprises.&lt;/p&gt;

&lt;h2&gt;
  
  
  What open source is not
&lt;/h2&gt;

&lt;p&gt;Open source is not a license to expose secrets.&lt;/p&gt;

&lt;p&gt;We keep a hard boundary between transparent implementation and private operational data. No private keys, tokens, or user data belong in public code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;I want this project to be understandable to people who care enough to inspect it.&lt;/p&gt;

&lt;p&gt;Not because it is perfect, but because trust grows faster when code, decisions, and trade-offs are visible.&lt;/p&gt;

&lt;p&gt;If that resonates, check the repositories, open issues, and challenge assumptions.&lt;/p&gt;

&lt;p&gt;If your specific goal is a free self-hosted mind mapping app for your home lab, start with the public repos and container image links above, then verify the architecture against your own privacy requirements.&lt;/p&gt;

&lt;p&gt;That is the kind of software culture I learned from, and the one I want to contribute back to.&lt;/p&gt;

</description>
      <category>github</category>
      <category>opensource</category>
      <category>privacy</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Privacy-first mind mapping app. Part 7: Owning Your Workflow</title>
      <dc:creator>Kornel Maraz</dc:creator>
      <pubDate>Mon, 11 May 2026 19:07:21 +0000</pubDate>
      <link>https://dev.to/kornel_maraz_5e66a3e4e27d/privacy-first-mind-mapping-app-part-6-owning-your-workflow-26ha</link>
      <guid>https://dev.to/kornel_maraz_5e66a3e4e27d/privacy-first-mind-mapping-app-part-6-owning-your-workflow-26ha</guid>
      <description>&lt;p&gt;Most posts about self‑hosting or indie projects drift into marketing speak. This one doesn’t. This is simply how I build and ship MindMapVault — the real workflow, the real tools, and the real reasons behind them.&lt;/p&gt;

&lt;p&gt;This chapter is about ownership. Not in the corporate sense, but in the “I want to understand and control my own tools” sense. If you’re building something solo, or experimenting with automation, or trying to level up your confidence as a developer, this might resonate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I Self‑Host My Git Forge
&lt;/h2&gt;

&lt;p&gt;I run a self‑hosted &lt;a href="https://forgejo.org/" rel="noopener noreferrer"&gt;Forgejo&lt;/a&gt; instance on my NAS with a runner. Not because GitHub or GitLab are bad — they’re great — but because I know myself.&lt;/p&gt;

&lt;p&gt;Sometimes I move fast.&lt;br&gt;&lt;br&gt;
Sometimes I forget things.&lt;br&gt;&lt;br&gt;
Sometimes I push &lt;code&gt;.env&lt;/code&gt; values straight into a repo.&lt;/p&gt;

&lt;p&gt;Keeping the forge and runner physically close to me solves that. It gives me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;control&lt;/strong&gt; (no accidental secrets leaving my network)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;convenience&lt;/strong&gt; (fast CI, predictable environment)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;confidence&lt;/strong&gt; (I can experiment without worrying about leaking something)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And honestly? I just like the project. Forgejo is clean, lightweight, and fits the “local‑first” philosophy behind MindMapVault.&lt;/p&gt;
&lt;h3&gt;
  
  
  What I use it for
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;another Git remote&lt;/li&gt;
&lt;li&gt;CI/CD workflows&lt;/li&gt;
&lt;li&gt;automated checks&lt;/li&gt;
&lt;li&gt;release builds&lt;/li&gt;
&lt;li&gt;experiments that I don’t want to run on a public cloud&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It’s not about paranoia. It’s about craftsmanship. When you own the forge, you own the workflow.&lt;/p&gt;
&lt;h2&gt;
  
  
  Automation Makes You a Better Developer (Especially Solo)
&lt;/h2&gt;

&lt;p&gt;When you’re working alone, automation isn’t a luxury — it’s a survival mechanism.&lt;/p&gt;

&lt;p&gt;My Forgejo runner handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;type‑checking&lt;/li&gt;
&lt;li&gt;offline‑parity checks&lt;/li&gt;
&lt;li&gt;desktop build pipelines&lt;/li&gt;
&lt;li&gt;release asset uploads&lt;/li&gt;
&lt;li&gt;version bumping&lt;/li&gt;
&lt;li&gt;changelog generation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This means I can focus on the actual product instead of the glue.&lt;/p&gt;

&lt;p&gt;And here’s the interesting part:&lt;br&gt;
AI tools like GitHub Copilot Agents changed the way I commit.&lt;/p&gt;

&lt;p&gt;Not by writing code for me — but by making me more disciplined:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;commits became larger but more coherent&lt;/li&gt;
&lt;li&gt;changelogs became more structured&lt;/li&gt;
&lt;li&gt;documentation became essential, not optional&lt;/li&gt;
&lt;li&gt;project planning became a real process, not a TODO file&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When AI helps you move faster, the meta‑work (planning, documenting, structuring) becomes even more important. Otherwise you drown in your own velocity.&lt;/p&gt;
&lt;h2&gt;
  
  
  Production Hosting: Simple, Predictable, Mine
&lt;/h2&gt;

&lt;p&gt;I host production on a Czech community VPS: &lt;a href="https://vpsfree.cz/" rel="noopener noreferrer"&gt;vpsFree.cz&lt;/a&gt;. It’s stable, affordable, and gives me the right balance of control and simplicity.&lt;/p&gt;

&lt;p&gt;The setup is intentionally minimal:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;backend (Rust)&lt;/li&gt;
&lt;li&gt;Postgres&lt;/li&gt;
&lt;li&gt;MinIO&lt;/li&gt;
&lt;li&gt;Cloudflare Tunnel&lt;/li&gt;
&lt;li&gt;Cloudflare Pages for the frontend&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All running via a single &lt;code&gt;docker-compose.yml&lt;/code&gt;, all credentials in a single .env file. when you are ready zyou just run &lt;code&gt;docker compose up -d&lt;/code&gt; and in 6 seconds the new version, fix is up in production.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ─────────────────────────────────────────────────────────────────────────────&lt;/span&gt;
&lt;span class="c1"&gt;# MindMapVault — local dev compose&lt;/span&gt;
&lt;span class="c1"&gt;# Manages the backend plus the local RustFS and PostgreSQL dependencies.&lt;/span&gt;
&lt;span class="c1"&gt;# Use `docker compose up` / `docker compose up --build`.&lt;/span&gt;
&lt;span class="c1"&gt;# ─────────────────────────────────────────────────────────────────────────────&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;minio&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;minio/minio:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mindmapvault-minio&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;cryptmind&lt;/span&gt;
    &lt;span class="na"&gt;ulimits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;nofile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;soft&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;65535&lt;/span&gt;
        &lt;span class="na"&gt;hard&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;65535&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;server /data --console-address ":9001"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;MINIO_ROOT_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${MINIO_ACCESS_KEY:-minioadmin}&lt;/span&gt;
      &lt;span class="na"&gt;MINIO_ROOT_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${MINIO_SECRET_KEY:-minioadmin}&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${MINIO_API_PORT:-9000}:9000"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${MINIO_CONSOLE_PORT:-9001}:9001"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;minio-data:/data&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curl&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-fsS&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;http://127.0.0.1:9000/minio/health/live&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;gt;/dev/null&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;||&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;exit&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;

  &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mindmapvault-postgres&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;cryptmind&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${POSTGRES_PORT:-5432}:5432"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${POSTGRES_USER:-postgres}&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${POSTGRES_PASSWORD:-postgres}&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${POSTGRES_DB:-cryptmind}&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;postgres-data:/var/lib/postgresql/data&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;${POSTGRES_USER:-postgres}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;${POSTGRES_DB:-cryptmind}"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;

  &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
      &lt;span class="na"&gt;dockerfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;backend/Dockerfile.local&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mindmapvault-backend&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;cryptmind&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;minio&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
      &lt;span class="na"&gt;mongodb&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
      &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${BACKEND_PORT:-8090}:8090"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;stoolap-data:/data&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.0.0.0"&lt;/span&gt;
      &lt;span class="na"&gt;PORT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8090"&lt;/span&gt;
      &lt;span class="na"&gt;MALLOC_ARENA_MAX&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2"&lt;/span&gt;
      &lt;span class="na"&gt;MALLOC_TRIM_THRESHOLD_&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;131072"&lt;/span&gt;
      &lt;span class="na"&gt;MALLOC_MMAP_THRESHOLD_&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;131072"&lt;/span&gt;
      &lt;span class="na"&gt;MALLOC_CONF&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${MALLOC_CONF:-background_thread:true,narenas:1,dirty_decay_ms:0,muzzy_decay_ms:0,metadata_thp:disabled}&lt;/span&gt;
      &lt;span class="na"&gt;DB_ENGINE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_ENGINE:-mongodb}&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DSN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-cryptmind}&lt;/span&gt;
      &lt;span class="na"&gt;MINIO_ENDPOINT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://minio:9000&lt;/span&gt;
      &lt;span class="na"&gt;MINIO_ACCESS_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${MINIO_ACCESS_KEY:-minioadmin}&lt;/span&gt;
      &lt;span class="na"&gt;MINIO_SECRET_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${MINIO_SECRET_KEY:-minioadmin}&lt;/span&gt;
      &lt;span class="na"&gt;MINIO_BUCKET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${MINIO_BUCKET:-mindmapvault-maps}&lt;/span&gt;
      &lt;span class="na"&gt;MINIO_PUBLIC_ENDPOINT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${MINIO_PUBLIC_ENDPOINT:-http://127.0.0.1:9000}&lt;/span&gt;
      &lt;span class="na"&gt;MINIO_REGION&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${MINIO_REGION:-us-east-1}&lt;/span&gt;
      &lt;span class="na"&gt;MINIO_PRESIGN_EXPIRY_SECS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${MINIO_PRESIGN_EXPIRY_SECS:-3600}&lt;/span&gt;
      &lt;span class="na"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${JWT_SECRET}&lt;/span&gt;
      &lt;span class="na"&gt;JWT_ACCESS_EXPIRY_SECS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${JWT_ACCESS_EXPIRY_SECS:-900}&lt;/span&gt;
      &lt;span class="na"&gt;JWT_REFRESH_EXPIRY_SECS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${JWT_REFRESH_EXPIRY_SECS:-2592000}&lt;/span&gt;
      &lt;span class="na"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${STRIPE_SECRET_KEY:-}&lt;/span&gt;
      &lt;span class="na"&gt;STRIPE_PUBLISHABLE_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${STRIPE_PUBLISHABLE_KEY:-}&lt;/span&gt;
      &lt;span class="na"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${STRIPE_WEBHOOK_SECRET:-}&lt;/span&gt;
      &lt;span class="na"&gt;STRIPE_PRICE_PAID_YEARLY_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${STRIPE_PRICE_PAID_YEARLY_ID:-}&lt;/span&gt;
      &lt;span class="na"&gt;STRIPE_CHECKOUT_SUCCESS_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${STRIPE_CHECKOUT_SUCCESS_URL:-https://mindmapvault.com/vaults}&lt;/span&gt;
      &lt;span class="na"&gt;STRIPE_CHECKOUT_CANCEL_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${STRIPE_CHECKOUT_CANCEL_URL:-https://mindmapvault.com/vaults}&lt;/span&gt;
      &lt;span class="na"&gt;STRIPE_PORTAL_RETURN_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${STRIPE_PORTAL_RETURN_URL:-https://mindmapvault.com/vaults}&lt;/span&gt;
      &lt;span class="na"&gt;DISABLE_APP_LOGIN_TURNSTILE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DISABLE_APP_LOGIN_TURNSTILE:-false}&lt;/span&gt;
      &lt;span class="na"&gt;ENABLE_DIAGNOSTICS_ROUTES&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${ENABLE_DIAGNOSTICS_ROUTES:-false}&lt;/span&gt;
      &lt;span class="na"&gt;ENABLE_PPROF&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${ENABLE_PPROF:-false}&lt;/span&gt;
      &lt;span class="na"&gt;PPROF_DURATION_SECS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${PPROF_DURATION_SECS:-90}&lt;/span&gt;
      &lt;span class="na"&gt;CORS_ALLOWED_ORIGINS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${CORS_ALLOWED_ORIGINS:-http://localhost:5173,http://tauri.localhost,https://tauri.localhost}&lt;/span&gt;
      &lt;span class="na"&gt;RUST_LOG&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${RUST_LOG:-backend=info,tower_http=info}&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curl&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-sf&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;http://localhost:8090/health&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;||&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;exit&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;

&lt;span class="na"&gt;cloudflared&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cloudflare/cloudflared:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mindmapvault-cloudflared&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tunnel --no-autoupdate run&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;TUNNEL_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${TUNNEL_TOKEN}&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;mindmapvault-network&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;minio-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cryptmind-minio-data&lt;/span&gt;
  &lt;span class="na"&gt;postgres-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cryptmind-postgres-data&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;cryptmind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cryptmind-network&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;then the docker ps looks like this:&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="go"&gt;CONTAINER ID   NAME                       CPU %     MEM USAGE / LIMIT   MEM %     NET I/O           BLOCK I/O   PIDS
468768ef5c79   mindmapvault-backend       0.00%     31.55MiB / 4GiB     0.77%     26.7MB / 4.73MB   0B / 0B     9
06c14906524a   mindmapvault-postgres      0.00%     31.82MiB / 4GiB     0.78%     2.17MB / 28.7MB   0B / 0B     7
d52f573bcdef   mindmapvault-minio         3.68%     86.56MiB / 4GiB     2.11%     3.65MB / 10.7MB   0B / 0B     13
27fcb47af8c7   mindmapvault-cloudflared   0.19%     16.31MiB / 4GiB     0.40%     132MB / 212MB     0B / 0B     13
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No Azure.&lt;br&gt;&lt;br&gt;
No Firebase.&lt;br&gt;&lt;br&gt;
No vendor lock‑in.&lt;/p&gt;

&lt;p&gt;Just a clean, reproducible, auditable setup.&lt;/p&gt;
&lt;h2&gt;
  
  
  Open‑Sourcing the Local‑First Version
&lt;/h2&gt;

&lt;p&gt;And when I have what to show then I pushed the FOSS version of MindMapVault here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/mindmapvault/mindmapvault-foss" rel="noopener noreferrer"&gt;https://github.com/mindmapvault/mindmapvault-foss&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It’s a local‑first, privacy‑focused, offline‑capable mind‑mapping desktop app.  &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No cloud.&lt;/li&gt;
&lt;li&gt;No telemetry.&lt;/li&gt;
&lt;li&gt;No account.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you review the code and see weak spots, please comment directly. I welcome criticism — it’s the only way a solo project grows.&lt;/p&gt;
&lt;h2&gt;
  
  
  GitHub Wiki &amp;amp; Pages — Free Tools Many Developers Underuse
&lt;/h2&gt;

&lt;p&gt;The GitHub &lt;a href="https://github.com/mindmapvault/mindmapvault-foss/wiki" rel="noopener noreferrer"&gt;Wiki&lt;/a&gt; is a perfect home for these research notes once they mature.&lt;br&gt;
It’s Markdown-native, versioned, and acts as a clean documentation hub.&lt;br&gt;
I even use it as a lightweight blog for deeper technical posts.&lt;br&gt;
GitHub &lt;a href="https://mindmapvault.github.io/mindmapvault-foss/demo/" rel="noopener noreferrer"&gt;Pages&lt;/a&gt; hosts the interactive MindMapVault demo — free, fast, and frictionless.&lt;br&gt;
Together, Wiki + Pages + Actions + runners make GitHub a full publishing and documentation platform.&lt;/p&gt;

&lt;p&gt;What this gives you for free:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Wiki for architecture docs&lt;/li&gt;
&lt;li&gt;Pages for interactive JS demos&lt;/li&gt;
&lt;li&gt;Actions for CI&lt;/li&gt;
&lt;li&gt;Runners for automation&lt;/li&gt;
&lt;li&gt;A complete developer ecosystem&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Research First — Write It Down Before You Build
&lt;/h2&gt;

&lt;p&gt;Before implementing any feature, I create a small Markdown mockup in research/ or notes/.&lt;br&gt;
It’s not a spec — just a quick outline of goals, constraints, and what “good” looks like.&lt;br&gt;
This keeps the project coherent and gives Copilot the right context.&lt;br&gt;
Even a rough sketch prevents future architectural mistakes.&lt;br&gt;
For MindMapVault, every complex feature starts as a simple .md file.&lt;/p&gt;

&lt;p&gt;Why this helps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;clarifies goals early&lt;/li&gt;
&lt;li&gt;reduces rework&lt;/li&gt;
&lt;li&gt;gives Copilot better context&lt;/li&gt;
&lt;li&gt;documents decisions&lt;/li&gt;
&lt;li&gt;keeps ideas discoverable
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Goals
- Allow arbitrary file attachments (many per map) without transferring plaintext to server in encrypted mode.
- Use per-map buckets or per-map prefixes to keep attachments discoverable and scannable.
- Lazy-download UX: file blobs are fetched only when the user requests/downloads them.
- Provide secure presigned URLs for direct client upload/download while the server maintains metadata, access control, and versioning.

High-level design
- Storage layout (S3/MinIO recommended):
  - Bucket-per-map: `cryptmind-&amp;lt;map-id&amp;gt;` OR
  - Single bucket + prefix: `maps/&amp;lt;map-id&amp;gt;/attachments/&amp;lt;attachment-uuid&amp;gt;` (preferred if many buckets are undesirable)
- Object keys: `attachments/&amp;lt;attachment-uuid&amp;gt;/&amp;lt;sanitized-filename&amp;gt;` or `attachments/&amp;lt;timestamp&amp;gt;-&amp;lt;uuid&amp;gt;-&amp;lt;name&amp;gt;`
- Versioning: enable S3 versioning where possible; store `s3_version_id` in DB on complete.

Security &amp;amp; encryption
- Encrypted mode (server never sees plaintext or DEKs):
  - Client-side: encrypt attachment (AES-GCM or AES-SIV) on the client using the same client-side DEK management as mindmaps.
  - Send `encrypted: true` and minimal `encryption_metadata` (algorithm, non-secret IV/nonce, tag if detached) to server when initiating upload. Do NOT send DEKs.
  - Server returns presigned upload URL or accepts proxied upload; backend stores opaque object and writes metadata to DB.
  - On download, server issues presigned GET; client downloads then decrypts locally.
- Plaintext mode: files uploaded without encryption; server may perform virus scanning or content checks per policy.

Database schema (maybe)
- attachments
  - id: UUID (PK)
  - map_id: UUID (FK)
  - node_id: UUID (nullable) — attach to a node/note if applicable
  - name: TEXT (original filename)
  - sanitized_name: TEXT
  - content_type: TEXT
  - size_bytes: INTEGER
  - s3_key: TEXT
  - s3_version_id: TEXT (nullable)
  - uploaded_by: UUID (user id)
  - uploaded_at: TIMESTAMP
  - encrypted: BOOLEAN
  - encryption_meta: JSON (algorithm, iv, tag — non-secret values only)
  - checksum_sha256: TEXT (optional)
  - status: ENUM('pending','available','deleted')
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Example: A Real Changelog Entry
&lt;/h2&gt;

&lt;p&gt;This is what a typical release looks like now — structured, explicit, and automation‑friendly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;**[0.3.27] – 2026‑05‑03**

**Changed**
- Editor UX / Node Icons — Restored full node icon workflow in `MindMapEditor.tsx`:
  - toolbar icon picker
  - context‑menu icon action
  - keyboard shortcut `I`
  - inline icon rendering
  - multi‑select icon toggling
  - help panel + status bar hints
- Editor Components — Added reusable icon picker infrastructure (`MindMapIconPicker.tsx`, `DynamicLucideIcon.tsx`).
- CI / Release Automation — Modernized `.github/workflows/desktop-build.yml`:
  - upgraded actions
  - replaced deprecated upload steps
  - switched to Corepack for `pnpm`
  - kept `$GITHUB_OUTPUT` usage

**Validation**
- `pnpm exec tsc --noEmit` → clean
- `node scripts/check_frontend_offline_parity.mjs` → passed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the kind of detail that makes automation reliable and future‑you grateful.&lt;/p&gt;

</description>
      <category>git</category>
      <category>webdev</category>
      <category>devjournal</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Try the MindMapVault FOSS Interactive Canvas (Privacy‑First, Local‑Only Mind Mapping)</title>
      <dc:creator>Kornel Maraz</dc:creator>
      <pubDate>Wed, 06 May 2026 07:01:00 +0000</pubDate>
      <link>https://dev.to/kornel_maraz_5e66a3e4e27d/try-the-mindmapvault-foss-interactive-canvas-privacy-first-local-only-mind-mapping-52c2</link>
      <guid>https://dev.to/kornel_maraz_5e66a3e4e27d/try-the-mindmapvault-foss-interactive-canvas-privacy-first-local-only-mind-mapping-52c2</guid>
      <description>&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%2Fwww.mindmapvault.com%2Fblog%2Fmindmapvault-foss-demo-teaser.gif" 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%2Fwww.mindmapvault.com%2Fblog%2Fmindmapvault-foss-demo-teaser.gif" alt=" " width="560" height="235"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;MindMapVault FOSS is a privacy‑first, local‑only mind‑mapping app with a minimalist canvas and zero cloud dependencies. No accounts, no telemetry, no lock‑in.&lt;/p&gt;

&lt;p&gt;This is a standalone post focused purely on the tool and your hands‑on experience.&lt;/p&gt;

&lt;p&gt;This is a direct invitation from an engineer building in public: open the live demo, test it hard, and tell me what you think.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the interactive demo
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Interactive canvas demo: &lt;a href="https://mindmapvault.github.io/mindmapvault-foss/demo/" rel="noopener noreferrer"&gt;https://mindmapvault.github.io/mindmapvault-foss/demo/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Project website: &lt;a href="https://www.mindmapvault.com/" rel="noopener noreferrer"&gt;https://www.mindmapvault.com/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Full FOSS repository: &lt;a href="https://github.com/mindmapvault/mindmapvault-foss" rel="noopener noreferrer"&gt;https://github.com/mindmapvault/mindmapvault-foss&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why I am sharing this now
&lt;/h2&gt;

&lt;p&gt;I wanted to ship a teaser that people can click and try immediately.&lt;/p&gt;

&lt;p&gt;No fake screenshots. No slide deck. No polished sales theater.&lt;/p&gt;

&lt;p&gt;Just the real UI running in the browser, so you can feel how the app behaves and judge it by yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Full naked FOSS, by design
&lt;/h2&gt;

&lt;p&gt;MindMapVault FOSS is built with a simple idea: keep it open, local-first, and privacy-first.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;full open-source codebase&lt;/li&gt;
&lt;li&gt;no hidden lock-in logic&lt;/li&gt;
&lt;li&gt;no fake "community" shell over closed internals&lt;/li&gt;
&lt;li&gt;practical architecture shaped by real engineering constraints&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The interactive demo is not a mockup — it’s the real thing. What you see and feel there is exactly how the app behaves in everyday use.&lt;/p&gt;

&lt;h2&gt;
  
  
  I want your real feedback
&lt;/h2&gt;

&lt;p&gt;If something is confusing, slow, ugly, or annoying, tell me directly.&lt;/p&gt;

&lt;p&gt;If something feels good, tell me that too.&lt;/p&gt;

&lt;p&gt;I am sharing this for real community feedback, not only compliments.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What works?&lt;/li&gt;
&lt;li&gt;What breaks your flow?&lt;/li&gt;
&lt;li&gt;What should be simpler?&lt;/li&gt;
&lt;li&gt;What should be faster?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  If you like it, please support the project
&lt;/h2&gt;

&lt;p&gt;If the idea makes sense to you and you want to support independent privacy-first FOSS work:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Try the demo.&lt;/li&gt;
&lt;li&gt;Share your thoughts and critiques.&lt;/li&gt;
&lt;li&gt;Give the repo a star if you think it deserves it: &lt;a href="https://github.com/mindmapvault/mindmapvault-foss" rel="noopener noreferrer"&gt;https://github.com/mindmapvault/mindmapvault-foss&lt;/a&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That signal helps more than people think.&lt;/p&gt;

&lt;p&gt;Thanks for checking it out and helping shape it in public.&lt;/p&gt;

</description>
      <category>devjournal</category>
      <category>productivity</category>
      <category>opensource</category>
      <category>privacy</category>
    </item>
    <item>
      <title>Privacy-first mind mapping app. Part 6: Maintainability and Coding Rules</title>
      <dc:creator>Kornel Maraz</dc:creator>
      <pubDate>Wed, 29 Apr 2026 21:00:49 +0000</pubDate>
      <link>https://dev.to/kornel_maraz_5e66a3e4e27d/privacy-first-mind-mapping-app-part-6-maintainability-and-coding-rules-1669</link>
      <guid>https://dev.to/kornel_maraz_5e66a3e4e27d/privacy-first-mind-mapping-app-part-6-maintainability-and-coding-rules-1669</guid>
      <description>&lt;p&gt;This is the chapter where I admit something simple: clean code talks are nice, but products are built in history, not in slides.&lt;/p&gt;

&lt;p&gt;When MindMapVault was small, I could hold most of it in my head. Then the project grew into frontend work, desktop work, encryption flows, uploads, backend routes, different database paths, deployment scripts, release notes, and a lot of "just fix this one thing" days.&lt;/p&gt;

&lt;p&gt;That is when coding rules stopped being theory and became survival.&lt;/p&gt;

&lt;p&gt;The rules I kept coming back to were not fancy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;keep changes small&lt;/li&gt;
&lt;li&gt;do not refactor the whole house when the sink is leaking&lt;/li&gt;
&lt;li&gt;be extra careful in crypto, auth, and storage code&lt;/li&gt;
&lt;li&gt;keep frontend and backend contracts aligned&lt;/li&gt;
&lt;li&gt;prefer readable code over clever code&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That sounds obvious. It is also the difference between a project that can still move and a project that starts breaking under its own weight.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code has a visible history, and that is normal
&lt;/h2&gt;

&lt;p&gt;You can see the project history in the codebase. I actually think that is healthy to admit.&lt;/p&gt;

&lt;p&gt;MindMapVault started with MongoDB. Later I added Stoolap. Later I added SQL-oriented paths and those &lt;code&gt;_sql.rs&lt;/code&gt; files. If you do not stop everything and rewrite the whole project from zero every time the architecture evolves, signs of the older path stay visible.&lt;/p&gt;

&lt;p&gt;That is not a moral failure. That is what real software looks like.&lt;/p&gt;

&lt;p&gt;You can often read the timeline directly from the repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;frontend_app/        hosted app UI, editor, crypto helpers, vault flows
frontend_www/        marketing site, release notes, public blog
desktop/src-tauri/   local desktop shell and native packaging
backend/src/         auth, routes, storage, DB adapters, upload flows
scripts/             regression runners, banner rendering, deployment helpers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then inside the backend there is another layer of history:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;older MongoDB-oriented paths&lt;/li&gt;
&lt;li&gt;later SQL and Stoolap paths&lt;/li&gt;
&lt;li&gt;route files that had to stay practical while the storage model evolved&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can absolutely see that evolution if you read the repo for long enough. I am fine with that. Every long-running project carries some residue of its earlier decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical first, perfect never
&lt;/h2&gt;

&lt;p&gt;I like practical things. I like jobs done.&lt;/p&gt;

&lt;p&gt;That preference is visible in the code.&lt;/p&gt;

&lt;p&gt;Some parts are tidy and stable. Some parts are tactical. Some parts are not how I would design them in a greenfield rewrite. But a real product is not rebuilt from first principles every Tuesday morning.&lt;/p&gt;

&lt;p&gt;There is a version of software advice that pretends all good code emerges from calm, linear planning. That is not how most product work happens.&lt;/p&gt;

&lt;p&gt;Real code grows under pressure from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;feature delivery&lt;/li&gt;
&lt;li&gt;production bugs&lt;/li&gt;
&lt;li&gt;changed infrastructure&lt;/li&gt;
&lt;li&gt;new storage backends&lt;/li&gt;
&lt;li&gt;packaging and deployment headaches&lt;/li&gt;
&lt;li&gt;the need to keep existing users working while the internals evolve&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So yes, theoretically clean code and real-life code are often different things.&lt;/p&gt;

&lt;p&gt;The goal was never to make MindMapVault look like a textbook. The goal was to keep it understandable enough, safe enough, and changeable enough while the product kept moving.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the rules were bent
&lt;/h2&gt;

&lt;p&gt;There are places where the project is cleaner than average, and places where it absolutely is not.&lt;/p&gt;

&lt;p&gt;The most visible compromises are usually these:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Boundaries are not always perfect&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Some concerns leak across layers because the fastest safe fix was not always the prettiest abstraction.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;React hygiene is not always textbook&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There are places with deliberate lint-rule exceptions or dependency-array compromises because stable behavior in a real flow mattered more than satisfying the purest interpretation of the rule.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Style consistency is uneven&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Some modules were written during calmer phases. Others were written during "let me finally this *** and go to the bed" phases. That difference is visible.&lt;/p&gt;

&lt;p&gt;I would rather say that openly than write fake architecture prose around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Copilot changed the texture of the code too
&lt;/h2&gt;

&lt;p&gt;Another honest point: code does not look like it did five years ago.&lt;/p&gt;

&lt;p&gt;This project carries signs of Copilot use and, more broadly, LLM-assisted development. That is real now. We should stop pretending otherwise.&lt;/p&gt;

&lt;p&gt;Sometimes that means faster scaffolding. Sometimes it means a strange but useful first draft. Sometimes it means the code gets a little more chaotic or stylistically mixed than it would with one human colleague writing every line in one voice.&lt;/p&gt;

&lt;p&gt;But there is another side to that trade-off.&lt;/p&gt;

&lt;p&gt;LLMs are also good at searching through that mess, finding the right file, spotting a broken path, or repairing a repeated pattern faster than a human might. In that sense, the code is not only written differently now. It is also maintained differently now.&lt;/p&gt;

&lt;p&gt;I think we have to accept both sides:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the Copilot touch is visible&lt;/li&gt;
&lt;li&gt;some generated structure is less elegant than an ideal hand-crafted version&lt;/li&gt;
&lt;li&gt;but the same tooling also makes large, messy codebases easier to search, patch, and recover&lt;/li&gt;
&lt;li&gt;and different coding styles are visible in a single project even one man´s hand (and a robot´s hand)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is part of modern software reality now.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually kept the project under control
&lt;/h2&gt;

&lt;p&gt;The workflow was practical, not ceremonial.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;tsc&lt;/code&gt; and production builds were the first fast safety net.&lt;/li&gt;
&lt;li&gt;backend regression runs through WSL and Python scripts were the real "did I break the product" check&lt;/li&gt;
&lt;li&gt;dependency audits were hygiene, not proof of quality&lt;/li&gt;
&lt;li&gt;security-sensitive changes were validated by behavior, not by vibes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the Python side I leaned on repeatable checks like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;scripts/backend_regression_test.py&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;scripts/attachement_regression_test.py&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;scripts/shared_regression_test.py&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;scripts/production_functional_test.py&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For burst and stress behavior I also used helpers like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;scripts/load_test_stoolap.py&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;scripts/production_burst_test.py&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;scripts/crud_burst_runner.py&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That does not make the project magically clean. It just means there were repeatable ways to keep reality in view.&lt;/p&gt;

&lt;p&gt;Passing lint does not prove good architecture. A green build does not prove good UX. An audit does not prove safe design.&lt;/p&gt;

&lt;p&gt;But together, those checks helped keep the project from drifting too far into chaos.&lt;/p&gt;

&lt;h2&gt;
  
  
  The maintainability standard I actually believe in
&lt;/h2&gt;

&lt;p&gt;For me, maintainability is not "could this win a code-style argument on the internet?"&lt;/p&gt;

&lt;p&gt;It is more practical:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;can I still understand this at 2 AM during a bug&lt;/li&gt;
&lt;li&gt;can I change one thing without breaking five others&lt;/li&gt;
&lt;li&gt;can I trace a storage or auth path end to end&lt;/li&gt;
&lt;li&gt;can I ship a fix without turning it into a rewrite&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the bar I care about.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.mindmapvault.com/" rel="noopener noreferrer"&gt;MindMapVault&lt;/a&gt; is not pristine, and I do not need to pretend it is. It is a real product with a real timeline, visible scars, and a codebase that shows both human shortcuts and AI-era development habits.&lt;/p&gt;

&lt;p&gt;I am okay with that.&lt;/p&gt;

&lt;p&gt;What matters is that the important parts stay understandable, the dangerous parts stay guarded, and the project keeps moving without collapsing under its own history.&lt;/p&gt;

</description>
      <category>codequality</category>
      <category>devjournal</category>
      <category>softwaredevelopment</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Privacy-first mind mapping app. Part 5: UI / Keyboard First</title>
      <dc:creator>Kornel Maraz</dc:creator>
      <pubDate>Sun, 26 Apr 2026 12:51:19 +0000</pubDate>
      <link>https://dev.to/kornel_maraz_5e66a3e4e27d/privacy-first-mind-mapping-app-part-5-ui-keyboard-first-4797</link>
      <guid>https://dev.to/kornel_maraz_5e66a3e4e27d/privacy-first-mind-mapping-app-part-5-ui-keyboard-first-4797</guid>
      <description>&lt;p&gt;Part 0 explained the feeling I wanted to recover: thought velocity.&lt;/p&gt;

&lt;p&gt;This chapter is where that feeling turns into UI decisions.&lt;/p&gt;

&lt;p&gt;I did not optimize for feature count first. I optimized for interruption cost. Every extra click, hidden mode, or ambiguous icon steals cognitive bandwidth from the idea itself.&lt;/p&gt;

&lt;p&gt;So the UI direction was keyboard-first and friction-minimal:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;common actions reachable without mouse travel&lt;/li&gt;
&lt;li&gt;fast node creation and editing loops&lt;/li&gt;
&lt;li&gt;predictable, stable interaction patterns&lt;/li&gt;
&lt;li&gt;visual structure that supports scanning, not decoration&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A keyboard-first interface is not nostalgia. It is a throughput strategy.&lt;/p&gt;

&lt;p&gt;When a person is deep in a problem, they should not negotiate with the UI. The interface should be almost mechanical, so attention stays on thinking.&lt;/p&gt;

&lt;p&gt;There is always tension here.&lt;/p&gt;

&lt;p&gt;Modern apps reward adding controls, toggles, and contextual actions everywhere. Some are useful. Too many create noise. For this product, I repeatedly chose less surface area when that preserved speed.&lt;/p&gt;

&lt;p&gt;The second tension is accessibility versus complexity.&lt;/p&gt;

&lt;p&gt;Keyboard-first does not mean mouse-hostile. It means every key path should be meaningful, discoverable, and reliable. It also means visual focus states, labels, and interaction feedback must stay clear.&lt;/p&gt;

&lt;h2&gt;
  
  
  The frontend backbone we built on
&lt;/h2&gt;

&lt;p&gt;The core model for the editor is built on the React Flow ecosystem (&lt;code&gt;@xyflow/react&lt;/code&gt;). For this project, it was the most reliable and practical foundation I found for a mind-map-style UI.&lt;/p&gt;

&lt;p&gt;Project link: &lt;a href="https://reactflow.dev/" rel="noopener noreferrer"&gt;https://reactflow.dev/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This mattered a lot because the whole frontend editor is effectively built around that graph viewport model. In simple terms, the canvas/viewport layer handles the graph rendering and interaction surface, and most of our product-specific work sits on top of it.&lt;/p&gt;

&lt;p&gt;In practice, a big part of the app is then "buttons and keyboard actions talking to the graph canvas":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;creating and deleting nodes&lt;/li&gt;
&lt;li&gt;connecting and re-ordering structure&lt;/li&gt;
&lt;li&gt;focusing, selecting, and navigating&lt;/li&gt;
&lt;li&gt;applying styling and metadata actions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That may sound obvious, but this choice was a huge helper. It gave the project a stable backbone early, so I could spend energy on product behavior (keyboard speed, encryption-aware flows, vault UX) instead of rebuilding graph rendering primitives from scratch.&lt;/p&gt;

&lt;p&gt;In practical product terms, this chapter connects directly to trust:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;fast interaction encourages regular use&lt;/li&gt;
&lt;li&gt;regular use creates real workflow dependence&lt;/li&gt;
&lt;li&gt;dependence only happens when the tool gets out of the way&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is still the bar for MindMapVault.&lt;/p&gt;

&lt;p&gt;If a user has to think about the interface more than the idea, the product failed for that moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Thanks and credits
&lt;/h2&gt;

&lt;p&gt;This part of the project also sits on top of a lot of other people's work.&lt;/p&gt;

&lt;p&gt;Huge thanks to the maintainers, contributors, communities, and companies behind React, React Flow, Tauri, TypeScript, Vite, Tailwind, Zustand, the Rust ecosystem, and the many smaller libraries that made this UI direction realistic for an independent product.&lt;/p&gt;

&lt;p&gt;React Flow deserves a specific mention here because it gave the editor a serious, practical backbone early. That let me spend time on keyboard speed, interaction clarity, and product behavior instead of rebuilding graph rendering and viewport interaction from zero.&lt;/p&gt;

&lt;p&gt;There is a fuller acknowledgement and licensing summary here: &lt;a href="https://www.mindmapvault.com/CREDITS.md" rel="noopener noreferrer"&gt;MindMapVault credits and licenses&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>devjournal</category>
      <category>productivity</category>
      <category>ui</category>
      <category>ux</category>
    </item>
  </channel>
</rss>
