Your browser apps, backed up automatically: encrypted, portable, and readable with the internet off.
Jestr is a Chrome extension that quietly mirrors the SaaS apps you already use into a folder you own. No cloud. No accounts. No OAuth. No API keys. It rides the sessions you are already signed into.
Click the toolbar icon and the panel opens beside your page. It shows, top to bottom:
- Auto redirect, the switch that serves live sites from your local copy (see below).
- This tab, what Jestr already holds for the app you are currently looking at.
- Storage, one bar for the shared budget, then a row per app: size on disk, what is collected, a pause switch, a Sync now, and a gear to that app's own settings.
That is it. One screen at a time, one source of truth. Everything after setup is automatic. You do not schedule anything, you do not press anything.
The internet goes down. You keep working.
You are on a plane, in a dead zone, behind a corporate firewall, or Google is simply having a bad day. Normally that means every doc, every chat, every thread you needed is gone until it comes back.
With Jestr, flip on Auto redirect and your browser keeps working as if nothing happened:
flowchart LR
NAV["You navigate to<br/>docs.google.com/document/d/..."] --> Q{"Auto redirect armed,<br/>and the app not paused?"}
Q -->|"no"| LIVE["The live site, as usual"]
Q -->|"yes"| INT["A short interstitial:<br/>serving from your backup"]
INT --> LOCAL["Your local copy in Jestr's viewer,<br/>with the network off"]
Any host Jestr collects is intercepted and served from disk: Drive documents, SharePoint libraries, Reddit feeds with their comments, whole Telegram histories, your Claude conversations. Reading them needs no network at all. Turn Auto redirect off from the panel (or from the interstitial itself) and you are back on the live sites.
Two deliberate details. A paused app is never intercepted, because standing in for a live service
with a copy you stopped maintaining is worse than not standing in at all. And the toolbar badge reads a
red AR for as long as interception is armed, so you always know which world you are in.
The account goes away. The data does not.
Accounts get locked. Trials expire. Workspaces get deprovisioned the day you leave a job. A subreddit gets nuked, a Telegram chat gets deleted by the other person, a doc gets revoked from under you. Cloud data is only yours while someone else says it is.
Jestr keeps a complete, offline, greppable copy on your own disk, from the moment you install it rather than from the moment you remember to export:
- Nothing upstream can delete your copy. A document vanishing from Drive or SharePoint never
removes it here. A Telegram message deleted by anyone stays, tagged
deletedISOand rendered as "Deleted from Telegram. Jestr kept the copy it made." - Version history. The last 5 versions of every document live in
.versions/. - Integrity rules borrowed from real backup tools: zip-magic validation (a stale Google session returns an HTML login page with HTTP 200, and Jestr will never overwrite a good backup with it), sha256 change-gating, atomic writes.
- A hard ceiling, never an eviction policy. One shared budget across every app, 5 GB by default. At the limit Jestr stops backing up and tells you: red badge, notification, a line on the Storage screen. It does not delete one backup to make room for another. Ever.
One folder. Any machine. No lock-in.
Your backup is a plain folder on your disk that you pick once. Not a database, not a proprietary archive, not a container only this extension can open.
your-backup-folder/
├── drive/ <Title> [docId8].docx | .xlsx | .pptx (+ .versions/, _manifest.json)
├── sharepoint/ <tenant>/sites/<Site>/<Library>/<folders…>/<file> ← the site's own tree
│ _versions/<id8>/<timestamp>.<ext> ← flat, id-keyed
├── reddit/ r/<community>/feed.json, icon.png
│ r/<community>/posts/<date>_<slug>_<id>/post.{json,md}, comments.{json,md}, media/
│ u/<you>/ your own posts and comments
├── telegram/ <slug>_<kind>-<peerId>/chat.json, messages/<n>.json, messages.md, media/
├── claude/ <created>_<slug>_<convId8>/conversation.{json,md} + artifacts/
└── _status.json, README.txt
Copy it to an external drive, sync it with anything, drop it on a new machine. Then in Jestr, Settings → Import pulls it back in: after a reinstall, a new Chrome profile, or a machine you have never used before. Import only fills gaps. A file already present is never overwritten, the folder is never written to, and re-running it continues where a previous pass stopped.
NOTE · Two tiers, one direction. Backups are written to OPFS, the extension's own storage, which needs no permission and exists from install. The folder you pick is a mirror: copied to, never read from during normal operation. That makes the folder the durable copy, since OPFS disappears with the extension or the profile.
Restore is equally boring by design. Drag the .docx files back into Drive, read the Markdown in any
editor, feed the raw conversation.json into whatever tooling you like.
Sealed at rest with a key only your passphrase unwraps.
File contents are sealed at rest, in OPFS and in your mirror folder, with a key only your passphrase can unwrap.
| Layer | What it does |
|---|---|
| Passphrase → key | PBKDF2-SHA256, 2,000,000 iterations, unwraps the vault key |
| Per-file envelope | Fresh ephemeral ECDH P-256 keypair → HKDF → AES-256-GCM |
| Recovery code | 128 bits of real entropy, issued at setup, bound to its own AAD so it cannot be replayed |
| Filenames | Item ids, not titles, so a directory listing is not a table of contents |
The lock re-arms on every browser restart. The viewer and the panel's contents sit behind it.
Backups keep running while locked. Sealing needs only the vault's public key, which is stored in the clear, so a restart never interrupts collection. Only reading needs you. One exception: Telegram message collection merges against the stored copy, so it waits for an unlock.
Stated plainly, what is not encrypted: chrome.storage.local, the per-item index of titles,
URLs and timestamps, and the SharePoint catalogue in IndexedDB. Neither is in the mirror folder, so a
stolen folder yields nothing. A copied Chrome profile shows what you have, not what is in it.
WARNING · There is no reset. Lose the passphrase and the recovery code and the backups cannot be read. That is the point of the design, not a gap in it.
Why Auto redirect sits above the lock
The Auto redirect switch is the one control held above the passphrase gate. Not so arming can skip the passphrase (it cannot), but because of the state where the two collide.
The lock re-arms on every browser restart; Auto redirect deliberately survives one. Restart mid-outage and you are locked and armed. Since disarming needs no passphrase, the switch is the only working control in exactly that state. Hide it, and someone whose browser is intercepting every navigation opens the panel to find nothing but a password box.
When you do arm it from a locked panel, the passphrase you type unlocks the rest of the panel too: one entry, both jobs.
Requirements: Chrome (or any Chromium browser) with Manifest V3 support, and Node 18+ to build.
git clone <this-repo> && cd jester-extension
npm install
npm run build # typechecks, then outputs dist/Then:
- Open
chrome://extensions - Toggle Developer mode on (top right)
- Click Load unpacked and select the
dist/folder - The setup page opens automatically → Choose backup folder… (if Chrome offers it, pick "Allow on every visit")
- Set your passphrase and save the recovery code somewhere safe
- Pin the Jestr icon to your toolbar. Clicking it opens the side panel
That is the whole setup. The first Claude sync starts immediately. Drive documents back up as you
open them. Reddit and SharePoint fill in on their own schedule. Telegram syncs whenever you have a
signed-in web.telegram.org/k/ tab open.
# open any Google Doc, then check:
your-folder/drive/<Title> [id].docx # appears within seconds
your-folder/drive/_manifest.json # lists it
your-folder/claude/ # one folder per conversation
your-folder/_status.json # last-success timestamps per appThe toolbar badge shows ! only when something actually needs you: signed out, folder access
lost. Silence means it is working.
For logs: chrome://extensions → Jestr → service worker console (triggers) or offscreen.html
console (fetches and writes).
flowchart TD
T1["Tab navigation"] --> SW["Service worker<br/>detects and dispatches, then dies"]
T2["Scheduled alarm"] --> SW
T3["Sync now"] --> SW
SW -->|"hands the job over"| OD["Offscreen document<br/>fetches as you, validates,<br/>hashes, seals, writes"]
OD --> OPFS[("OPFS<br/>working copy")]
OPFS -->|"mirrored out"| DIR[("Your folder<br/>the durable copy")]
OPFS --> VIEW["Viewer / Auto redirect<br/>decrypted in memory,<br/>rendered with no network"]
DIR -.->|"Import, after a reinstall<br/>or on a new machine"| OPFS
The split is forced by Manifest V3. A service worker is killed after roughly 30 seconds of idle, so it can never be the thing that runs a long sync loop; it only notices that work is due and hands it over. The offscreen document is a hidden page with a normal page lifetime, which is what makes cookie-authenticated fetches and File System Access writes possible in the first place.
Every backup tool you have used before this one asked for an account: an OAuth consent screen, an API key, a token pasted into a settings box. That is a second credential to issue, store, scope, refresh and eventually leak.
Jestr never asks, because the credential already exists. You are signed into these apps right now. Your browser is holding a live, authenticated session for each of them, and an extension with host permission for a site can issue requests that carry it:
fetch('https://…/export?format=docx', { credentials: 'include' })Chrome attaches the site's own cookies to that call. From the server's point of view it is you, in your browser, asking for a document you already have open in another tab. Jestr calls the exact same private endpoints the web app calls when you click things, so it gets exactly what the app would have shown you: no more, and no less.
That is the whole trick, and it comes in three grades of difficulty.
flowchart LR
S(["Your live browser session"]) --> G1["1 - Ride the cookie<br/>credentials: include"]
S --> G2["2 - Beat the anti-forgery check<br/>SAPISIDHASH + Origin rewrite"]
S --> G3["3 - Drive the page itself<br/>injected shim, no cookie exists"]
G1 --> API["The app's own private endpoints"]
G2 --> API
G3 --> API
API --> OUT(["Exactly the data you can already see"])
1. Riding the cookie. Most apps need nothing else. The offscreen document fetches a document export, a JSON listing, a media file, and the session cookie does all the work.
2. Satisfying the anti-forgery checks. Some private frontends want more than a cookie, precisely to
stop requests that did not come from their own page. Google's data frontend is the strict one: it wants
a SAPISIDHASH derived from the SAPISID cookie, and an Origin header it recognises, which an
extension origin is not. So Jestr reads that one cookie to compute the hash, and rewrites the Origin
header on its own outbound calls with a declarativeNetRequest rule. The rule is deliberately scoped
so it cannot touch anything but Jestr's requests: the extension holds no host permission for
drive.google.com at all, so a real Drive tab's traffic is out of its reach by construction.
3. Driving the page itself. Sometimes there is no cookie to ride. Telegram Web keeps its session as an auth key inside the page and talks MTProto over a WebSocket, so there is no URL to call and no credential to attach. For that case Jestr injects a small shim into a tab you already have signed in and asks the page's own authenticated client to do the work. It asks for no phone number, no login code, and it injects nothing until a backup is actually running. The cost is the one constraint that applies nowhere else: that tab has to be open.
What this deliberately cannot do. The boundaries matter more than the trick:
| Never sees your password | It rides a session; it does not create one |
| Stores no credential | No token, no key, no cookie copy is written anywhere |
| Cannot outlive your session | Sign out and collection stops, with a ! on the badge |
| Reaches nothing you cannot | The server applies your own permissions to every call |
| Talks to nobody else | Traffic goes to the app's own origin. No Jestr server exists |
The same pipeline runs for everything, whichever grade it arrived by:
flowchart TD
B["Fetched bytes"] --> V{"Is it what it claims to be?<br/>zip magic, not a login page"}
V -->|"no"| REJ["Rejected.<br/>The good copy is left alone"]
V -->|"yes"| H{"sha256 differs from<br/>what is already stored?"}
H -->|"no"| SKIP["Nothing written"]
H -->|"yes"| VER["Previous copy moved<br/>into .versions/, last 5 kept"]
VER --> SEAL["Sealed: ECDH P-256 into HKDF<br/>into AES-256-GCM, public key only"]
SEAL --> W["Written atomically to OPFS"]
W --> M["Copied out to your folder"]
Each gate earns its place. Validation exists because a stale session famously answers with an HTML login page under HTTP 200, and a plausible-looking failure is the one thing that must never land on top of a good backup. Hash-gating is what keeps a sync that runs all day from churning your disk. Sealing needs only the vault's public key, which is why collection carries on while the vault is locked.
Two rules govern pace throughout. The shared budget is a hard ceiling, so at the limit collection stops and says so rather than deleting anything. And you always outrank the backup: work runs in larger slices while the machine is idle or locked, backs off while you are using it, and where an app enforces a request budget of its own, a reserve is left permanently untouched so Jestr can never be the reason your own tabs start failing.
Repair, the only action that deletes from your folder
Two guards sit around it:
- It refuses to prune a subtree when the orphans outnumber the files still in OPFS. Pruning is for leftovers such as a renamed document's old filename, or versions whose tombstones were dropped at the cap, which are a minority of a healthy folder by nature. Outnumbering them means OPFS is what is wrong, not the folder. Declined subtrees are reported, not silently skipped.
- The panel blocks Repair outright while an import is outstanding.
The trade is deliberate: history legitimately pruned to nothing leaves its folder copies in place. Disk hygiene forgone so that a delete never runs on bad evidence.
npm run dev # vite dev server
npm run build # tsc --noEmit && vite build → dist/
npm run typecheck # types only
npm test # vitest
npm run icons # regenerate extension icons from assets/src/
├── apps/ one folder per app: drive, sharepoint, reddit, telegram, claude
│ ├── index.ts the registry (identity, settings, summaries)
│ ├── collectors.ts what actually fetches
│ ├── triggers.ts what starts a job
│ └── views.ts how the offline viewer renders it
├── background/ MV3 service worker, triggers and dispatch only
├── offscreen/ the long-lived worker: fetches and writes
├── panel/ the side panel UI
├── viewer/ the offline reader
└── lib/crypto/ vault, envelopes, keys, sealed maps
Adding an app means adding one folder under src/apps/ and registering it in src/apps/index.ts.