<?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: Sike Ren</title>
    <description>The latest articles on DEV Community by Sike Ren (@sike_ren_f38951df83469817).</description>
    <link>https://dev.to/sike_ren_f38951df83469817</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%2F4065087%2Fdfdd209b-554e-4758-83fc-614ce28e35f3.jpg</url>
      <title>DEV Community: Sike Ren</title>
      <link>https://dev.to/sike_ren_f38951df83469817</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sike_ren_f38951df83469817"/>
    <language>en</language>
    <item>
      <title>No accounts meant no license server — so my $19 one-time SaaS signs its keys with ECDSA P-256</title>
      <dc:creator>Sike Ren</dc:creator>
      <pubDate>Thu, 20 Aug 2026 19:18:12 +0000</pubDate>
      <link>https://dev.to/sike_ren_f38951df83469817/no-accounts-meant-no-license-server-so-my-19-one-time-saas-signs-its-keys-with-ecdsa-p-256-502o</link>
      <guid>https://dev.to/sike_ren_f38951df83469817/no-accounts-meant-no-license-server-so-my-19-one-time-saas-signs-its-keys-with-ecdsa-p-256-502o</guid>
      <description>&lt;p&gt;I made a product promise I didn't think through: &lt;strong&gt;no accounts, no subscription, $19 one-time&lt;/strong&gt;. The product is Resbetter — paste your resume, get the interview questions it will trigger, with STAR answers already built. The "no accounts" promise made sense for privacy (there's nothing to breach if there's nothing stored). It also closed a door: with no user table, there is no server-side place to record "this person paid". Which means a $19 license key has to prove itself &lt;em&gt;without any server lookup&lt;/em&gt; — and a key that works offline is a key someone can study, patch around, and share.&lt;/p&gt;

&lt;p&gt;Here's the architecture that fell out of that constraint.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constraint chain
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No accounts&lt;/strong&gt; → no entitlement store on the server → the key must self-verify.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privacy by construction&lt;/strong&gt; → the resume is parsed in the browser, only plain text is ever uploaded, held ≤24h, then deleted → there's no user profile to attach a key to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One-time $19&lt;/strong&gt; → no recurring billing → no subscription token to check on every request.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The key had to be a self-contained proof: &lt;em&gt;something only I can produce, that the browser can check offline, that the server can check again.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Signed keys, not random strings
&lt;/h2&gt;

&lt;p&gt;A random "XXXX-XXXX-XXXX" license key is checkable server-side only if the server has a list. An &lt;strong&gt;asymmetric signature&lt;/strong&gt; is checkable anywhere — the public half ships in the bundle, the private half never leaves my machine.&lt;/p&gt;

&lt;p&gt;The key format is deliberately boring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"RN1-" + base64url( version(1 byte) || serial(uint32 BE) || ECDSA-P256-SHA256 signature(64 bytes) )
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;5 bytes of payload, 64 bytes of signature. Nothing clever — the cleverness would be in the tooling around it.&lt;/p&gt;

&lt;p&gt;Keys are minted offline with a small Node script, using &lt;code&gt;node:crypto&lt;/code&gt; (which wraps OpenSSL, so no extra dependency):&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;webcrypto&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;VERSION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PAYLOAD_BYTES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PREFIX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;RN1-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;payloadFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;serial&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;payload&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;PAYLOAD_BYTES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;VERSION&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;payload&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;setUint32&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="nx"&gt;serial&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;payload&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;pair&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ECDSA&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;namedCurve&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;P-256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sign&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;verify&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;privateKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;privateKey&lt;/span&gt;
&lt;span class="c1"&gt;// privateKey is exported as JWK and written to .secrets/licence-key.jwk&lt;/span&gt;
&lt;span class="c1"&gt;// (mode 0600). It is the one file whose loss cannot be recovered from:&lt;/span&gt;
&lt;span class="c1"&gt;// without it, no further key can ever be signed.&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;let&lt;/span&gt; &lt;span class="nx"&gt;serial&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="nx"&gt;serial&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;serial&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;payloadFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;serial&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;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sign&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;ECDSA&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="nx"&gt;privateKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;payload&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;bytes&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;PAYLOAD_BYTES&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&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;bytes&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="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;signature&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;PAYLOAD_BYTES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PREFIX&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&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="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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The serial isn't a secret — it's the &lt;em&gt;identifier&lt;/em&gt;. It's the only stable handle the server has for "this key", which is how a redemption ledger can count off it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying in the browser, network off
&lt;/h2&gt;

&lt;p&gt;The client bundles the public key — 65 bytes, safe to ship:&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;LICENSE_PUBLIC_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;BDHuQN7mU6QpfyASU9h15DW1R9UNdvRV5F7XZdpOKRYt2ShXhM0IugtJ6HQ2NSJOmVB3z9Om_t8L8VV_Ci-btSc&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verification is stock Web Crypto:&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rawKey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&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;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;key&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;5&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;RN1-&lt;/span&gt;&lt;span class="dl"&gt;"&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="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&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;body&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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fromBase64Url&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="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;bytes&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;64&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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subarray&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;5&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;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&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;valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&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="s2"&gt;ECDSA&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="s2"&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;publicKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;valid&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;span class="c1"&gt;// payload = version(1) || serial(uint32 BE)&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;DataView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&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="mi"&gt;1&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;getUint32&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="kc"&gt;false&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 it. &lt;code&gt;crypto.subtle.verify&lt;/code&gt; is available in every modern browser over &lt;code&gt;https&lt;/code&gt; (and even &lt;code&gt;file://&lt;/code&gt;). Enter the key with the network switched off and it still validates. The serial it returns is remembered in &lt;code&gt;localStorage&lt;/code&gt; so the unlock UI doesn't re-prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the server re-verifies the same signature
&lt;/h2&gt;

&lt;p&gt;An offline-verifiable key has a structural weakness: it's a &lt;em&gt;client-side&lt;/em&gt; check. A user can patch the bundle, delete the check, or just read the source. So the browser check is the &lt;strong&gt;fast path, not the gate&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The gate is the Worker. When you redeem a key, the server verifies the &lt;em&gt;same signature&lt;/em&gt; with the &lt;em&gt;same public key&lt;/em&gt;, and only then mints access:&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;// functions/api/unlock.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;serial&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;verifyLicenseSerial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;licenseKey&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;serial&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;That key isn't valid. Check it and try again.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Redemption ledger, keyed by serial (KV)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ledgerKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`lic:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;serial&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;used&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&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;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PACK_KV&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;ledgerKey&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;used&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;MAX_REDEMPTIONS&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;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Key used too many times.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PACK_KV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ledgerKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;used&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth saying about that ledger:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;MAX_REDEMPTIONS = 5&lt;/code&gt;, not 1.&lt;/strong&gt; A buyer legitimately unlocks more than once (second device, a retry, a fresh run after the 24-hour document window closes). But each redemption spends a full paid-tier generation, so it can't be unbounded — one $19 key posted publicly would bill me forever. 5 is the ceiling where neither side is harmed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;KV has no compare-and-swap.&lt;/strong&gt; Two simultaneous unlocks can read the same count and both write count+1, losing one tick. That's the &lt;em&gt;right&lt;/em&gt; way to lose this race: a buyer is never wrongly locked out, and the ceiling still holds within one extra redemption.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The public key being in the client is not a weakness to hide — it's the design. Ship the public half, keep the private half in &lt;code&gt;.secrets/&lt;/code&gt;, verify in two places, and a patched bundle still can't forge a key.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same constraint also moved parsing into the browser
&lt;/h2&gt;

&lt;p&gt;The "privacy by construction" promise didn't stop at license keys. The resume file itself never leaves the browser until it's been reduced to plain text — PDF via &lt;code&gt;pdfjs-dist&lt;/code&gt; (with CJK CMaps for Chinese resumes), DOCX via &lt;code&gt;mammoth&lt;/code&gt;, both dynamically imported so the landing page stays lean:&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;ext&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pdf&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getDocument&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;GlobalWorkerOptions&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="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pdfjs-dist&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;workerUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pdfjs-dist/build/pdf.worker.min.mjs?url&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;
  &lt;span class="nx"&gt;GlobalWorkerOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workerSrc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;workerUrl&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buf&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;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&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;pdf&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;getDocument&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;cMapUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/cmaps/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// CJK CID fonts need pdf.js's glyph CMaps&lt;/span&gt;
    &lt;span class="na"&gt;cMapPacked&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nx"&gt;promise&lt;/span&gt;
  &lt;span class="c1"&gt;// ...re-cluster pdf.js text items into rows, split into lines&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the extracted plain text is ever sent to the API — one request, held ≤24h, deleted. Which is exactly the claim that makes "no accounts" honest: there's nowhere to store a profile, so there's nothing to leak.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;crypto.subtle&lt;/code&gt; is unavailable on insecure origins.&lt;/strong&gt; The whole scheme silently dies over plain HTTP. Ship HTTPS or ship nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The offline key invites reverse-engineering.&lt;/strong&gt; The public key in the bundle is public, and the check can be patched out. The server-side re-verification closes the forgery hole; it does not stop a determined freeloader from cracking &lt;em&gt;their own&lt;/em&gt; copy. For a $19 one-time product, that's an acceptable line — the redemption cap is what actually keeps a shared key from becoming an unlimited leak.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sign more than 5 bytes.&lt;/strong&gt; Version + serial is minimal by design, but if you want the key to encode the buyer's email or a product tier, ECDSA signs whatever you put in front of it — the payload size just grows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lesson I keep re-learning: &lt;strong&gt;product constraints are architecture constraints.&lt;/strong&gt; "No accounts" didn't mean "smaller backend" — it meant &lt;em&gt;no backend entitlement at all&lt;/em&gt;, which is a strictly harder problem. But it's also why the system holds together: every part re-verifies what the part before it trusted, and nothing trusts the client.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>privacy</category>
    </item>
    <item>
      <title>Tattoo data taught me to delete every third-party script — and build analytics from a SQLite table</title>
      <dc:creator>Sike Ren</dc:creator>
      <pubDate>Tue, 18 Aug 2026 18:55:06 +0000</pubDate>
      <link>https://dev.to/sike_ren_f38951df83469817/tattoo-data-taught-me-to-delete-every-third-party-script-and-build-analytics-from-a-sqlite-table-7ng</link>
      <guid>https://dev.to/sike_ren_f38951df83469817/tattoo-data-taught-me-to-delete-every-third-party-script-and-build-analytics-from-a-sqlite-table-7ng</guid>
      <description>&lt;p&gt;&lt;em&gt;The post is a casual chain, not a product pitch. Blackwork is only the evidence.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;A tattoo booking is one of the most sensitive payloads a web app can touch. Not in the "payment card" sense — in the &lt;em&gt;body&lt;/em&gt; sense. The booking form asks for placement (which arm, which rib), size, reference photos, a deposit amount, and a phone number. That's more personally identifying than a bank transfer: it describes a body and a plan to permanently modify it.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://blackwork.app/" rel="noopener noreferrer"&gt;Blackwork&lt;/a&gt;, a booking/deposit/CRM system for tattoo studios. The studio's clients submit that data. And the default SaaS stack would have handed a copy of it to a dozen companies I never met.&lt;/p&gt;

&lt;p&gt;Here's the chain of decisions that ended with: &lt;strong&gt;my app ships exactly one inline script.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The default stack leaks
&lt;/h2&gt;

&lt;p&gt;A fresh Next.js app, following the path of least resistance, ships a pile of third-party requests:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GA4 or Plausible or PostHog for analytics&lt;/li&gt;
&lt;li&gt;Sentry for errors&lt;/li&gt;
&lt;li&gt;Hotjar or Microsoft Clarity for session replay&lt;/li&gt;
&lt;li&gt;Google Fonts / a font CDN&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of those is a company that now receives a copy of every page load — IP, user agent, behavior, and whatever ended up in the DOM. For a page that collects tattoo references, that's a leak nobody asked for.&lt;/p&gt;

&lt;p&gt;Blackwork's landing page makes a promise: &lt;em&gt;"No analytics scripts, no tracking. Your data is yours."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The mistake would be treating that as marketing copy. I treated it as an architecture constraint. Once it's a constraint, the downstream decisions stop being judgment calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Consequence one: exactly one inline script
&lt;/h2&gt;

&lt;p&gt;Here is the entire client-side script footprint of the app — a three-line theme toggler that runs before first paint to avoid a flash:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blackwork_theme&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;dark&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dark&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toggle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dark&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;colorScheme&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dark&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dark&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;light&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;colorScheme&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;light&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="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 it. Fonts are self-hosted at build time by &lt;code&gt;next/font&lt;/code&gt;. No &lt;code&gt;&amp;lt;Script&amp;gt;&lt;/code&gt;, no &lt;code&gt;gtag&lt;/code&gt;, no error SDK, no widget. If you open DevTools → Network on the public pages, the request list is mostly your own assets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Consequence two: analytics became a table
&lt;/h2&gt;

&lt;p&gt;But I still wanted to know the same things a founder needs: does anyone land on the pricing page? Do launch channels convert? So I wrote the smallest possible first-party tracker — one route, one INSERT into the same SQLite the app already runs on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/track/route.ts — fire-and-forget, returns 204 so the client pays nothing&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;POST&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="nx"&gt;Request&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;store&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;cookies&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;vid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;vid&lt;/span&gt;&lt;span class="dl"&gt;"&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;vid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;vid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;vid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;vid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;httpOnly&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;sameSite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lax&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;secure&lt;/span&gt;&lt;span class="p"&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;NODE_ENV&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;maxAge&lt;/span&gt;&lt;span class="p"&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;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;365&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;let&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="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="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&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;path&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="c1"&gt;// malformed body — still count it as a pageview&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;trackEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eventForPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;vid&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;Response&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;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;204&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;trackEvent&lt;/code&gt; is a prepared INSERT into a &lt;code&gt;site_events&lt;/code&gt; table, wrapped in a try/catch so tracking can never break a page:&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;function&lt;/span&gt; &lt;span class="nf"&gt;trackEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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;path&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;visitorId&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="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;db&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INSERT INTO site_events (event, path, visitor_id, created_at) VALUES (?, ?, ?, ?)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;visitorId&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="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="c1"&gt;// best-effort: tracking failure is never fatal&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 visitor id is an httpOnly cookie, so a visitor can't be fingerprinted from JS, and it's only ever stored first-party.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dashboard reads the same table
&lt;/h2&gt;

&lt;p&gt;The analytics dashboard at &lt;code&gt;/analytics/[secret]&lt;/code&gt; is a server component gated by an env var. No client bundle, no chart library on the page — the queries do the work. The one that surprised me by being pleasant to write is distinct daily visitors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'%Y-%m-%d'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'unixepoch'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'localtime'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;visitor_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;site_events&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;day&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus pageviews, signups, logins, bookings, and subscription states from the same database. The whole thing is under 200 lines of queries and a zero-filled 30-day grid.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the constraint buys
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No cookie banner.&lt;/strong&gt; The &lt;code&gt;vid&lt;/code&gt; cookie is first-party, httpOnly, functional. There's nothing to consent to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A one-paragraph privacy policy.&lt;/strong&gt; "What Blackwork stores, and what we deliberately do not do with your data" — the page honestly describes a very short list of stored things.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No GDPR vendor list.&lt;/strong&gt; The privacy policy doesn't need a table of 40 processors. There is one processor: our own server.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The part I like least
&lt;/h2&gt;

&lt;p&gt;The constraint has a cost, and it's real:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No session replay, no heatmaps, no real-time.&lt;/strong&gt; If a visitor bounces on the pricing page, I know the count went up but not where their cursor went.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Crash telemetry is a &lt;code&gt;console.log&lt;/code&gt; on a server nobody is watching.&lt;/strong&gt; The trade: the studio's data never leaves the request path. If the app breaks, the app breaks loudly and locally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attribution is UTM query params only.&lt;/strong&gt; "Which directory sent visitors" is answerable. "What's the LTV per channel" is a spreadsheet exercise.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If I ever need real funnel analysis, I'll outgrow this — and that's fine. The point of a constraint isn't to be the best possible analytics setup. It's to make the privacy promise structurally true instead of rhetorically true.&lt;/p&gt;

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

&lt;p&gt;"Zero third-party scripts" reads like a slogan and works like a spec. Every feature that wants a script now has to justify crossing the boundary: it has to be first-party, or it doesn't ship.&lt;/p&gt;

&lt;p&gt;The weirdest outcome: a privacy constraint that most products treat as a legal paragraph turned into the product's strongest feature. Studios don't ask "is this compliant?" — they ask "who sees my clients' references?" and the honest answer is &lt;em&gt;one server, ours&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;If you're building a vertical SaaS where users hand over data they'd be embarrassed to leak, try deleting every third-party script for a week. The panic lasts a day. The clarity lasts longer.&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>nextjs</category>
      <category>webdev</category>
      <category>sql</category>
    </item>
    <item>
      <title>I ship an Ed25519 public key in my JavaScript bundle and call it a licence</title>
      <dc:creator>Sike Ren</dc:creator>
      <pubDate>Fri, 14 Aug 2026 13:48:57 +0000</pubDate>
      <link>https://dev.to/sike_ren_f38951df83469817/i-ship-an-ed25519-public-key-in-my-javascript-bundle-and-call-it-a-licence-4k8h</link>
      <guid>https://dev.to/sike_ren_f38951df83469817/i-ship-an-ed25519-public-key-in-my-javascript-bundle-and-call-it-a-licence-4k8h</guid>
      <description>&lt;p&gt;I build a Bates-numbering tool for litigation PDFs. Bates numbers are the unique page identifiers stamped across a document production — &lt;code&gt;DEF-000123&lt;/code&gt;, &lt;code&gt;DEF-000124&lt;/code&gt;, running continuously — so a court or an opposing party can point at one exact page. It runs entirely in the browser.&lt;/p&gt;

&lt;p&gt;The reason it runs entirely in the browser is the part people don't guess, and it's the part that forced every interesting decision downstream, including the licence check.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constraint: the documents can't leave the device
&lt;/h2&gt;

&lt;p&gt;The PDFs this tool processes are privileged. Under a protective order, the court can literally forbid the material from leaving the machine it was produced on — "highly confidential — attorneys' eyes only" is a phrase with teeth. Uploading to a server is not a "trusted" option, it's a non-option. A hosted SaaS that asks you to "securely upload your privileged documents for processing" is asking the wrong question.&lt;/p&gt;

&lt;p&gt;So the tool parses and rewrites PDFs locally with pdf-lib, in the tab, and writes the finished files through the File System Access API to a folder you pick. Your originals are never touched; output goes somewhere else, because stamping a PDF can't be undone.&lt;/p&gt;

&lt;p&gt;You can verify that claim instead of trusting it. The only network requests the page ever makes are six counters — page view, batch finished, check finished, checkout opened, licence verified, error type. Each is a ~30-byte JSON object, no cookie, no identifier, no filename, no page count:&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;// src/lib/analytics.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ENDPOINT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.bateskit.com/e&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;view&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;run&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;check&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;buy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unlock&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;send&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&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;blob&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;Blob&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/plain;charset=UTF-8&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="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sendBeacon&lt;/span&gt;&lt;span class="p"&gt;?.(&lt;/span&gt;&lt;span class="nx"&gt;ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;blob&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;void&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&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="na"&gt;keepalive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no-cors&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Block the endpoint and the tool is identical. Or load the page once, disconnect from the network, and run a batch — it works. That's the property a hosted tool can't offer, and it's the whole reason to use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The consequence: there is no server to ask "did they pay?"
&lt;/h2&gt;

&lt;p&gt;Here's the cascade. The tool can't have a server, because the documents can't go to one. And if there's no server, there's no licence server. Every SaaS licence check works by asking the server whether a key is valid, because the server is the trusted party. Mine can't have one — and the person doing production QC might be on a machine that has &lt;em&gt;never&lt;/em&gt; been on the network. Air-gapped is not a hypothetical for this audience.&lt;/p&gt;

&lt;p&gt;So the licence check has to run offline. That means the verification logic ships in the page, and the verification key ships in the page. The key format is &lt;code&gt;BK1.&amp;lt;payload&amp;gt;.&amp;lt;signature&amp;gt;&lt;/code&gt;, both halves base64url, and the verification is a single WebCrypto call — no crypto library at all:&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;// src/engine/license.ts&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;verifyLicense&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;LicenseInfo&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;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;key&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="nf"&gt;replaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parts&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;3&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BK1&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fromB64Url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parts&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fromB64Url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parts&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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="nf"&gt;fromB64Url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PUBLIC_KEY_B64&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Ed25519&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;verify&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;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Ed25519&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&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;ok&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;order&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;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&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="na"&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The payload is just the order reference. The public key is a 32-byte constant sitting in the bundle:&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;PUBLIC_KEY_B64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;RzWyRhHf12xzr32jodAg0CiconSeTLDz6_E6n_xpr0M&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The signing half of the pair never leaves the signing machine. &lt;code&gt;crypto.subtle.verify&lt;/code&gt; is available in every browser, so this has zero dependencies and needs no network at any point.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I had to accept: the wall doesn't exist
&lt;/h2&gt;

&lt;p&gt;Now the honest part. I can't actually stop anyone from pirating this, and neither can anyone building the same thing. The public key is public by construction — you cannot hide the verifying half of a signature, because the verifier is the whole point of the mechanism. Anyone can open the bundle, extract the key, and sign their own payloads. Or simply read the verification code and patch the result. There is no technical wall here. There is no wall that could exist, offline, in shipped JavaScript.&lt;/p&gt;

&lt;p&gt;The model I landed on works by accepting that and choosing which failure mode I &lt;em&gt;don't&lt;/em&gt; want. A licence that phones home to a server can't be cracked, but it can lock out a paying customer the day the server goes away — or the day I get bored and stop paying for the domain. An offline-verified licence can be extracted, but it keeps working forever, air-gapped, with no account, no telemetry, and no one to go out of business.&lt;/p&gt;

&lt;p&gt;For a $19 one-time purchase used four times a year, "keeps working if the maker vanishes" is worth more than "hard to pirate." So the free tier is one file up to 25 pages with every feature enabled — genuinely useful, enough to validate the output on your own document — and the paid tier is a signed key that unlocks unlimited use. The licence isn't a fortress; it's a convenience unlock for people who'd rather pay $19 than extract a 32-byte key. That's the entire anti-piracy strategy, and it's a deliberate one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I'm telling you this
&lt;/h2&gt;

&lt;p&gt;Tools like this usually get dismissed as "just a wrapper" or — worse for a tool handling privileged documents — assumed to be secretly phoning home. The offline licence is my answer to both, but it only works as an answer because the verification code is &lt;em&gt;readable&lt;/em&gt;. Security through obscurity would be a contradiction here: the whole model depends on the user being able to look at the bundle and confirm the only cryptography is a signature check against a published key.&lt;/p&gt;

&lt;p&gt;If you're building a client-side tool that needs to charge for it, you'll hit the same wall. The instinct is to bolt on a server just for the licence. Before you do: ask whether the thing you lose — a customer who can never be locked out — is worth the piracy you think you're preventing. For a small one-time price on a tool your users run on machines that may never see the network, it probably isn't.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;BatesKit is at &lt;a href="https://bateskit.com" rel="noopener noreferrer"&gt;bateskit.com&lt;/a&gt;. It numbers a folder of PDFs, checks redaction and metadata and privilege markers, reconciles against your index, and writes a Summation-style load.dat — all locally, all read-only on the checks, $19 once.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>security</category>
      <category>privacy</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why SharedArrayBuffer forced me to delete every third-party script on my site</title>
      <dc:creator>Sike Ren</dc:creator>
      <pubDate>Thu, 06 Aug 2026 04:35:10 +0000</pubDate>
      <link>https://dev.to/sike_ren_f38951df83469817/why-sharedarraybuffer-forced-me-to-delete-every-third-party-script-on-my-site-4fa6</link>
      <guid>https://dev.to/sike_ren_f38951df83469817/why-sharedarraybuffer-forced-me-to-delete-every-third-party-script-on-my-site-4fa6</guid>
      <description>&lt;p&gt;I wanted multithreaded ffmpeg running in a browser tab. What I got, several steps&lt;br&gt;
  later, was a site with zero third-party scripts — no analytics vendor, no font CDN,&lt;br&gt;
  no embedded video, no chat widget. Not because I took a principled stand on privacy.&lt;br&gt;
  Because the platform stopped allowing them, and by then it was too late to argue.&lt;/p&gt;

&lt;p&gt;Here's the chain, because each link is obvious in isolation and the destination is not.&lt;/p&gt;
&lt;h2&gt;
  
  
  The chain
&lt;/h2&gt;

&lt;p&gt;ffmpeg is a C program that uses threads. Compiled to WebAssembly via Emscripten,&lt;br&gt;
  those threads become Web Workers, and the workers need to share one linear memory&lt;br&gt;
  with the main thread. Shared memory in a browser means one thing: &lt;code&gt;SharedArrayBuffer&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SharedArrayBuffer&lt;/code&gt; was restricted after Spectre, because a shared buffer plus a&lt;br&gt;
  high-resolution timer is a usable side-channel. Browsers brought it back behind a&lt;br&gt;
  gate: your document must be &lt;strong&gt;cross-origin isolated&lt;/strong&gt;. That means two response&lt;br&gt;
  headers on the document:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  Cross-Origin-Opener-Policy: same-origin
  Cross-Origin-Embedder-Policy: require-corp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Cloudflare Pages this is a plain text file at &lt;code&gt;public/_headers&lt;/code&gt;, no build step,&lt;br&gt;
  no framework config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    Cross-Origin-Opener-Policy: same-origin
    Cross-Origin-Embedder-Policy: require-corp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify it in the console, not by reading your config:&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="nx"&gt;crossOriginIsolated&lt;/span&gt;              &lt;span class="c1"&gt;// must be true&lt;/span&gt;
  &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;SharedArrayBuffer&lt;/span&gt;         &lt;span class="c1"&gt;// 'function'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;crossOriginIsolated&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;, ffmpeg.wasm silently falls back to&lt;br&gt;
  single-threaded and you spend an afternoon wondering why encoding is slow.&lt;/p&gt;

&lt;h2&gt;
  
  
  What COEP: require-corp actually does
&lt;/h2&gt;

&lt;p&gt;This is where the third-party scripts died. &lt;code&gt;require-corp&lt;/code&gt; says: every cross-origin&lt;br&gt;
  subresource must explicitly opt in to being embedded, either by sending&lt;br&gt;
  &lt;code&gt;Cross-Origin-Resource-Policy: cross-origin&lt;/code&gt; or by being loaded with CORS.&lt;/p&gt;

&lt;p&gt;Almost nothing on the open web sends CORP. So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Third-party analytics is gone.&lt;/strong&gt; A &lt;code&gt;&amp;lt;script src="https://vendor.example/a.js"&amp;gt;&lt;/code&gt;
is a no-cors request. No CORP header on the response, no script. Every hosted
analytics vendor I checked, self-hosted or not, fails this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google Fonts is gone.&lt;/strong&gt; The stylesheet request from &lt;code&gt;fonts.googleapis.com&lt;/code&gt; is
not CORS, so it never loads, so the &lt;code&gt;@font-face&lt;/code&gt; rules never arrive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embeds are gone.&lt;/strong&gt; A YouTube iframe, a demo video, an embedded widget — iframes
are subject to the same rule, and none of them send CORP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;COOP: same-origin&lt;/code&gt; breaks popup flows too.&lt;/strong&gt; It severs the &lt;code&gt;window.opener&lt;/code&gt;
relationship with cross-origin windows, which is exactly the channel that
popup-based OAuth uses to hand a token back to you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is a softer variant, &lt;code&gt;Cross-Origin-Embedder-Policy: credentialless&lt;/code&gt;, which&lt;br&gt;
  loads cross-origin no-cors resources without credentials instead of blocking them.&lt;br&gt;
  It's the escape hatch if you need third-party images or media — check current&lt;br&gt;
  browser support before you build on it. I went with &lt;code&gt;require-corp&lt;/code&gt; because I needed&lt;br&gt;
  nothing from anyone else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What still works
&lt;/h2&gt;

&lt;p&gt;Same-origin resources are unaffected: your own fonts, your own images, your own JS.&lt;br&gt;
  And crucially, a &lt;strong&gt;CORS &lt;code&gt;fetch&lt;/code&gt; to your own API is fine&lt;/strong&gt; — CORS requests satisfy&lt;br&gt;
  the opt-in requirement.&lt;/p&gt;

&lt;p&gt;So analytics became about twenty lines posting an event name to my own Cloudflare&lt;br&gt;
  Worker, and a token-protected plaintext URL to read the counters back. Five events:&lt;br&gt;
  page loaded, batch finished, checkout opened, licence verified, encoder failed.&lt;br&gt;
  That's enough to tell me which step of the funnel is broken, which is the only&lt;br&gt;
  thing I ever actually used a dashboard for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I didn't plan
&lt;/h2&gt;

&lt;p&gt;The tool encodes video locally and uploads nothing. Every site that claims something&lt;br&gt;
  like that is asking you to trust a sentence in a footer.&lt;/p&gt;

&lt;p&gt;Cross-origin isolation turns it into something you can check in ten seconds: open&lt;br&gt;
  DevTools → Network, run a batch, watch it stay empty. Load the page once, go&lt;br&gt;
  offline, encode again — it still works, because there's no server in the path.&lt;/p&gt;

&lt;p&gt;And the stronger version: I &lt;em&gt;can't&lt;/em&gt; quietly leak your data through a third party,&lt;br&gt;
  because the browser will not let me load one. The privacy property isn't a promise&lt;br&gt;
  I'm making; it's a consequence of a constraint I accepted for an unrelated reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest costs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;You own your analytics forever. No Sentry, no session replay, no funnel tool. When
something breaks in production you have the events you thought to add, and nothing else.&lt;/li&gt;
&lt;li&gt;No embedded demo video on your own landing page. Self-host the file or use a GIF.&lt;/li&gt;
&lt;li&gt;No drop-in chat widget, no support tooling, no A/B testing service.&lt;/li&gt;
&lt;li&gt;If you genuinely need a third-party resource later, your options are self-host it
or proxy it through your own origin. Both are work you didn't budget for.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a tool whose whole premise is that nothing leaves the tab, that trade was free.&lt;br&gt;
  If you're building anything else, understand that turning on &lt;code&gt;SharedArrayBuffer&lt;/code&gt;&lt;br&gt;
  means adopting all of it — the isolation is not a flag you can scope to one route.&lt;/p&gt;

&lt;h2&gt;
  
  
  One link
&lt;/h2&gt;

&lt;p&gt;The thing I built with it is &lt;a href="https://batchcrunch.com" rel="noopener noreferrer"&gt;BatchCrunch&lt;/a&gt; — drop a folder&lt;br&gt;
  of video, it compresses all of them locally via ffmpeg.wasm. But the headers above&lt;br&gt;
  are the part worth stealing.&lt;/p&gt;

&lt;p&gt;Happy to answer anything about the setup, the Emscripten pthread build, or the&lt;br&gt;
  worker-side licence check in the comments.&lt;/p&gt;

</description>
      <category>webassembly</category>
      <category>webdev</category>
      <category>performance</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
