<?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>Telling devices apart without accounts: 576 animal + adjective names for a Socket.IO clipboard app</title>
      <dc:creator>CokeBear</dc:creator>
      <pubDate>Wed, 11 Nov 2026 23:04:56 +0000</pubDate>
      <link>https://dev.to/kangchanghwan/telling-devices-apart-without-accounts-576-animal-adjective-names-for-a-socketio-clipboard-app-458m</link>
      <guid>https://dev.to/kangchanghwan/telling-devices-apart-without-accounts-576-animal-adjective-names-for-a-socketio-clipboard-app-458m</guid>
      <description>&lt;p&gt;When several devices share a room with no login, the device list has a problem: nobody knows which row is "my laptop" and who just sent that file. That came up while building Clipboard Share, a no-signup tool for moving files and text between devices on the same network. My fix was a small naming scheme: every device gets a tag like "Sleepy Panda". I built the app with Claude Code: &lt;a href="https://www.clipboardapp.org" rel="noopener noreferrer"&gt;https://www.clipboardapp.org&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The rooms themselves come from a hashed-IP design that I covered in an earlier post (in Korean). This one is about what happens inside the room.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a name at all
&lt;/h2&gt;

&lt;p&gt;With no account there is nothing to point at. With only a device type and a browser name, two phones of the same model look identical in the list, so the server gives each device a human-friendly label. The first implementation landed on 2026-10-01 in commit &lt;code&gt;a6ebd2b&lt;/code&gt; ("device identity (animal + adjective) assignment and reroll, sender stamping").&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Two integers
&lt;/h3&gt;

&lt;p&gt;The server never stores a name. It only handles two indexes: 24 adjectives times 24 animals, so 576 combinations.&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="cm"&gt;/** 24 adjectives x 24 animals = 576 combos. Index order must match the frontend */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="no"&gt;ADJ_COUNT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;24&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="no"&gt;ANIMAL_COUNT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;24&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="no"&gt;IDENTITY_COMBOS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;ADJ_COUNT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="no"&gt;ANIMAL_COUNT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The frontend turns indexes into text in the viewer's language through i18n keys (&lt;code&gt;identity.adj[n]&lt;/code&gt;, &lt;code&gt;identity.animal[n]&lt;/code&gt;). The same device is "졸린 판다" in Korean and "Sleepy Panda" in English.&lt;/p&gt;

&lt;h3&gt;
  
  
  No duplicates in a room
&lt;/h3&gt;

&lt;p&gt;On connect, the server collects the combos already used in the socket's rooms (the global room and the network room) and picks around them. The fallback order, from the source comments:&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="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="n"&gt;preferred&lt;/span&gt; &lt;span class="n"&gt;combo&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;saved&lt;/span&gt; &lt;span class="n"&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="n"&gt;it&lt;/span&gt; &lt;span class="n"&gt;is&lt;/span&gt; &lt;span class="n"&gt;free&lt;/span&gt;
 &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;on&lt;/span&gt; &lt;span class="n"&gt;conflict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keep&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;animal&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="n"&gt;only&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;adjective&lt;/span&gt;
 &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;otherwise&lt;/span&gt; &lt;span class="kt"&gt;any&lt;/span&gt; &lt;span class="n"&gt;free&lt;/span&gt; &lt;span class="n"&gt;combo&lt;/span&gt;
 &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;all&lt;/span&gt; &lt;span class="mi"&gt;576&lt;/span&gt; &lt;span class="n"&gt;are&lt;/span&gt; &lt;span class="n"&gt;taken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;append&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;suffix&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&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="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The "preferred" combo is what the browser remembered. The client keeps it in &lt;code&gt;localStorage&lt;/code&gt; under &lt;code&gt;clipboard-identity&lt;/code&gt; for 30 days:&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="no"&gt;STORAGE_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'clipboard-identity'&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="no"&gt;STORAGE_TTL_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server does not trust it. &lt;code&gt;sanitizeIdentity&lt;/code&gt; checks that both values are integers in range and drops anything else.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reroll with a cooldown
&lt;/h3&gt;

&lt;p&gt;The "reroll" button sits on your own row in &lt;code&gt;DeviceList.vue&lt;/code&gt;. The server passes the current combo as &lt;code&gt;avoid&lt;/code&gt;, so you never get the same name twice in a row, and each socket has a 3-second cooldown:&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="no"&gt;REROLL_COOLDOWN_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3000&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;REROLL_COOLDOWN_MS&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="k"&gt;typeof&lt;/span&gt; &lt;span class="n"&gt;ack&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'function'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;ack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;ok&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="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'cooldown'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retryAfterMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;REROLL_COOLDOWN_MS&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;last&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="k"&gt;return&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;After a reroll the server re-broadcasts &lt;code&gt;room-users&lt;/code&gt; to both rooms, and the button stays disabled until &lt;code&gt;rerollAvailableAt&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The server stamps the sender
&lt;/h3&gt;

&lt;p&gt;Who sent a message is decided server-side. Any &lt;code&gt;sender&lt;/code&gt; field from the client is thrown away and replaced:&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="cm"&gt;/**
 * Attach the sender info to a publish message. The client's own sender field
 * is always discarded and overwritten (anti-spoofing).
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;stampSender&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;DeviceInfo&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The stamp carries &lt;code&gt;socketId&lt;/code&gt;, &lt;code&gt;identity&lt;/code&gt;, device label, browser, and model (&lt;code&gt;toSender&lt;/code&gt;). The receiving side uses it to tell "sent by me" from "sent by another device".&lt;/p&gt;

&lt;h3&gt;
  
  
  Icons
&lt;/h3&gt;

&lt;p&gt;The 24 animals are Microsoft Fluent Emoji (Flat) SVGs under the MIT license. The license text and source link live next to them in &lt;code&gt;frontend/src/assets/animals/LICENSE&lt;/code&gt;. They are exported as separate hashed files (&lt;code&gt;import.meta.glob&lt;/code&gt; with &lt;code&gt;?no-inline&lt;/code&gt;), not inlined into the JS bundle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where else the name shows up
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Arrival indicators and notifications use it as the sender name (&lt;code&gt;senderName&lt;/code&gt; in &lt;code&gt;useArrivalBadge.js&lt;/code&gt;), falling back to model, browser, then a generic string. The arrival notifications PR (#74) merged today, 2026-11-12.&lt;/li&gt;
&lt;li&gt;The design doc for group rooms (#75, merged the same day) sets the default room name to "'s room", using this label.&lt;/li&gt;
&lt;li&gt;1:1 pairing refers to the other device by its name (&lt;code&gt;usePairFlow.js&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is still weak
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Only 576 combos per room. Past that the code appends a suffix number.&lt;/li&gt;
&lt;li&gt;Uniqueness is checked when you connect or reroll. The name is a label, not authentication.&lt;/li&gt;
&lt;li&gt;The label is not covered by the password lock. The lock protects the file (and text), but the sender's label, file size, and send time stay visible in the list.&lt;/li&gt;
&lt;li&gt;Files uploaded through the quick-send API have no label, so the arrival notification falls back to a model name like "iPhone" (&lt;code&gt;routes/quickSend.ts&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;The reroll cooldown lives in server memory per socket, so a new socket starts a fresh cooldown.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Open &lt;a href="https://www.clipboardapp.org" rel="noopener noreferrer"&gt;https://www.clipboardapp.org&lt;/a&gt; on two devices and the names show up right away. If something feels off, there is a "건의하기" (feedback) link at the bottom of the site, and I would like to hear it.&lt;/p&gt;

&lt;p&gt;Originally published in Korean on velog: &lt;a href="https://velog.io/@ch_kang/%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%97%86%EC%9D%B4-%EA%B8%B0%EA%B8%B0-%EA%B5%AC%EB%B6%84%ED%95%98%EA%B8%B0-%EB%8F%99%EB%AC%BC%ED%98%95%EC%9A%A9%EC%82%AC-%EB%9E%9C%EB%8D%A4-%EB%8B%89%EB%84%A4%EC%9E%84-576%EA%B0%9C%EB%A1%9C-%EB%B3%B4%EB%82%B8-%EC%82%AC%EB%9E%8C-%ED%91%9C%EC%8B%9C-Socket.IO-%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;/p&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>vue</category>
    </item>
    <item>
      <title>Knowing the roomId Was Enough to List and Delete Files: Adding HMAC Room Tokens to a Socket.IO App</title>
      <dc:creator>CokeBear</dc:creator>
      <pubDate>Wed, 04 Nov 2026 23:09:48 +0000</pubDate>
      <link>https://dev.to/kangchanghwan/knowing-the-roomid-was-enough-to-list-and-delete-files-adding-hmac-room-tokens-to-a-socketio-app-3fkp</link>
      <guid>https://dev.to/kangchanghwan/knowing-the-roomid-was-enough-to-list-and-delete-files-adding-hmac-room-tokens-to-a-socketio-app-3fkp</guid>
      <description>&lt;p&gt;I'm building a no-login online clipboard: devices on the same network land in the same room, and files shared there go through Cloudflare R2. This post is about locking the room's file API, which turned out to rest on a bad assumption. You can try the app here: &lt;a href="https://www.clipboardapp.org" rel="noopener noreferrer"&gt;https://www.clipboardapp.org&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;The file endpoints looked like &lt;code&gt;/api/r2/files/:roomId&lt;/code&gt;. Send a roomId, get the file list. Send a roomId and a name, delete the file. Same for upload URL presigning.&lt;/p&gt;

&lt;p&gt;The design assumed the roomId was effectively a secret. IP-based room IDs are an HMAC of the client IP with a server secret, so you can't guess one. But the roomId also shows up in the public file 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="c1"&gt;// frontend/src/services/r2Service.js&lt;/span&gt;
&lt;span class="nf"&gt;getFileUrl&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;fileName&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="s2"&gt;`&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;publicUrl&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;roomId&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;fileName&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;Anyone who receives a QR code or download link learns the roomId, and can call the list/delete API directly. The fix commit says it plainly: "require a room token on APIs that allowed list/upload/delete with only a roomId" (2026-09-29, PR #35). I have no evidence anyone abused it. It was open by construction, so I closed it. I built this together with Claude Code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: prove you're actually in the room
&lt;/h2&gt;

&lt;p&gt;The server signs a statement that "this socket connection is in that room", and the REST API requires it. The roomId can leak in links. The token never goes in a URL.&lt;/p&gt;

&lt;p&gt;Format: &lt;code&gt;v1.&amp;lt;expiry unix seconds&amp;gt;.&amp;lt;HMAC-SHA256 base64url&amp;gt;&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;// backend/src/utils/roomToken.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sign&lt;/span&gt; &lt;span class="o"&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;exp&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="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="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="s2"&gt;`room-token:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;VERSION&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;roomId&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;exp&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="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;base64url&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;The &lt;code&gt;room-token:v1:&lt;/code&gt; prefix keeps this HMAC separate from the one that derives IP room IDs. Default lifetime is 2 hours (&lt;code&gt;ROOM_TOKEN_TTL_SEC&lt;/code&gt;). Verification checks shape, then signature with &lt;code&gt;timingSafeEqual&lt;/code&gt;, then expiry.&lt;/p&gt;

&lt;p&gt;Tokens are issued on socket connect, only for rooms the server already put that socket in:&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;// backend/src/handlers/socketHandlers.ts&lt;/span&gt;
&lt;span class="nx"&gt;roomTokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;issueRoomTokens&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;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="nx"&gt;ipRoomId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;getPrivateRooms&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clients can't ask for a token for a room of their choosing. If a device moves networks and reconnects, it can't get a token for its old IP room anymore.&lt;/p&gt;

&lt;p&gt;On the REST side it's one Express middleware per source of roomId:&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;// backend/src/server.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;roomIdFromParams&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;requireRoomToken&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;roomIdFromBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;requireRoomToken&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;List, size, delete, upload-URL signing and the text queue use it. Download-URL signing is deliberately left open, because people with a share link need it. A code comment notes you must know the exact file name, so it's the same level of access as the public URL.&lt;/p&gt;

&lt;p&gt;The frontend keeps tokens in memory only (no localStorage, no 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="c1"&gt;// frontend/src/services/roomTokenStore.js&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;roomAuthHeaders&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&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;getRoomToken&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;token&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;X-Room-Token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;token&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;It refreshes over a socket event (&lt;code&gt;room-tokens&lt;/code&gt;) when under 5 minutes remain. Expiry is computed as "time received + ttl" on the client, so a wrong device clock doesn't matter. On a 401 it drops the token, refreshes, and retries once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rolling it out: report-only mode
&lt;/h2&gt;

&lt;p&gt;Frontend and backend deploy separately, and old tabs stay open. So I added a switch:&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;// backend/src/middleware/roomAuth.ts&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;isRoomTokenEnforced&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="o"&gt;=&amp;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_TOKEN_ENFORCE&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;false&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="nf"&gt;isRoomTokenEnforced&lt;/span&gt;&lt;span class="p"&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;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[RoomAuth] token &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (report-only mode, allowed) ...`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;next&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;ROOM_TOKEN_ENFORCE=false&lt;/code&gt;, bad or missing tokens pass and only a warning is logged. You can watch the logs before enforcing, and roll back with an env var instead of a code revert.&lt;/p&gt;

&lt;p&gt;One honest note: &lt;strong&gt;the code default is enforcing.&lt;/strong&gt; Only the exact string &lt;code&gt;'false'&lt;/code&gt; turns on report-only. Nothing in the repo's Dockerfile, vercel.json or CI config sets this variable, so if you do nothing, it enforces. Forgetting the switch fails closed, not open.&lt;/p&gt;

&lt;p&gt;The frontend is also safe to ship first. If the server has never issued a token (old backend), it sends no header at all, so CORS preflight isn't triggered:&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;issuedByServer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tests (&lt;code&gt;roomAuth.api.test.ts&lt;/code&gt;) cover: 401 without a token, another room's token rejected, an expired token returning &lt;code&gt;ROOM_TOKEN_EXPIRED&lt;/code&gt;, report-only passthrough, and preflight allowing &lt;code&gt;X-Room-Token&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's still weak
&lt;/h2&gt;

&lt;p&gt;Only things I can confirm from the code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's a bearer token.&lt;/strong&gt; It isn't bound to the socket. If it leaks, it works until expiry (2h default). There's no server-side revocation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The shared global room hands a token to everyone&lt;/strong&gt; (&lt;code&gt;socket.globalRoomId&lt;/code&gt;). That's by design, since it's a public room, but there the token restricts nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secret fallback.&lt;/strong&gt; If neither &lt;code&gt;ROOM_TOKEN_SECRET&lt;/code&gt; nor &lt;code&gt;ROOM_ID_SECRET&lt;/code&gt; is set, it falls back to &lt;code&gt;'dev-insecure-secret'&lt;/code&gt;. A missing &lt;code&gt;ROOM_ID_SECRET&lt;/code&gt; logs an error at startup; I didn't find an equivalent warning for the token secret alone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Download-URL signing is open&lt;/strong&gt;, so the defense there is the file name. The public file URL itself is an R2 public address and is outside this token.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The token closes the "know the roomId, see the list" path. Keeping file contents private is a separate problem, which I'm tackling with in-browser encryption.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;If you try it and something feels off, tell me. There's a feedback ("건의하기") link at the bottom of the site. &lt;a href="https://www.clipboardapp.org" rel="noopener noreferrer"&gt;https://www.clipboardapp.org&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Originally published in Korean on velog. &lt;a href="https://velog.io/@ch_kang/roomId%EB%A7%8C%EC%9C%BC%EB%A1%9C-%ED%8C%8C%EC%9D%BC-%EB%AA%A9%EB%A1%9D-%EC%A1%B0%ED%9A%8C%EC%82%AD%EC%A0%9C%EA%B0%80-%EB%90%98%EB%8D%98-API%EC%97%90-HMAC-%EB%A3%B8-%ED%86%A0%ED%81%B0-%EB%B6%99%EC%9D%B4%EA%B8%B0-Socket.IO-%EC%9D%B8%EC%A6%9D-%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;/p&gt;

</description>
      <category>webdev</category>
      <category>node</category>
      <category>security</category>
      <category>socketio</category>
    </item>
    <item>
      <title>From a 100 MB cap to 5 GB: Cloudflare R2 multipart upload with resume and cancel in the browser</title>
      <dc:creator>CokeBear</dc:creator>
      <pubDate>Wed, 28 Oct 2026 23:08:55 +0000</pubDate>
      <link>https://dev.to/kangchanghwan/from-a-100-mb-cap-to-5-gb-cloudflare-r2-multipart-upload-with-resume-and-cancel-in-the-browser-1kd2</link>
      <guid>https://dev.to/kangchanghwan/from-a-100-mb-cap-to-5-gb-cloudflare-r2-multipart-upload-with-resume-and-cancel-in-the-browser-1kd2</guid>
      <description>&lt;p&gt;I built Clipboard Share (&lt;a href="https://www.clipboardapp.org" rel="noopener noreferrer"&gt;https://www.clipboardapp.org&lt;/a&gt;), a no-login web clipboard for moving files between your phone and PC. For a while, files were capped at 100 MB because everything went up as a single presigned PUT to Cloudflare R2. That's simple, but a single dropped connection on a big file means starting over. This post covers how I replaced that for large files with multipart upload, resume, and cancel. Most of the code was written with Claude Code. The changes shipped as PRs #41 to #43 on 2026-10-01.&lt;/p&gt;

&lt;p&gt;Every number below comes from the current master branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of it
&lt;/h2&gt;

&lt;p&gt;Files up to 100 MB still use one presigned PUT. Above that, the browser splits the file and talks to five small endpoints: &lt;code&gt;create&lt;/code&gt;, &lt;code&gt;sign&lt;/code&gt;, &lt;code&gt;parts&lt;/code&gt;, &lt;code&gt;complete&lt;/code&gt;, &lt;code&gt;abort&lt;/code&gt;. Part bytes go straight from the browser to R2. My server only signs URLs and tracks state. (File data goes through R2, it isn't peer to peer.)&lt;/p&gt;

&lt;p&gt;Limits: 5 GB per file, 10 GB per room, 20 GB per IP per day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part size, concurrency, retries
&lt;/h2&gt;

&lt;p&gt;From &lt;code&gt;frontend/src/services/multipartUploader.js&lt;/code&gt;:&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;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;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_SIGN_BATCH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&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;MAX_PART_RETRIES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&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;RETRY_BASE_DELAY_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;
&lt;span class="cm"&gt;/** signed URLs last 1h; re-sign after 50 min */&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;SIGN_TTL_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Parts are 64 MiB, so a 5 GB file is about 80 parts, far below the 10,000-part limit. Concurrency depends on the user agent: 4 parts in parallel on desktop, 2 on mobile. I picked those conservatively and haven't tuned them against real networks.&lt;/p&gt;

&lt;p&gt;Each part gets up to 4 retries with 1s/2s/4s/8s backoff. Presigned URLs are valid for an hour on the server. The client stops trusting a URL after 50 minutes, and a 403/400 on a part drops its cached URL so the next attempt re-signs. Signing is batched, up to 20 parts per request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Offline pause
&lt;/h2&gt;

&lt;p&gt;A failure while &lt;code&gt;navigator.onLine === false&lt;/code&gt; doesn't consume a retry. The worker just waits for the &lt;code&gt;online&lt;/code&gt; event:&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;function&lt;/span&gt; &lt;span class="nf"&gt;waitForOnline&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="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onLine&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;false&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;resolve&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;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;online&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;online&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handler&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;h2&gt;
  
  
  Resume with localStorage
&lt;/h2&gt;

&lt;p&gt;When an upload starts, the session (&lt;code&gt;uploadId&lt;/code&gt;, &lt;code&gt;key&lt;/code&gt;, &lt;code&gt;partSize&lt;/code&gt;, &lt;code&gt;createdAt&lt;/code&gt;) is saved under a fingerprint key:&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;const&lt;/span&gt; &lt;span class="nx"&gt;fingerprintOf&lt;/span&gt; &lt;span class="o"&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;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;STORAGE_PREFIX&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="s2"&gt;|&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;name&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;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="s2"&gt;|&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;lastModified&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the user picks the same file again in the same room, the client asks the server (&lt;code&gt;parts&lt;/code&gt;, which calls R2's ListParts) what already landed, and skips parts whose number and byte size match. If the upload no longer exists server-side, it clears the saved entry and starts fresh. Entries older than 24 hours are pruned on startup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Server state
&lt;/h2&gt;

&lt;p&gt;Upload records live in an in-memory Map (&lt;code&gt;backend/src/utils/multipartStore.ts&lt;/code&gt;) for 25 hours. A record holds room, key, size, part size, part count, and the daily-quota key. It lets the server compute each part's expected length when signing, reject an &lt;code&gt;uploadId&lt;/code&gt; that belongs to another room, and count in-flight reservations toward the room limit.&lt;/p&gt;

&lt;p&gt;If the server restarts, the record is gone. When the client sends &lt;code&gt;size&lt;/code&gt; along, the server rebuilds the record and marks it &lt;code&gt;recovered: true&lt;/code&gt;. Recovered records don't reserve daily quota. On &lt;code&gt;complete&lt;/code&gt;, the server also re-reads the object's real size and deletes it with a 422 &lt;code&gt;SIZE_MISMATCH&lt;/code&gt; if it doesn't match what was declared.&lt;/p&gt;

&lt;p&gt;The per-IP daily quota (&lt;code&gt;backend/src/utils/dailyQuota.ts&lt;/code&gt;) keys on &lt;code&gt;KST date : HMAC(IP)&lt;/code&gt; so raw IPs aren't stored. Aborting refunds everything except the bytes that actually uploaded:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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="nx"&gt;recovered&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;releaseDailyQuota&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;ipKey&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;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="nx"&gt;record&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="nx"&gt;uploaded&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;
  
  
  Cleaning up unfinished uploads
&lt;/h2&gt;

&lt;p&gt;Incomplete parts still take up storage. So when a room is cleared, in-flight multipart uploads are aborted first (&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="c1"&gt;// in-flight multipart uploads hold storage with unfinished parts, abort them first&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;abortedCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&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;abortRoomMultipartUploads&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="nf"&gt;deleteRecordsForRoom&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That method pages through &lt;code&gt;ListMultipartUploads&lt;/code&gt; for the room prefix and aborts each upload. A failure on one logs a warning and the loop continues.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cancel
&lt;/h2&gt;

&lt;p&gt;Each file gets a handle with states &lt;code&gt;queued&lt;/code&gt;, &lt;code&gt;running&lt;/code&gt;, &lt;code&gt;committed&lt;/code&gt;. Queued files cancel immediately. Running ones are cut with an &lt;code&gt;AbortController&lt;/code&gt;. Once &lt;code&gt;complete&lt;/code&gt; has been sent (&lt;code&gt;committed&lt;/code&gt;), cancel is ignored so you never end up with a half-finished file. On cancel, the uploader clears the localStorage entry and fires &lt;code&gt;abort&lt;/code&gt; without awaiting it, so the UI doesn't hang on the network call. A comment in the code says an R2 lifecycle rule would clean up if that abort fails. I haven't verified that rule from this repo, so that's something I still need to check in the R2 console.&lt;/p&gt;

&lt;p&gt;Uploads that fail for network reasons deliberately keep their saved entry, so picking the same file again resumes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's still rough
&lt;/h2&gt;

&lt;p&gt;Only things visible in the code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Large files go one at a time; parallelism is only across parts within a file.&lt;/li&gt;
&lt;li&gt;Resume needs the user to re-select the file, and the fingerprint is name + size + lastModified, so a renamed or edited file starts over.&lt;/li&gt;
&lt;li&gt;Upload records and daily usage are in server memory. A restart resets the quota count.&lt;/li&gt;
&lt;li&gt;The quota is per IP, so people on the same shared network share 20 GB a day.&lt;/li&gt;
&lt;li&gt;Part size and concurrency are fixed, not adaptive.&lt;/li&gt;
&lt;li&gt;If the ETag header can't be read (CORS misconfiguration), the part fails immediately instead of retrying.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you try it, I'd like to hear what breaks. There's a feedback button at the bottom of the site ("건의하기"). &lt;a href="https://www.clipboardapp.org" rel="noopener noreferrer"&gt;https://www.clipboardapp.org&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>node</category>
      <category>showdev</category>
    </item>
    <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>
