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
- Event capture — Content script intercepts DOM events (click, scroll,
navigation, selection, clipboard) and sends them via
chrome.runtime.sendMessageto the background service worker. - Local storage — Background worker writes events to IndexedDB (
blincr-trailsdatabase) viatrail-db.ts. Trails are available offline. - Session relay — When a live session is active, the background worker
forwards events over a WebSocket to the
SessionCoordinatorDurable Object, which broadcasts to all participants. - Cloud sync — Authenticated users can upload trails to D1 (
cloud_trailstable) with R2 storage for the full event payload. - 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, inchrome.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). Thecloud_trailsrow 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-privatemints a token (is_publicstays0— 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=fromwindow.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 tot1, not the T3t3_selfstate. - Orphan-blob GC: folded into the existing daily cron
(
workers/handler.tsscheduled) —runOrphanBlobGcinsrc/backup.tsremoves anyusers/{id}/trails/{id}.jsonobject with no matchingcloud_trailsrow after 12 months, one R2 list page per run. - Dashboard:
/my/trailsnever 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/:tokenoffers "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,visibilityback tot1) 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), honorsrobots.txt, extracts viaHTMLRewriter, compares againstlast_value, and on a crossing writes ablincr_alertsrow + anotificationsrow (kind='blincr_alert', "The trail blinked") with a per-blincr cooldown. Five consecutive failures flag the blincrsight_lostand 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 throughsrc/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_DENYLISTKV namespace, checked incheckOne()beforerobots.txtor the page itself — a hostname key's mere existence blocks it, no request is ever made. Lets an abuse report (/abuse) take effect immediately viawrangler kv key put, no redeploy. A hit pauses the watcher (status = 'paused') and notifies its owner, separate from the transient-failuresight_lostpath. UA:blincr-watch/1.0 (+https://blincr.com/abuse).
Deployment
- Worker:
npm run deploy→wrangler deploy workers/deploy-entry.ts(prebuilt dist entry).wrangler.jsoncmainpoints atworkers/app.ts(virtual RR build) soreact-router devhot-reloads route loaders; the deploy/preview scripts override the entry on the command line. Cloudflare Workers Builds runs a barewrangler deployand never sees that override, sowrangler.jsoncalso aliasesvirtual: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 onnpm run dev), so loader hot-reload is unaffected. - Watch worker:
wrangler deploy -c watch-worker/wrangler.jsonc - Dev:
wrangler dev(useswrangler.jsonc) - Extension:
cd extension && node build.js(esbuild, outputs toextension/dist/) - Domain:
blincr.com(Cloudflare custom domain)