<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Abdulrahman]]></title><description><![CDATA[Abdulrahman]]></description><link>https://abdulrahman-gg.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Thu, 29 Oct 2026 00:52:54 GMT</lastBuildDate><atom:link href="https://abdulrahman-gg.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Making "one-time" links actually one-time: fragments, preview bots and an atomic DELETE]]></title><description><![CDATA[Every team has a "temporary" password sitting in a chat history somewhere. Someone pasted a database credential into a DM two years ago, and it is still…]]></description><link>https://abdulrahman-gg.hashnode.dev/making-one-time-links-actually-one-time-fragments-preview-bots-and-an-atomic-delete</link><guid isPermaLink="true">https://abdulrahman-gg.hashnode.dev/making-one-time-links-actually-one-time-fragments-preview-bots-and-an-atomic-delete</guid><category><![CDATA[Security]]></category><category><![CDATA[webdev]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[Cryptography]]></category><dc:creator><![CDATA[Abdulrahman]]></dc:creator><pubDate>Mon, 26 Oct 2026 18:59:49 GMT</pubDate><content:encoded><![CDATA[<p>Every team has a "temporary" password sitting in a chat history somewhere. Someone pasted a database credential into a DM two years ago, and it is still there, searchable, synced to every device, included in every export.</p>
<p>One-time secret links are the usual fix: encrypt the secret, send a link, and the link dies after the first read. I built <a href="https://b1.gg">B1 Drop</a> to do exactly that. When I started, though, I found that "one-time" is surprisingly easy to get wrong. Two failure modes kept coming up:</p>
<ol>
<li><strong>The server can read the secret.</strong> If the key lives next to the ciphertext, "one-time" only describes the UI. The operator, a log pipeline, or a database backup can still read it.</li>
<li><strong>Link preview bots burn the link.</strong> Paste a link into Slack, iMessage, Teams or WhatsApp and a bot fetches it to build a preview card. If a plain GET consumes the secret, the bot reads it first and your recipient sees "already opened".</li>
</ol>
<p>There's also a third, quieter problem: two requests racing to read the same secret. Here's how B1 Drop handles all three, with excerpts from the actual code.</p>
<h2>Keeping the key off the server</h2>
<p>Everything is encrypted in the browser with WebCrypto. For each drop, the client generates 32 random bytes with <code>crypto.getRandomValues</code>, plus a 16-byte salt and a 12-byte IV. Those 32 bytes never become the AES key directly. They go through HKDF-SHA256 with the salt and a fixed context string. If the sender sets a passphrase, PBKDF2-SHA256 with 600,000 iterations stretches it, and the result is concatenated with the random key before HKDF:</p>
<pre><code class="language-ts">async function deriveKey(rawKey: Bytes, salt: Bytes, passphrase?: string): Promise&lt;CryptoKey&gt; {
  let ikm = rawKey;
  if (passphrase) {
    const pk = await subtle.importKey("raw", new TextEncoder().encode(passphrase.normalize("NFC")), "PBKDF2", false, ["deriveBits"]);
    const bits = new Uint8Array(await subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt, iterations: PBKDF2_ITERATIONS }, pk, 256));
    ikm = new Uint8Array(rawKey.length + bits.length);
    ikm.set(rawKey, 0);
    ikm.set(bits, rawKey.length);
  }
  const hk = await subtle.importKey("raw", ikm, "HKDF", false, ["deriveKey"]);
  return subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt, info: INFO }, hk, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
}
</code></pre>
<p>With this design the passphrase is an extra factor, not a replacement. Without the 32 bytes from the link, the passphrase alone gets you nothing, and there's a unit test for exactly that case.</p>
<p>The data is then encrypted with AES-256-GCM. The envelope starts with a 30-byte header (version, flags, salt, IV), and that header is passed as additional authenticated data. Flip a salt byte or the passphrase flag and decryption fails instead of quietly doing something odd. The tests tamper with both the ciphertext and the header to check this.</p>
<p>The server only ever sees <code>header || ciphertext</code>. The browser then builds the link as <code>${location.origin}/${j.id}#${key}</code>. The key is base64url-encoded into 43 characters and sits after the <code>#</code>. Browsers don't send the fragment in HTTP requests, so it never shows up in our server's request line or logs. The server only knows the 8-character ID. IDs are drawn from base-62 using rejection sampling (bytes of 248 and above are discarded), so they aren't biased toward some characters.</p>
<p>Two small details on the receiving side. Once the secret is decrypted, the page calls <code>history.replaceState</code> to strip the key out of the address bar and the history entry. Pages and API responses also send <code>Referrer-Policy: no-referrer</code>.</p>
<h2>GET never consumes</h2>
<p>This was the most important design decision, and it's almost boring: <strong>a GET request never changes state.</strong> The router makes that explicit:</p>
<pre><code class="language-ts">if (!m[2] &amp;&amp; req.method === "GET") return await status(req, env, m[1], now);
if (m[2] &amp;&amp; req.method === "POST") return await reveal(req, env, url, m[1], now);
// ...
// Short link page. Serving it never touches the database, so link-preview bots cannot burn a drop.
if (ID_RE.test(p.slice(1))) return asset("/open.html", 200, true, false)!; // never frameable
</code></pre>
<p>When a preview bot fetches <code>https://b1.gg/AbCd1234</code>, it gets a static HTML shell. Serving it doesn't run a single database query. The response is marked <code>no-store</code> and <code>noindex</code>, and it can't be framed (<code>frame-ancestors 'none'</code> plus <code>X-Frame-Options: DENY</code>), so another page can't clickjack the reveal button. When a real person opens the page, its script calls <code>GET /api/drops/:id</code>. That endpoint runs a <code>SELECT</code> that only reports whether the drop exists, whether it needs a passphrase, and when it expires. It never reads the ciphertext.</p>
<p>To actually reveal, you press <strong>Reveal and delete</strong>. That sends a <code>POST</code> to <code>/api/drops/:id/reveal</code> with a custom <code>X-B1-Drop: 1</code> header, and the server refuses the request without it. Preview bots don't send POSTs with made-up headers. A malicious page can't make your browser do it cross-origin either, because the custom header triggers a CORS preflight that our server never approves. If an <code>Origin</code> header is present and doesn't match, the request is refused too. The test suite checks this directly. It fetches the share page five times with a <code>Slackbot-LinkExpanding</code> user agent and then confirms the drop can still be revealed. Another test confirms that a reveal without the header, or with a foreign <code>Origin</code>, returns 403 and doesn't consume the drop.</p>
<h2>Reveal is one atomic read-and-delete</h2>
<p>The naive version of "read once" looks like this: <code>SELECT</code> the row, return it, then <code>DELETE</code> it. Between those two statements, a second request can read the same row. Two tabs, a double-click, or a retrying proxy is enough to get two reads of a "one-time" secret.</p>
<p>B1 Drop collapses the read and the delete into a single SQL statement:</p>
<pre><code class="language-ts">const row = await env.DB.prepare("DELETE FROM drops WHERE id = ? RETURNING expires_at, chunks").bind(id).first&lt;{ expires_at: number; chunks: number }&gt;();
if (!row) return null;
const [sel] = await env.DB.batch([
  env.DB.prepare("SELECT data FROM chunks WHERE drop_id = ? ORDER BY n").bind(id),
  env.DB.prepare("DELETE FROM chunks WHERE drop_id = ?").bind(id),
]);
if (row.expires_at &lt;= now) return null;
</code></pre>
<p><code>DELETE ... RETURNING</code> is the gate. Only one request can delete a given row, so only one gets a non-null result. Every other request sees nothing and gets a 404 <code>"gone"</code>. Only the winner reads the ciphertext chunks, and it deletes them in the same batch. Files are split into chunks of under 2 MB for storage, and a drop row is only inserted in the same transaction as all of its chunks.</p>
<p>Expiry is checked <em>after</em> the delete. That way, revealing an expired drop also removes it instead of leaving it for the cleanup job.</p>
<p>I didn't want to just trust this, so there's a test that fires 12 concurrent reveals at the same drop:</p>
<pre><code class="language-ts">const res = await Promise.all(Array.from({ length: 12 }, (_, i) =&gt; reveal(id, `192.0.2.${i}`)));
const codes = res.map((r) =&gt; r.status).sort();
expect(codes.filter((c) =&gt; c === 200).length).toBe(1);
expect(codes.filter((c) =&gt; c === 404).length).toBe(11);
</code></pre>
<p>Exactly one 200 and eleven 404s.</p>
<p>The other limits are deliberately simple. Expiry is one of three fixed values (1 hour, 1 day, 7 days), and the server rejects anything else. Files and messages are capped at 5 MB, checked in the browser and again on the server. Creates are limited to 20 per hour and lookups to 30 per minute per network. The rate limiter stores only a salted hash of the network, not the IP. A scheduled job purges expired drops.</p>
<h2>What this doesn't protect against</h2>
<p>I'd rather be precise about the limits than oversell. This list also appears on the <a href="https://b1.gg/security">security page</a>:</p>
<ul>
<li><strong>Whoever opens the link first wins.</strong> The link <em>is</em> the key. If it leaks through chat history, a screenshot or a shared clipboard before the recipient opens it, the attacker reads it. The recipient then sees "gone" and knows something is wrong, but it can't be undone.</li>
<li><strong>A passphrase doesn't stop burning.</strong> The reveal POST deletes the drop before the passphrase is checked in the browser. Someone with the link but not the passphrase can destroy the drop, even though they can't read it. Also, a wrong passphrase doesn't restore the drop. The ciphertext stays in that tab so you can retry, and it's gone once the tab closes.</li>
<li><strong>Weak passphrases can be guessed offline</strong> by anyone who has both the link and the ciphertext. PBKDF2 slows that down. It doesn't prevent it.</li>
<li><strong>You trust the JavaScript we serve.</strong> As with every browser-based encryption tool, a compromised server could ship code that leaks the key. A strict Content-Security-Policy and zero third-party scripts reduce the risk but don't remove that trust.</li>
<li><strong>Compromised endpoints</strong>, malicious browser extensions, or a recipient who simply keeps a copy.</li>
<li><strong>Metadata.</strong> The server stores the ciphertext size, creation and expiry times, and the salted network hash for rate limiting. Anyone holding a valid ID can also see whether a drop exists and when it expires.</li>
<li><strong>Database backups.</strong> The database keeps point-in-time recovery snapshots for about 7 days. Any copy there is ciphertext without a key.</li>
<li><strong>Sender identity.</strong> Drops are anonymous. Confirm who sent one through another channel.</li>
</ul>
<p>If you're building something similar, these are the three rules I'd keep: put the key in the fragment, never let a GET change state, and make "read" and "delete" the same SQL statement.</p>
<p>Try it: <a href="https://b1.gg">https://b1.gg</a></p>
]]></content:encoded></item><item><title><![CDATA[Counting to 1,000 fairly: graphemes, whitespace runs and a share image that never shows your words]]></title><description><![CDATA[Most writing apps are built to keep you inside them. I wanted one that lets you go. 1k.gg is a single page where you write 1,000 characters a day. When you…]]></description><link>https://abdulrahman-gg.hashnode.dev/counting-to-1-000-fairly-graphemes-whitespace-runs-and-a-share-image-that-never-shows-your-words</link><guid isPermaLink="true">https://abdulrahman-gg.hashnode.dev/counting-to-1-000-fairly-graphemes-whitespace-runs-and-a-share-image-that-never-shows-your-words</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[webdev]]></category><category><![CDATA[writing]]></category><category><![CDATA[privacy]]></category><dc:creator><![CDATA[Abdulrahman]]></dc:creator><pubDate>Mon, 26 Oct 2026 18:47:06 GMT</pubDate><content:encoded><![CDATA[<p>Most writing apps are built to keep you inside them. I wanted one that lets you go. 1k.gg is a single page where you write 1,000 characters a day. When you reach the thousandth one, a quiet note appears: <em>Today's 1k is written. One thousand characters. The rest of the day is yours.</em> No confetti and no particle layer. You can keep writing if you like, but the day is done.</p>
<p>That small idea turned out to need some careful engineering. Here is how it works, taken straight from the source.</p>
<h2>Why a writing app should end</h2>
<p>A goal that never ends isn't really a goal. A fixed daily target makes starting cheap: you know how much is "enough" before you write the first word. So the app has one constant, <code>GOAL = 1000</code>, and one moment that matters, when the count crosses it. Completion is recorded once (<code>completedAt</code>) and stays recorded. If you delete a sentence afterwards, the day doesn't become unfinished.</p>
<p>That puts all the pressure on one function. If the count is wrong or easy to game, the full stop means nothing.</p>
<h2>Counting correctly</h2>
<p>"Characters" sounds simple until you remember what <code>string.length</code> actually returns: UTF-16 code units. <code>'🙂'.length</code> is 2, a flag is 4, and <code>'cafe\u0301'</code> (an <em>e</em> plus a combining accent) is 5. For a writer, each of those should count as what they see on screen.</p>
<p>There's a second problem: whitespace. If spaces count, someone can hold down the space bar and "finish". If spaces don't count, ordinary prose is penalised, because the spaces between words are part of writing. The rule I settled on:</p>
<ul>
<li>every user-perceived character counts: letters, digits, punctuation, emoji, CJK ideographs, and the spaces between words;</li>
<li>a run of whitespace (spaces, tabs, line breaks) counts as <strong>one</strong> character;</li>
<li>leading and trailing whitespace counts nothing.</li>
</ul>
<p>Honestly, I didn't get there in one go. The git history has a commit that made every whitespace character count separately, followed about an hour later by a revert to "runs count once". The revert message gives the reasons: it matched the copy on the site, and it kept the drawing of the page (more on that below) consistent.</p>
<p>Here is the counter, slightly trimmed:</p>
<pre><code class="language-ts">const segmenter = (() =&gt; {
  try {
    return Intl.Segmenter
      ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
      : null;
  } catch { return null; }
})();

const PRINTABLE_ASCII = /^[\x21-\x7e]*$/;

export function graphemeCount(s: string): number {
  if (PRINTABLE_ASCII.test(s)) return s.length; // fast path
  if (segmenter) { let n = 0; for (const _ of segmenter.segment(s)) n++; return n; }
  return [...s].length; // fallback: code points
}

export function charRuns(text: string): number[] {
  if (!text) return [];
  const out: number[] = [];
  for (const run of text.split(/\s+/u)) if (run) out.push(graphemeCount(run));
  return out;
}

export function countChars(text: string): number {
  const runs = charRuns(text);
  let n = runs.length ? runs.length - 1 : 0; // one counted space between runs
  for (const r of runs) n += r;
  return n;
}
</code></pre>
<p>A few details worth pointing out:</p>
<ul>
<li><strong>Grapheme clusters come from <code>Intl.Segmenter</code>.</strong> Where it's missing, the code falls back to code points (<code>[...s]</code>), which still handles astral characters like 𝒜, though not ZWJ sequences.</li>
<li><strong>The ASCII fast path.</strong> Most runs in English text are plain printable ASCII, where one code unit is one character, so the code skips the segmenter for them. This matters because the count runs on every <code>input</code> event.</li>
<li><strong>Splitting on <code>/\s+/u</code> gives you the whitespace rule for free.</strong> Empty strings at the edges are dropped, so leading and trailing whitespace disappears. Each gap between runs adds exactly one.</li>
</ul>
<p>That gives a useful invariant: <code>countChars(t) === sum(charRuns(t)) + max(0, runs - 1)</code>. The unit tests check it, along with the cases I worried about most:</p>
<pre><code class="language-ts">expect(countChars('cafe\u0301')).toBe(4);   // e + combining acute
expect(countChars('👩‍💻')).toBe(1);          // ZWJ sequence
expect(countChars('🇸🇦')).toBe(1);           // flag
expect(countChars('a' + ' '.repeat(500) + 'b')).toBe(3);
expect(countChars('   \n\n ')).toBe(0);     // can't pad your way to 1,000
</code></pre>
<h2>Local-first: no account, and export</h2>
<p>The writing never leaves the device. Storage sits behind a small <code>Store</code> interface with three implementations. It prefers IndexedDB (one object store, <code>entries</code>, keyed by <code>day</code>). If that fails, as it does in some private browsing modes, it falls back to <code>localStorage</code>. If that's blocked too, it uses an in-memory map, and the Data page tells you plainly: <em>"memory only (this browser blocks storage; export before closing)"</em>.</p>
<pre><code class="language-ts">export async function openStore(name: string): Promise&lt;Store&gt; {
  try {
    if (typeof indexedDB !== 'undefined') return await openIdb(name);
  } catch { /* fall through */ }
  try {
    localStorage.setItem('1k:probe', '1'); localStorage.removeItem('1k:probe');
    return kvStore(name, localStorage);
  } catch { return kvStore(name, null); } // memory only
}
</code></pre>
<p>Writes are debounced by 400 ms. The pending save is flushed when the tab is hidden or closing, and saved immediately at the moment you cross 1,000. An end-to-end test types 600 characters, waits, and asserts that <strong>zero</strong> network requests were made. The Content-Security-Policy (<code>default-src 'self'; connect-src 'self'</code>) backs that up at the browser level.</p>
<p>With no account, export is the backup plan, so there are three formats:</p>
<ul>
<li><strong>Markdown</strong>: every page, oldest first, each with its date, count and a "done" marker;</li>
<li><strong>JSON</strong>: a versioned backup (<code>format: '1k.gg/backup'</code>, <code>version: 2</code>) that can be imported again;</li>
<li><strong>TXT</strong>: today's page exactly as written.</li>
</ul>
<p>Import never trusts the numbers in the file. <code>parseBackup</code> validates each entry, rejects oversized pages, and <strong>recomputes every count</strong> with <code>countChars</code>. That's how older word-count backups still import cleanly after the switch to characters. When the same day appears in both the backup and your current data, the merge keeps the copy edited most recently, and completion stays sticky.</p>
<h2>The share card that never shows your words</h2>
<p>People want to share that they finished. I didn't want them to share <em>what</em> they wrote, and I didn't want a share button that leaks a private journal by accident.</p>
<p>The answer is "the Thousand": today's page drawn as strokes. Each run of non-space characters becomes one rounded stroke, as long as the run, and each gap is the one counted space. Because of the invariant above, ink plus gaps adds up exactly to the day's count, and a vermilion dot lands right after the stroke holding the 1,000th character. Anything written after that is drawn fainter. The layout is a pure function of <code>number[]</code>. It never receives the text.</p>
<p>The share card paints that layout onto a 1080×1080 canvas, adds the line "my 1k of the day", the date and the character count, and exports a PNG. The drawing function only needs a small subset of <code>CanvasRenderingContext2D</code>, so I can unit-test it with a recording fake and assert that no word from the page ever reaches <code>fillText</code>:</p>
<pre><code class="language-ts">drawShareCard(r.ctx, { runs: charRuns(page), chars: 1049, dateLabel: 'Tuesday 13 October' });
expect(all).toContain('my 1k of the day');
expect(all.join(' ')).not.toMatch(/w\drd/); // the writing itself is never drawn
</code></pre>
<p>Sharing goes through the Web Share API when the browser can share files, and falls back to a download otherwise:</p>
<pre><code class="language-ts">if (typeof File === 'function' &amp;&amp; typeof nav.share === 'function' &amp;&amp; typeof nav.canShare === 'function') {
  const file = new File([blob], name, { type: 'image/png' });
  if (nav.canShare({ files: [file] })) {
    try { await nav.share({ files: [file], title: SHARE_LINE }); return 'shared'; }
    catch (e) { if (e.name === 'AbortError') return 'cancelled'; }
  }
}
downloadBlob(blob, name);
return 'downloaded';
</code></pre>
<p>One practical trick: <code>navigator.share()</code> needs a user gesture, and rendering a PNG is async. So the app pre-renders the card while idle once the day is complete and caches it by day and count. The click can then call <code>share()</code> right away.</p>
<h2>Testing a tiny static app</h2>
<p>The whole thing is a Vite build of plain TypeScript with no framework, and the test suite is far bigger than the app:</p>
<ul>
<li><strong>44 Vitest unit tests</strong> covering counting, the Thousand layout and its invariant, export/import (including old backups), dates and streaks, storage (with <code>fake-indexeddb</code>), and the share card;</li>
<li><strong>127 Playwright tests</strong> across Chromium, Firefox, WebKit, a Pixel 7 and an iPhone 14 profile. They cover the 999 → 1,000 transition, a check that the downloaded PNG really is 1080×1080 with a PNG signature, Web Share (stubbed) and the download fallback, axe accessibility scans, and every page loaded under the production CSP.</li>
</ul>
<p>The lesson I'd pass on: keep the interesting logic in pure functions (<code>countChars</code>, <code>thousandLayout</code>, <code>drawShareCard</code>, <code>parseBackup</code>). Then the browser tests only have to prove the wiring.</p>
<p>Try it: <a href="https://1k.gg">https://1k.gg</a></p>
]]></content:encoded></item></channel></rss>