Architecture

System Overview

Blincr is a co-browsing platform built on Cloudflare Workers. Users install a Chrome extension that captures browsing trails (navigation, clicks, scrolls, selections, clipboard, overlays) and syncs them to a backend that coordinates real-time sessions and persists data.

Component Map

┌─────────────────────────────────────┐
│  Chrome Extension (MV3)             │
│  ├── content.ts    event capture    │
│  ├── background.ts session relay    │
│  ├── popup.ts      controls/tabs    │
│  ├── sidepanel.ts  trail replay     │
│  ├── trail-db.ts   IndexedDB store  │
│  └── plugins/      overlay system   │
│       ├── overlay-manager.ts        │
│       └── comment.ts                │
└────────────┬────────────────────────┘
             │ HTTP / WebSocket
┌────────────▼────────────────────────┐
│  Cloudflare Worker (workers/app.ts) │
│  ├── React Router SSR (dashboard)   │
│  ├── /api/*  REST endpoints         │
│  ├── /mcp    MCP server (agents)    │
│  └── /session/*  DO proxy           │
└──┬──────────┬──────────┬────────────┘
   │          │          │
┌──▼──┐  ┌───▼───┐  ┌───▼────┐
│ D1  │  │  R2   │  │  DO    │
│AUDIT│  │TRAILS │  │SESSION │
│ _DB │  │SNAPS  │  │COORD.  │
└─────┘  └───────┘  └────────┘

Data Flow

  1. Event capture — Content script intercepts DOM events (click, scroll, navigation, selection, clipboard) and sends them via chrome.runtime.sendMessage to the background service worker.
  2. Local storage — Background worker writes events to IndexedDB (blincr-trails database) via trail-db.ts. Trails are available offline.
  3. Session relay — When a live session is active, the background worker forwards events over a WebSocket to the SessionCoordinator Durable Object, which broadcasts to all participants.
  4. Cloud sync — Authenticated users can upload trails to D1 (cloud_trails table) with R2 storage for the full event payload.
  5. Dashboard — React Router app served by the same worker renders trails, audit logs, live sessions, and account settings.

Storage

Binding Service Purpose
AUDIT_DB D1 Users, sessions, credentials, audit log
SESSION_SNAPSHOTS R2 Trail JSON snapshots (trails/{id}.json)
SESSION_COORDINATOR DO Real-time WebSocket session state (SQLite)

E2EE distribution (E12.3)

Every trail is in exactly one visibility tier (cloud_trails.visibility): t0 (browser-only, no server row at all), t1 (encrypted backup), t2 (encrypted private share), t3/t3_self (plaintext, published or grandfathered). T1/T2 keep the server holding ciphertext it cannot read.

  • Keyring (extension/src/crypto.ts): one random 32-byte master secret per device, in chrome.storage.local, never sent to the server. Every trail's AES-GCM-256 content key is derived on demand via HKDF (salt = trailId) — no growing per-trail keyring to store or sync. Recovery is a base32 "recovery code" export of the master secret; losing it with no backup is unrecoverable by design (no escrow).
  • T1 backup: the extension encrypts a trail locally, uploads only {v, iv, ciphertext} (POST /api/user/trails/backup, src/backup.ts). The cloud_trails row gets a fixed title ("Encrypted trail") and no metadata — nothing about the trail's content or subject leaks centrally.
  • T2 private share: POST /api/user/trails/:id/share-private mints a token (is_public stays 0 — never listed/searchable). The content key is derived and appended to the URL fragment (#k=...) entirely client-side by the extension; the server only ever sees the bare URL. Fragments are never sent in HTTP requests, so the key never touches the server even in transit.
  • T2 viewer (/p/:token, app/routes/p.$token.tsx): no loader — the whole page is a client component. It reads #k= from window.location.hash, fetches the ciphertext (GET /api/public/trails/:token/ciphertext, no auth — safe, since the blob is useless without the fragment key), and decrypts with Web Crypto in the browser (app/lib/e2ee.ts). Genuinely no SSR of content, not just "the server doesn't render the events."
  • Revocation: DELETE /api/user/trails/:id/share (shared with T3, E12.6.2) is tier-aware — a revoked T2 share returns to t1, not the T3 t3_self state.
  • Orphan-blob GC: folded into the existing daily cron (workers/handler.ts scheduled) — runOrphanBlobGc in src/backup.ts removes any users/{id}/trails/{id}.json object with no matching cloud_trails row after 12 months, one R2 list page per run.
  • Dashboard: /my/trails never shows "Copy link"/"Publish" for t1/t2 rows — the dashboard holds no content key and cannot construct a correct /p/:token#k=... URL. Those trails show "🔒 manage from extension" instead; Revoke and Delete still work (they don't need the key).
  • Abuse reporting under E2EE (E12.6.2): blincr cannot inspect T2 content to judge a report the way it can for T3. /p/:token offers "Report this share" with an opt-out-able checkbox — checked, it builds a digest (buildTrailBundle) from the trail this browser already decrypted and includes it; unchecked, no content field is sent at all. This reporter-consented reveal is the only way T2 content ever reaches the moderation queue. Resolving a T2 report uses "revoke" (kill the share token, visibility back to t1) rather than "unpublish" — the content itself was never blincr's to take down.

Blincrs (standing page watchers)

A blincr is a watcher pinned to a page: it periodically re-fetches the page, extracts a value (CSS text, JSON-LD field, or regex), and alerts the owner when the value changes / crosses a threshold / appears / disappears.

  • blincr-watch-worker (separate deployment, watch-worker/) runs on a */5 * * * * cron. Each tick selects due blincrs (next_check_at <= now, one hit per domain per run for politeness), honors robots.txt, extracts via HTMLRewriter, compares against last_value, and on a crossing writes a blincr_alerts row + a notifications row (kind='blincr_alert', "The trail blinked") with a per-blincr cooldown. Five consecutive failures flag the blincr sight_lost and pause it.
  • Shared pure logic (extractors, comparators, robots parsing) lives in src/blincr-engine.ts (unit-tested, no bindings) so both workers reuse it.
  • The main worker owns CRUD: src/blincrs.ts + /api/user/blincrs* routes, with per-plan quotas resolved through src/entitlements.ts (ARC).
  • Separation rationale: MASTER-PLAN §3.2 — scheduled fetch fan-out runs on its own trigger and must not compete with interactive traffic.
  • Denylist (E12.6.3): WATCH_DENYLIST KV namespace, checked in checkOne() before robots.txt or the page itself — a hostname key's mere existence blocks it, no request is ever made. Lets an abuse report (/abuse) take effect immediately via wrangler kv key put, no redeploy. A hit pauses the watcher (status = 'paused') and notifies its owner, separate from the transient-failure sight_lost path. UA: blincr-watch/1.0 (+https://blincr.com/abuse).

Deployment

  • Worker: npm run deploy → wrangler deploy workers/deploy-entry.ts (prebuilt dist entry). wrangler.jsonc main points at workers/app.ts (virtual RR build) so react-router dev hot-reloads route loaders; the deploy/preview scripts override the entry on the command line. Cloudflare Workers Builds runs a bare wrangler deploy and never sees that override, so wrangler.jsonc also aliases virtual:react-router/server-build → ./dist/server/index.js. Both entries therefore bundle to the same prebuilt server build. Vite ignores the alias (it warns as much on npm run dev), so loader hot-reload is unaffected.
  • Watch worker: wrangler deploy -c watch-worker/wrangler.jsonc
  • Dev: wrangler dev (uses wrangler.jsonc)
  • Extension: cd extension && node build.js (esbuild, outputs to extension/dist/)
  • Domain: blincr.com (Cloudflare custom domain)