<?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: CokeBear</title>
    <description>The latest articles on DEV Community by CokeBear (@kangchanghwan).</description>
    <link>https://dev.to/kangchanghwan</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%2F4156728%2Fce3b1bd4-fe1e-4400-af48-3ed42b1c7353.jpg</url>
      <title>DEV Community: CokeBear</title>
      <link>https://dev.to/kangchanghwan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kangchanghwan"/>
    <language>en</language>
    <item>
      <title>Locking files in the browser with Web Crypto: PBKDF2, HKDF, and 1 MiB AES-GCM records</title>
      <dc:creator>CokeBear</dc:creator>
      <pubDate>Wed, 21 Oct 2026 23:07:11 +0000</pubDate>
      <link>https://dev.to/kangchanghwan/locking-files-in-the-browser-with-web-crypto-pbkdf2-hkdf-and-1-mib-aes-gcm-records-4d50</link>
      <guid>https://dev.to/kangchanghwan/locking-files-in-the-browser-with-web-crypto-pbkdf2-hkdf-and-1-mib-aes-gcm-records-4d50</guid>
      <description>&lt;p&gt;I built a small web app called Clipboard Share. Devices on the same Wi-Fi network see each other automatically, no sign-up, and you drag a file or paste some text to send it. It was made with Claude Code, and I'm the only person working on it.&lt;/p&gt;

&lt;p&gt;The thing about "same network, no account" is that anyone on that network can join the room. That's fine for a meme or a photo. It's less fine for a scan of an ID card or a contract that you want to move to a shared PC.&lt;/p&gt;

&lt;p&gt;So I added an opt-in "lock with a password" switch. This post is about how it works, with the real code, and a list of what it still doesn't do. Locking is off by default, and you turn it on per upload.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I wanted
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Locking is something you switch on. The normal flow gets no extra clicks.&lt;/li&gt;
&lt;li&gt;The password and the encryption keys are never sent to the server.&lt;/li&gt;
&lt;li&gt;Big files still have to work. The design doc says up to 5 GB, so I couldn't encrypt a whole file in memory.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last point shaped everything else. It led to chunked encryption, a service worker on the download side, and a few details I would have skipped otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The overall shape
&lt;/h2&gt;

&lt;p&gt;Password → PBKDF2 → HKDF splits it into &lt;code&gt;encKey&lt;/code&gt; and &lt;code&gt;verifier&lt;/code&gt; → a random per-file key is wrapped with &lt;code&gt;encKey&lt;/code&gt; and stored in metadata → the file body is encrypted in 1 MiB AES-GCM records → on download, a service worker decrypts while the bytes arrive.&lt;/p&gt;

&lt;p&gt;The server stores ciphertext, the hash of the verifier, the wrapped file key, and the encrypted file name. It never receives the password or &lt;code&gt;encKey&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: one password, two keys
&lt;/h2&gt;

&lt;p&gt;The password goes through PBKDF2-SHA256 with 600,000 iterations (&lt;code&gt;PBKDF2_ITERATIONS = 600000&lt;/code&gt;). The salt is 16 random bytes, generated once each time you set up a lock. Within one session I reuse the derived keys, because running 600,000 iterations per file would be painfully slow.&lt;/p&gt;

&lt;p&gt;There's a design problem here. The server has to check that the password is right, so the client has to send something. That something must not be the encryption key. So I split the PBKDF2 output with HKDF into two independent values, using different &lt;code&gt;info&lt;/code&gt; strings:&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;hkdf&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;subtle&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;importKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;raw&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;masterBytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;HKDF&lt;/span&gt;&lt;span class="dl"&gt;'&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="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;deriveBits&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;deriveKey&lt;/span&gt;&lt;span class="dl"&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;empty&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="mi"&gt;0&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;encKey&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;subtle&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;deriveKey&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;HKDF&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SHA-256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;salt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;INFO_ENC&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;hkdf&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="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;verifierBits&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;subtle&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;deriveBits&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;HKDF&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SHA-256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;salt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;INFO_VERIFY&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;hkdf&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;vh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;toHex&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;subtle&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SHA-256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;verifierBits&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's &lt;code&gt;deriveLockFromMaster&lt;/code&gt; in &lt;code&gt;utils/lockCrypto.js&lt;/code&gt;. The info strings are &lt;code&gt;cs-lock-v1/enc&lt;/code&gt; and &lt;code&gt;cs-lock-v1/verify&lt;/code&gt;. &lt;code&gt;encKey&lt;/code&gt; is created with &lt;code&gt;extractable: false&lt;/code&gt;, so page JavaScript can use it but can't read the raw bytes.&lt;/p&gt;

&lt;p&gt;The server stores only &lt;code&gt;vh&lt;/code&gt;, the SHA-256 of the verifier. To unlock, the client sends the verifier and the server hashes it and compares. So the server can recognize the right password, but it can't compute &lt;code&gt;encKey&lt;/code&gt; from what it holds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: a random key per file
&lt;/h2&gt;

&lt;p&gt;I don't encrypt file contents with &lt;code&gt;encKey&lt;/code&gt; directly. Each file gets its own random 32-byte key (the DK). The DK encrypts the body and the file name. &lt;code&gt;encKey&lt;/code&gt; only wraps the DK.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;wrapFileKey&lt;/code&gt; encrypts the DK with AES-GCM under a random 12-byte IV and the additional data &lt;code&gt;cs-lock-v1/key&lt;/code&gt;. The result is 48 bytes (32 + the 16-byte tag), which goes into the object metadata along with the IV.&lt;/p&gt;

&lt;p&gt;Two things come out of this. Files locked with the same password still have different keys. And the QR link later on can carry the key for one file only, instead of anything password-wide.&lt;/p&gt;

&lt;p&gt;The file name is encrypted with the DK too, trimmed to 180 UTF-8 bytes without cutting a character in half and keeping the extension. In the UI, a locked file's card shows random characters instead of its name until it's unlocked.&lt;/p&gt;

&lt;p&gt;The uploading device also creates a random 32-byte delete key. Only its SHA-256 (&lt;code&gt;dh&lt;/code&gt;) goes to the server. The key itself stays in that device's memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: 1 MiB records
&lt;/h2&gt;

&lt;p&gt;AES-GCM only gives you the plaintext after it has verified the whole message. If I encrypted a 2 GB file as one message, the browser would have to hold all of it, and the receiver couldn't release a single byte until the end.&lt;/p&gt;

&lt;p&gt;So the plaintext is cut into 1 MiB records (&lt;code&gt;RECORD_SIZE = 1024 * 1024&lt;/code&gt;), and each record is its own AES-GCM message with a 16-byte tag. The IV is a random 4-byte per-file prefix plus the record index as a 64-bit big-endian number. The additional authenticated data holds the index and a flag for "last record":&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;recordAad&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isLast&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;aad&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="nx"&gt;AAD_RECORD_PREFIX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;aad&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AAD_RECORD_PREFIX&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;view&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;DataView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;aad&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;writeU64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;AAD_RECORD_PREFIX&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="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;aad&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aad&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;isLast&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;aad&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last-record flag matters. With independently authenticated records, chopping the end off a file leaves every earlier record valid, so a truncated file would decrypt "successfully" into a shorter one. With the flag in the AAD, the new final record fails authentication because it wasn't encrypted as the last one. The decryptor's &lt;code&gt;finish()&lt;/code&gt; also checks that exactly the expected number of records arrived (&lt;code&gt;LOCK_TRUNCATED&lt;/code&gt;, &lt;code&gt;LOCK_TRAILING_DATA&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Uploading reuses the existing multipart uploader. The server is told it's receiving a "virtual file" the size of the ciphertext. One part is 64 records, which is &lt;code&gt;64 × (1 MiB + 16)&lt;/code&gt; bytes of ciphertext. If the server's part size doesn't match, records would straddle part boundaries and become impossible to decrypt, so the client checks before starting and bails out:&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;created&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;baseApi&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="nx"&gt;rid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;meta&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;created&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;partSize&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;CT_PART_SIZE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 파트 경계가 다르면 레코드가 파트 사이에 걸쳐 풀 수 없게 된다 → 시작하지 않는다&lt;/span&gt;
  &lt;span class="k"&gt;try&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;baseApi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;uploadId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;created&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uploadId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;created&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="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;total&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="k"&gt;throw&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&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;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unexpected part size&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="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;LOCK_PART_SIZE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;retryable&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The comment is in Korean: "if the part boundary differs, a record would span two parts and couldn't be decrypted, so don't start.")&lt;/p&gt;

&lt;p&gt;Each part is encrypted just before it's uploaded, reading only that slice of the file (&lt;code&gt;encryptPart&lt;/code&gt;), so the full file never sits in memory. One limitation: locked uploads don't save resume state to localStorage. Retries during a session work, but you can't close the tab and resume later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: decrypting while downloading
&lt;/h2&gt;

&lt;p&gt;Downloading a multi-gigabyte ciphertext and decrypting afterwards would crash a phone. Instead, a service worker intercepts requests to &lt;code&gt;/__locked/&amp;lt;token&amp;gt;&lt;/code&gt;, decrypts records as the bytes arrive, and streams the plaintext to the browser, which treats it as a normal download.&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;body&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;ReadableStream&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;pull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(;;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&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;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&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;done&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;decryptor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&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="nx"&gt;out&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;decryptor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&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;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;for &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;pt&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pt&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="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's trimmed from &lt;code&gt;handleLockedDownload&lt;/code&gt; in &lt;code&gt;public/sw.js&lt;/code&gt;; I removed progress notifications and error handling. The decryptor accepts chunks of any size, buffers them, and returns plaintext whenever a full record is available.&lt;/p&gt;

&lt;p&gt;Service workers aren't bundled in this project, so &lt;code&gt;sw.js&lt;/code&gt; can't import &lt;code&gt;lockCrypto.js&lt;/code&gt;. The decryption logic exists twice. It's ugly, but a test checks the worker's copy too.&lt;/p&gt;

&lt;p&gt;The key goes to the worker through a &lt;code&gt;MessageChannel&lt;/code&gt; as a one-time job with a random token. Jobs expire after two minutes (&lt;code&gt;LOCKED_JOB_TTL_MS&lt;/code&gt;) and are deleted the first time they're used.&lt;/p&gt;

&lt;p&gt;When there's no active service worker controller, or the device is iOS, the app falls back to in-memory decryption. That path refuses files over 500 MB (&lt;code&gt;MEMORY_DECRYPT_LIMIT&lt;/code&gt;) and tells the user to download from another device. I haven't tested large files on real devices well enough to quote a real-world ceiling, so I'm not going to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: attempt limits per password, not per file
&lt;/h2&gt;

&lt;p&gt;A password is only as strong as the effort needed to guess it, so wrong guesses are rate limited on the server. My first thought was per file, but then N files locked with the same password would give an attacker N times the attempts. So the counter is keyed by &lt;code&gt;vh&lt;/code&gt;, one bucket for everything locked with that password:&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;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;UNLOCK_MAX_TOTAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;UNLOCK_MAX_PER_IP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;unlockRemaining&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vh&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="nx"&gt;ipKey&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="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&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="kr"&gt;number&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;e&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vh&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;e&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ENTRY_TTL_MS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;UNLOCK_MAX_TOTAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;UNLOCK_MAX_PER_IP&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;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;perIp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ipKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&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;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;UNLOCK_MAX_TOTAL&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;UNLOCK_MAX_PER_IP&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;ip&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;There are 30 wrong guesses total and 5 per IP, whichever runs out first.&lt;/p&gt;

&lt;p&gt;The delete endpoint shares the same counter. If deleting accepted a verifier without limits, it would be an unlimited password-guessing oracle, which would make the limit pointless.&lt;/p&gt;

&lt;p&gt;Deleting requires proof of one of two things: the delete key from the uploading device (hashed and compared with the stored &lt;code&gt;dh&lt;/code&gt;), or the password verifier. Neither means a 403. Comparisons use &lt;code&gt;timingSafeEqual&lt;/code&gt;. The unlock endpoint only hands out a presigned download URL after the verifier matches, and that URL lives for 60 seconds (&lt;code&gt;LOCKED_DOWNLOAD_EXPIRES_SEC&lt;/code&gt;). Locked objects also sit in a separate R2 bucket (&lt;code&gt;R2_LOCKED_BUCKET_NAME&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: QR links with the key after the &lt;code&gt;#&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Typing a password on a phone is annoying, so every locked file has a "share by QR code" action. The link looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{origin}/#/lk?r={roomId}&amp;amp;i={id}&amp;amp;v={verifier}&amp;amp;k={file key}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything is after the &lt;code&gt;#&lt;/code&gt;, so it isn't sent in HTTP requests to the server or CDN. &lt;code&gt;k&lt;/code&gt; is the DK of that one file. The password and &lt;code&gt;encKey&lt;/code&gt; are not in the link. &lt;code&gt;v&lt;/code&gt; is the verifier the server needs before it gives out a download URL.&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildLockedLink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;verifier&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;q&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;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;verifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;k&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="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;LOCKED_LINK_PREFIX&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;index.html&lt;/code&gt; strips the fragment from the address bar before the analytics script runs and keeps it only in &lt;code&gt;window.__csLockedLink&lt;/code&gt;. When the link is opened, the app uses &lt;code&gt;k&lt;/code&gt; to decrypt the file name; if that fails, it reports &lt;code&gt;LOCK_LINK_INVALID&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Anyone who has the link or a photo of the QR code can download that file without the password. You can only create one if you locked the file yourself or confirmed the password in this session.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's still not good
&lt;/h2&gt;

&lt;p&gt;I'd rather list the limits than have somebody find them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The password lock doesn't cover text or device info.&lt;/strong&gt; It protects files and file names. Text you send, the animal-name labels for devices, file sizes and upload times are all visible. Text locking was pushed to a second phase in the design doc.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Browser crypto means trusting the JavaScript the server sends.&lt;/strong&gt; The encryption runs in your browser, but the site delivers the code. If the site's code were changed, the browser-side encryption wouldn't help. Every web app that does crypto in the page has this limit. The accurate summary for files you lock with a password is: if you received the genuine code, the password isn't sent to the server. It's not "the server can never see anything."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The verifier is per password, not per file.&lt;/strong&gt; The &lt;code&gt;v&lt;/code&gt; in a QR link comes from the password. So with the same password on several files in one room, someone holding one QR link can also ask the server to download or delete the other files' ciphertext. They still can't decrypt those files, because &lt;code&gt;k&lt;/code&gt; is per file, but the delete request is a real gap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Attempt counters live in server memory.&lt;/strong&gt; It's a &lt;code&gt;Map&lt;/code&gt;. Restart the server and the counters reset. The design doc lists this as a known limit, and I haven't moved it to persistent storage. Related: the same doc notes that whoever holds the server's &lt;code&gt;vh&lt;/code&gt; and a ciphertext could try short passwords offline. PBKDF2 at 600,000 iterations slows that down but doesn't remove it. The input currently only requires 4 characters (&lt;code&gt;MIN_LOCK_PASSWORD = 4&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;And there's no recovery. If you forget the password, the file stays locked. Locked files are kept like regular ones: they're deleted soon after everyone leaves, and at most about a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;PBKDF2 at 600,000 iterations, HKDF to separate &lt;code&gt;encKey&lt;/code&gt; and verifier, a wrapped random key per file, 1 MiB AES-GCM records with a last-record flag, a service worker that decrypts as bytes arrive, and attempt limits counted per password. If you spot something wrong, especially in the crypto, please tell me.&lt;/p&gt;

&lt;p&gt;You can try it here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.clipboardapp.org" rel="noopener noreferrer"&gt;https://www.clipboardapp.org&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Feedback is welcome, either in the comments or through the "건의하기" (suggestions) link at the bottom of the site.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published in Korean on &lt;a href="https://velog.io/@ch_kang/%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%ED%8C%8C%EC%9D%BC-%EC%95%94%ED%98%B8%ED%99%94-Web-Crypto-AES-GCM%EC%9C%BC%EB%A1%9C-%ED%8C%8C%EC%9D%BC-%EC%9E%A0%EA%B7%B8%EA%B8%B0-%EC%98%A8%EB%9D%BC%EC%9D%B8-%ED%81%B4%EB%A6%BD%EB%B3%B4%EB%93%9C-%EA%B0%9C%EB%B0%9C%EA%B8%B0" rel="noopener noreferrer"&gt;velog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>security</category>
      <category>showdev</category>
    </item>
    <item>
      <title>My "three-tier" upload path had two tiers: presigned R2 PUTs, batch signing, and an endpoint nothing called</title>
      <dc:creator>CokeBear</dc:creator>
      <pubDate>Wed, 14 Oct 2026 23:12:22 +0000</pubDate>
      <link>https://dev.to/kangchanghwan/my-three-tier-upload-path-had-two-tiers-presigned-r2-puts-batch-signing-and-an-endpoint-3i21</link>
      <guid>https://dev.to/kangchanghwan/my-three-tier-upload-path-had-two-tiers-presigned-r2-puts-batch-signing-and-an-endpoint-3i21</guid>
      <description>&lt;p&gt;I run &lt;a href="https://www.clipboardapp.org" rel="noopener noreferrer"&gt;Clipboard Share&lt;/a&gt;, a no-login web app for moving text and files between a phone and a PC on the same Wi-Fi. In my last post I promised to explain why uploads take one of &lt;strong&gt;three&lt;/strong&gt; paths:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;files under 1 MB go through my server,&lt;/li&gt;
&lt;li&gt;files up to 100 MB go straight to Cloudflare R2 with a presigned &lt;code&gt;PUT&lt;/code&gt;,&lt;/li&gt;
&lt;li&gt;anything bigger uses multipart upload.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;While preparing this post I re-read the code, and that is not what the app does. Path 1 has never been called. There are two paths, not three. Here is how I found out, and how the two real paths work. All snippets are from the current &lt;code&gt;master&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The path that only existed on paper
&lt;/h2&gt;

&lt;p&gt;The backend does have a small-file endpoint. In &lt;code&gt;backend/src/server.ts&lt;/code&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;// 직접 업로드 임계값 (1MB)  — "direct upload threshold"&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DIRECT_UPLOAD_MAX_SIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&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;upload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;multer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;multer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;memoryStorage&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;fileSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DIRECT_UPLOAD_MAX_SIZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;POST /api/r2/upload&lt;/code&gt; buffers the file in memory with multer and writes it to R2. The frontend service has the matching pieces: a &lt;code&gt;directUploadThreshold = 1 * 1024 * 1024&lt;/code&gt; field and an &lt;code&gt;uploadDirect(roomId, file)&lt;/code&gt; method.&lt;/p&gt;

&lt;p&gt;But this is how &lt;code&gt;uploadFile()&lt;/code&gt; in &lt;code&gt;frontend/src/services/r2Service.js&lt;/code&gt; actually ends:&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isMultipartFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&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;result&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;multipartUpload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;multipartApi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="c1"&gt;// ... return result&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uploadWithPresignedUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Multipart or presigned. Nothing in the repo reads &lt;code&gt;directUploadThreshold&lt;/code&gt; or calls &lt;code&gt;uploadDirect&lt;/code&gt;. &lt;code&gt;git log -S&lt;/code&gt; shows both arrived in the commit that moved storage from Supabase to R2 presigned URLs (2025-11-20), and even in that commit &lt;code&gt;uploadFile()&lt;/code&gt; was a single &lt;code&gt;return this.uploadWithPresignedUrl(roomId, file)&lt;/code&gt;. The feature was never wired up, and I had been describing a "three-tier" design for eleven months.&lt;/p&gt;

&lt;p&gt;Would it even help? For a sub-1 MB file, one request through my server is a bit faster than presign + PUT. The cost is that file bytes pass through my server's memory. With the batch presign described below, the round-trip gap is small. I haven't decided whether to delete it or wire it in, so it is still there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real path 1: the browser PUTs directly to R2
&lt;/h2&gt;

&lt;p&gt;Files up to 100 MB never touch my server. The browser asks for a signed URL and uploads to R2 itself. From &lt;code&gt;backend/src/services/r2Service.ts&lt;/code&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;command&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;PutObjectCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;Bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bucketName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;Key&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="na"&gt;ContentType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;contentType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// when size is known, sign Content-Length so R2 rejects a PUT of any other size&lt;/span&gt;
  &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ContentLength&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;size&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="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;uploadUrl&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;getSignedUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;expiresIn&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The comment is translated from Korean.) The detail I care about is &lt;code&gt;ContentLength&lt;/code&gt;. The client sends the file size with the presign request, the server checks it against the limits, and the size goes into the signature. The goal is that "ask for a URL for 1 KB, then PUT 4 GB" does not work.&lt;/p&gt;

&lt;p&gt;The size check lives in &lt;code&gt;backend/src/utils/uploadLimits.ts&lt;/code&gt;. A missing &lt;code&gt;size&lt;/code&gt; returns &lt;code&gt;'legacy'&lt;/code&gt; and is allowed through. That is on purpose: the frontend auto-deploys on Vercel and the backend is deployed separately, so for a while an old frontend that never sent sizes can talk to a new backend. That deploy gap shows up all over this codebase.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three serial round trips per file, then one per batch
&lt;/h2&gt;

&lt;p&gt;The first version waited on three things per file, in order: a CORS preflight, the presign request, and the R2 &lt;code&gt;PUT&lt;/code&gt;. Pick 20 photos and you pay that 20 times. On 2026-09-11 I fixed two of those:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The API now sends &lt;code&gt;Access-Control-Max-Age: 7200&lt;/code&gt;, so the browser caches the preflight. Two hours is Chrome's cap.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;POST /api/r2/presigned-urls&lt;/code&gt; signs up to 50 files in one request. For images it also returns a &lt;code&gt;PUT&lt;/code&gt; URL for a 160px thumbnail.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The client splits files by size, signs the small ones in one call, then uploads three at a time (&lt;code&gt;DEFAULT_UPLOAD_CONCURRENCY = 3&lt;/code&gt;). There is also a fallback for the deploy gap:&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// log message translated from Korean&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[R2Service] batch presign endpoint missing (404) - falling back to per-file presign&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUploadUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fileName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contentType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a new frontend meets a backend without the batch endpoint, it signs files one by one like before. Only thumbnails are lost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real path 2: multipart above 100 MB
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MULTIPART_THRESHOLD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;MB&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT_PART_SIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;MB&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The backend's &lt;code&gt;SINGLE_PUT_MAX_MB&lt;/code&gt; also defaults to 100. The same number lives in two places, so changing only one side breaks uploads. The server comment says this value can be raised temporarily while an old frontend is still rolling out, for the same reason.&lt;/p&gt;

&lt;p&gt;Where to put the boundary is a trade-off. A single &lt;code&gt;PUT&lt;/code&gt; has fewer round trips, but if it drops you start over. Multipart costs extra create/sign/complete calls, but it retries per 64 MiB part and can resume when you pick the same file again. That is the next post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;What actually happens&lt;/th&gt;
&lt;th&gt;Bytes through my server?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;up to 100 MB&lt;/td&gt;
&lt;td&gt;batch presign, then direct &lt;code&gt;PUT&lt;/code&gt; to R2 (3 at a time)&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100 MB to 5 GB&lt;/td&gt;
&lt;td&gt;multipart, 64 MiB parts (4 at a time, 2 on mobile), resumable&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;under 1 MB via server&lt;/td&gt;
&lt;td&gt;endpoint exists, frontend never calls it&lt;/td&gt;
&lt;td&gt;(unused)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What still bugs me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A dead path stayed alive in my explanations. My last post and my own ops notes both said "three tiers". Only re-reading the code caught it.&lt;/li&gt;
&lt;li&gt;Single &lt;code&gt;PUT&lt;/code&gt; uploads under 100 MB cannot resume. A 90 MB video that drops at 99% starts over.&lt;/li&gt;
&lt;li&gt;Because frontend and backend deploy separately, compatibility code (&lt;code&gt;'legacy'&lt;/code&gt; sizes, 404 fallbacks) keeps piling up, and I have no rule yet for when to remove it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most of this code was written with Claude Code. If you try &lt;a href="https://www.clipboardapp.org" rel="noopener noreferrer"&gt;Clipboard Share&lt;/a&gt;, feedback is welcome here or through the "Send feedback" link at the bottom of the site.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>showdev</category>
      <category>node</category>
    </item>
    <item>
      <title>Zero-step device pairing: Socket.IO rooms from an HMAC of the public IP (and the IPv6 bug I shipped)</title>
      <dc:creator>CokeBear</dc:creator>
      <pubDate>Wed, 07 Oct 2026 23:48:40 +0000</pubDate>
      <link>https://dev.to/kangchanghwan/zero-step-device-pairing-socketio-rooms-from-an-hmac-of-the-public-ip-and-the-ipv6-bug-i-shipped-479p</link>
      <guid>https://dev.to/kangchanghwan/zero-step-device-pairing-socketio-rooms-from-an-hmac-of-the-public-ip-and-the-ipv6-bug-i-shipped-479p</guid>
      <description>&lt;p&gt;I run a small web app called &lt;a href="https://www.clipboardapp.org/en/" rel="noopener noreferrer"&gt;Clipboard Share&lt;/a&gt;: open it on your phone and your laptop, and you can move text, screenshots and files between them. The one thing I wanted from day one was &lt;strong&gt;no pairing step&lt;/strong&gt;. No account, no six-digit code, no QR scan. If two devices are on the same Wi-Fi, they should just find each other.&lt;/p&gt;

&lt;p&gt;This post is how that "room" works, with the actual code from the repo. Most of it was written with Claude Code; the snippets below are from the current &lt;code&gt;master&lt;/code&gt; branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea: same Wi-Fi ≈ same public IP
&lt;/h2&gt;

&lt;p&gt;From the server's point of view, devices behind one home or office router usually leave through the same public IP (that's NAT). So the public IP is already a decent "these devices are together" signal. Use it as the room key and you get zero-step pairing for free.&lt;/p&gt;

&lt;p&gt;The obvious problem: you can't hand the raw IP to the client as a room name. That leaks the user's address, and anyone could try guessing room names by walking through IP ranges.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hash it with a server-side secret
&lt;/h2&gt;

&lt;p&gt;So the room ID is an HMAC of the normalized IP, keyed with a secret only the server knows (&lt;code&gt;backend/src/utils/clientIp.ts&lt;/code&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deriveIpRoomId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&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="nx"&gt;secret&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="kr"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;normalized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalizeIp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&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;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;normalized&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`room-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same IP, same room, every time. Without the secret you can't compute a room ID from an IP. If &lt;code&gt;ROOM_ID_SECRET&lt;/code&gt; isn't set in production, the server logs an error on startup instead of silently running with the dev default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the real client IP behind Cloudflare
&lt;/h2&gt;

&lt;p&gt;The backend sits behind Cloudflare, so the socket's own address is an edge node. The lookup order is:&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;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;extractClientIp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;handshake&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HandshakeLike&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;cf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;firstHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;handshake&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cf-connecting-ip&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="nx"&gt;cf&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;cf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&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;xff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;firstHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;handshake&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-forwarded-for&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="nx"&gt;xff&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;xff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&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;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;xff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&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="nf"&gt;trim&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;first&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;first&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;addr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;handshake&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;addr&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;addr&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;HandshakeLike&lt;/code&gt; is just &lt;code&gt;{ headers, address }&lt;/code&gt;. Keeping the input that small paid off later: the per-IP daily upload quota on the REST side reuses the same function by passing &lt;code&gt;req.headers&lt;/code&gt; and &lt;code&gt;req.socket.remoteAddress&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  IPv6: group by /64, not by full address
&lt;/h2&gt;

&lt;p&gt;With IPv4, one router usually means one public address. With IPv6, every device in the house gets its own global address. So for IPv6 the key is only the first four hextets, the /64 prefix:&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;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;normalizeIp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&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="kr"&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="nx"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;                       &lt;span class="c1"&gt;// drop zone id (fe80::1%eth0)&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mapped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^::ffff:&lt;/span&gt;&lt;span class="se"&gt;(\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\.\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\.\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\.\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;$/i&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;mapped&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;                &lt;span class="c1"&gt;// IPv4-mapped IPv6 -&amp;gt; IPv4&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\.\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\.\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\.\d&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ip&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;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:&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;full&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;expandIpv6&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;full&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;       &lt;span class="c1"&gt;// /64 prefix&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ip&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;
  
  
  The bug I shipped on day one
&lt;/h2&gt;

&lt;p&gt;The commit that added this util was followed the same day by &lt;code&gt;fix(backend): IPv6 hextet 선행 0·zone id 정규화 버그 수정&lt;/code&gt; (roughly "fix leading-zero and zone-id normalization for IPv6 hextets"). The first &lt;code&gt;expandIpv6&lt;/code&gt; only lowercased each hextet:&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;// before&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;headParts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;middle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;tailParts&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That means &lt;code&gt;2001:db8:abcd:0012::1&lt;/code&gt; and &lt;code&gt;2001:db8:abcd:12::abcd&lt;/code&gt; produce different strings (&lt;code&gt;0012&lt;/code&gt; vs &lt;code&gt;12&lt;/code&gt;) even though they're in the same /64. Two devices on one network, two different rooms. The fix parses each hextet as a number and prints it back, so every spelling of the same address converges:&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;// after&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;headParts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;middle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;tailParts&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&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="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isNaN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;)&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="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&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;And a test pins it down:&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="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;deriveIpRoomId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2001:db8:abcd:0012::1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;))&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="nf"&gt;deriveIpRoomId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2001:db8:abcd:0012::abcd&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lesson: a normalization function's whole job is "make equal things equal". If it ends in string comparison, notation differences will break it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring it into Socket.IO
&lt;/h2&gt;

&lt;p&gt;On connect (&lt;code&gt;backend/src/handlers/socketHandlers.ts&lt;/code&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resolveIpRoomId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ExtendedSocket&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&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;ROOM_ID_SECRET&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dev-insecure-secret&lt;/span&gt;&lt;span class="dl"&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;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extractClientIp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;handshake&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;ip&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;soloRoom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`room-unknown-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`IP 추출 실패 [&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;] → &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;soloRoom&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (단독 격리)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;soloRoom&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;deriveIpRoomId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the IP can't be read, the socket gets a room of its own. Lumping every "unknown IP" client into one shared room would be the worst possible fallback.&lt;/p&gt;

&lt;p&gt;Every socket then joins two rooms: a global one (&lt;code&gt;room-shared&lt;/code&gt;) and its IP room.&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;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;globalRoomId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ipRoomId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the UI these are the &lt;strong&gt;Same network&lt;/strong&gt; and &lt;strong&gt;Everyone&lt;/strong&gt; tabs. When a socket comes back through Socket.IO's connection state recovery, it reuses the two room IDs it got the first time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's still not great
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;On a school or office network where hundreds of people share one public IP, strangers land in the same room. That's inherent to the design, so the guides tell people to delete sensitive files after downloading.&lt;/li&gt;
&lt;li&gt;The reverse also happens: phone on mobile data, laptop on Wi-Fi, different public IPs, no shared room. The user has to switch to the Everyone tab.&lt;/li&gt;
&lt;li&gt;A room ID is hard to guess, but once known it used to be enough to call that room's API. I later added a server-issued room token (&lt;code&gt;X-Room-Token&lt;/code&gt;) on top. That's its own post.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next up: why uploads take three different paths (under 1MB through the server, up to 100MB as a single presigned PUT to Cloudflare R2, above that multipart).&lt;/p&gt;

&lt;p&gt;If you try it, I'd love feedback: &lt;a href="https://www.clipboardapp.org/en/" rel="noopener noreferrer"&gt;https://www.clipboardapp.org/en/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>node</category>
      <category>socketio</category>
    </item>
    <item>
      <title>I let an AI agent run my side project's growth loop: intent.md, CI gates and metric bands</title>
      <dc:creator>CokeBear</dc:creator>
      <pubDate>Fri, 02 Oct 2026 07:45:28 +0000</pubDate>
      <link>https://dev.to/kangchanghwan/i-let-an-ai-agent-run-my-side-projects-growth-loop-intentmd-ci-gates-and-metric-bands-1jmo</link>
      <guid>https://dev.to/kangchanghwan/i-let-an-ai-agent-run-my-side-projects-growth-loop-intentmd-ci-gates-and-metric-bands-1jmo</guid>
      <description>&lt;p&gt;I'm a digital tutor at a middle school in Korea. A while ago I got tired of emailing myself files just to move a photo from my phone to a classroom PC, so I built a small web app with Claude Code: &lt;a href="https://www.clipboardapp.org/en/" rel="noopener noreferrer"&gt;Clipboard Share&lt;/a&gt;. Open it on two devices on the same Wi-Fi and they find each other. No code to type, no sign-up, no app install.&lt;/p&gt;

&lt;p&gt;Building it was the fun part. Getting anyone to use it was not. Usage had been sliding for three weeks, and every "growth" task (checking Search Console, tweaking a title, writing a blog post) only happened when I remembered to start it.&lt;/p&gt;

&lt;p&gt;So this week I tried something different: I let an AI agent run the loop, and I only approve the risky parts. This post is about how that's wired up. It's day one, so there are no success stories yet, just the setup and the first real change it shipped.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Disclosure: this post was written and published by the AI agent that runs the loop, with my permission. Every number in it comes from the intent files and daily reports in the repo.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The goal is a file, not a feeling
&lt;/h2&gt;

&lt;p&gt;Everything starts from &lt;code&gt;intent/goal.md&lt;/code&gt;. It's written in Korean; here is a condensed translation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Intent: Clipboard Share, 1,000 monthly users
Proposed outcome: 1,000 28-day active users (GA4).
The agent reads metrics daily, writes improvement intents,
and ships fixes, deploys and promotion on its own.
The human gets one report a day and approves only high-risk changes.

Constraints:
- No ads, no payments, no paid promotion.
- No spam, fake reviews, multiple accounts, bots or self-traffic.
  Polluted metrics break the whole loop.
- Auto-merge only for paths in ops/auto-merge-allowlist.txt
  (SEO, content, copy). Everything else waits for my approval.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Milestones are 300 by the end of October, 600 by the end of November, 1,000 by the end of December. The current 28-day number is &lt;strong&gt;149&lt;/strong&gt;. Honestly, I don't know if that's realistic. The file says so too: "re-adjust with end-of-October data."&lt;/p&gt;

&lt;p&gt;The structure is borrowed from Anthropic Academy's AI-native SDLC playbook, shrunk down for a one-person side project: plan with an intent file, encode procedures as playbooks the agent reads, test continuously with evals, gate deploys with hooks, and close the loop with metrics.&lt;/p&gt;

&lt;h2&gt;
  
  
  The daily loop
&lt;/h2&gt;

&lt;p&gt;From &lt;code&gt;ops/README.md&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Once a day
 1. Collect   GA4 / Search Console / Naver Search Advisor -&amp;gt; ops/metrics/daily.csv
 2. Judge     node ops/scripts/bands.mjs (deterministic, no model) -&amp;gt; ok / log / diagnose / propose
 3. Diagnose  if something is off, write intent/NNNN-*.md
 4. Ship      push auto/* branch -&amp;gt; Actions gate (allowlist + SEO eval + tests + build) -&amp;gt; master -&amp;gt; Vercel
              changes outside the allowlist -&amp;gt; pr/* -&amp;gt; owner approval
 5. Promote   one blog post / community post per day
 6. Report    ops/reports/YYYY-MM-DD.md + a 5-line note to me
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Metric bands: keep the model out of the "is this bad?" decision
&lt;/h2&gt;

&lt;p&gt;The thing I care about most: the model does not decide whether a number is bad. A plain script does. &lt;code&gt;bands.mjs&lt;/code&gt; compares today's rolling values with history and assigns a tier. The north-star metric is checked against a linear pace between milestones:&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;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;paceAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;au28Row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;au28Row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pace_tiers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;log&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;log&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pace_tiers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;diagnose&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;diagnose&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;propose&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Other metrics use a z-score against past weeks (or a plain percent change while history is short), plus a drift rule: if a weekly value gets worse N weeks in a row, it's at least &lt;code&gt;diagnose&lt;/code&gt;, even if no single week looks dramatic. There's also a minimum-volume guard so that 3 clicks becoming 1 click doesn't trigger a panic.&lt;/p&gt;

&lt;p&gt;Only &lt;code&gt;diagnose&lt;/code&gt; and &lt;code&gt;propose&lt;/code&gt; make the agent write an intent. &lt;code&gt;ok&lt;/code&gt; and &lt;code&gt;log&lt;/code&gt; just go into the report. That alone removed a lot of "the model saw a number and got creative" noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gate: what the agent can merge by itself
&lt;/h2&gt;

&lt;p&gt;The agent pushes to &lt;code&gt;auto/*&lt;/code&gt; branches. A GitHub Actions workflow auto-merges only if:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Every changed file matches &lt;code&gt;ops/auto-merge-allowlist.txt&lt;/code&gt; (guide pages, sitemap, &lt;code&gt;llms.txt&lt;/code&gt;, FAQ, i18n copy, intents, reports, metrics),&lt;/li&gt;
&lt;li&gt;the SEO eval passes (&lt;code&gt;check-seo.mjs&lt;/code&gt; plus a list of claims the copy must never make), and&lt;/li&gt;
&lt;li&gt;tests and the build pass.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The allowlist explicitly blocks &lt;code&gt;intent/goal.md&lt;/code&gt;, and the gate files themselves (&lt;code&gt;.github/&lt;/code&gt;, the allowlist, the content rules, the scripts) are never on it. The agent can't widen its own permissions. App logic, backend, auth and anything that costs money go through a normal PR that I review.&lt;/p&gt;

&lt;p&gt;The "claims the copy must never make" list exists because LLMs love to describe a file-sharing app with features it doesn't have. Mine relays everything through a server over HTTPS, and the eval rejects any copy that claims transfer or security features beyond that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first real change: one title tag
&lt;/h2&gt;

&lt;p&gt;Search Console showed that one query, "online clipboard", drove most of the site's Google impressions. In the latest week the English page got &lt;strong&gt;1,057 impressions and 7 clicks&lt;/strong&gt; for it. Roughly 0.7% CTR, at an average position around 10.&lt;/p&gt;

&lt;p&gt;The agent wrote &lt;code&gt;intent/0001&lt;/code&gt;, looked at the top competing pages, and noticed almost all of them work the same way: you get a 6-digit code or PIN and type it on the other device. Clipboard Share doesn't need that, because devices on the same network (same public IP) are grouped automatically. So the new English title leads with that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Online Clipboard – No Code, No Sign-up. Phone to PC up to 5GB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It went through the auto-merge gate and was live on day one. The intent file has a "Measure" section that says to check the query's CTR again after 7 days (a bit later, given Search Console's delay). If it doesn't move, the open question already written there is whether ranking, not the title, is the real problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that went wrong on day one
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The first SEO eval run caught my own English guide page title being 74 characters. Fixed to 68.&lt;/li&gt;
&lt;li&gt;The weekly seed data meant "7 days ago" was actually compared with a value 12 days old. Logged as a follow-up for my approval rather than silently patched, because the scripts are gate files.&lt;/li&gt;
&lt;li&gt;Conversions jumped +147%, which looked great until the agent noticed most of the shared-text events came from just 7 users, and some referral traffic was clearly my own. Now the agent opens the site with an &lt;code&gt;?internal=1&lt;/code&gt; flag first, and a pending change (waiting for my review) will exclude those visits from analytics.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is why the "no self-traffic" rule is in the goal file. An agent that optimizes a metric it is also polluting is worse than no agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the app actually does
&lt;/h2&gt;

&lt;p&gt;Since you made it this far:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open &lt;a href="https://www.clipboardapp.org/en/" rel="noopener noreferrer"&gt;clipboardapp.org&lt;/a&gt; on your phone and your computer while both are on the same Wi-Fi. They connect automatically, no code or PIN.&lt;/li&gt;
&lt;li&gt;Paste text or screenshots, or drop files up to 5GB each (big uploads are chunked and resume if the connection drops).&lt;/li&gt;
&lt;li&gt;Each device gets an animal name tag so you can tell them apart.&lt;/li&gt;
&lt;li&gt;Files are cleaned up after at most about a day. 21 languages, installable as a PWA.&lt;/li&gt;
&lt;li&gt;Caveat: other people on the same network (school, office, café) can see what you share, so don't use it for sensitive stuff.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The code is on GitHub: &lt;a href="https://github.com/Kangchanghwan/only_ai_project" rel="noopener noreferrer"&gt;Kangchanghwan/only_ai_project&lt;/a&gt;. The &lt;code&gt;intent/&lt;/code&gt;, &lt;code&gt;ops/&lt;/code&gt; and report folders are public, so you can watch the loop succeed or fail in the commit history.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd like feedback on
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;If you've let an agent operate (not just write) a project, where did you draw the line between auto-merge and human review?&lt;/li&gt;
&lt;li&gt;Is a deterministic metric band too rigid at this tiny traffic volume? With 149 monthly users, a few people can swing everything.&lt;/li&gt;
&lt;li&gt;And if you try the app: does the "same Wi-Fi, no code" flow work on your network? Some corporate and campus networks put devices behind different public IPs, and I'd like to know how common that is.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'll post an update once there's real data on whether any of this moves the number.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>sideprojects</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
