Nook is a note-taking tool that runs entirely inside your browser. No account, no server, no synchronisation. Your notes are encrypted on your own machine, and the page is configured so that no outgoing request is possible at all.
It is a static site on GitHub Pages: the server sends files and never has anything to receive back.
| Promise | Mechanism |
|---|---|
| The page cannot make a request | connect-src 'none' in the CSP, in every document, always — not a setting |
| Nothing is fetched unless you say so | Two switches, both off, each naming what it costs — see Letting the web in |
| Nobody disabled that by accident | CI inspects the published bundle and fails if network-capable code gets in (the guard) |
| Your notes are unreadable on disk | AES-GCM-256 with a non-extractable key, never exposed to JavaScript |
| Everything really can be erased | Database, caches, service worker and storages destroyed, then an inventory of what remains |
| It works offline | Precached and installable as a PWA |
Out of the box, nothing leaves your machine. Two settings can change that, both
off, both named, and neither can be turned on without a screen telling you what
it gives away. The one thing they cannot touch is connect-src 'none': Nook
itself never gains the ability to send anything anywhere.
- Write. A block editor with a slash menu, drag handles, headings, lists, checkboxes, code and tables. There is no Save button; edits are written after a pause in typing, when the tab goes to the background, and when you leave the page. Opening Nook puts you back in the page you last wrote in.
- Organise. Pages nest to any depth. Every page keeps up to twenty past versions you can walk back through and restore from — filed automatically on an interval you choose, or kept the moment you press Snapshot. Any one of them can be restored, or deleted — deleting asks twice, because a version exists nowhere but inside its own page.
- Connect.
[[Wikilinks]]resolve by title, and every page lists what links to it. A link to a page you have not written yet is offered as a page to create rather than reported as an error.#tagswork anywhere. - Find.
⌘Ksearches titles, tags and the text inside pages, and doubles as a command palette — new page, new database, today's note. - Structure. Databases with typed columns (text, number, select, date, checkbox, URL), shown as a table, a board or a calendar. The three are views of one set of rows, with their own filters and sorts.
- See the whole thing. A map places every page on a plane you can pan and zoom, draws a line for every link, and remembers where you drag a card.
- Print. The reading column, without the application around it, with link destinations spelled out.
- Nest. A page can show another page inside it, read-only, with a link to the real one. Entirely local — it is the same vault and the same key. A page that embeds itself is caught and said so, rather than recursed into.
- Leave. Export everything as plain JSON, import it back, or destroy the lot and read the storage back to check.
- Share. A page travels as an encrypted copy, as a link or as a file. See Sharing, which is mostly about what sharing without a server cannot do.
The editor's emoji picker downloads its data and its images from a CDN at runtime. The CSP would block those requests and the menu would be silently empty — the worst of both worlds. Rather than switching it off with an option, the library is replaced at build time, so the network code is not merely unreachable: it is never served. Emoji typed on the system keyboard work as usual.
Two things people reasonably want — a picture hosted elsewhere, a video player — cannot exist under a policy this strict, because the browser fetches both. So each is a switch in Settings, off, and each says what it costs before it moves.
A policy can only ever be tightened at runtime, never loosened. A flag in
memory therefore cannot open img-src. So the build emits two documents:
| Document | Pictures from the web | Players from the web |
|---|---|---|
index.html |
refused | refused |
open-web.html |
allowed | YouTube, Vimeo |
They are generated from the same bytes and differ in exactly those two
directives — the guard fails the build if anything else differs, or if
connect-src 'none' is not identical in both. Turning a switch on moves you to
the second document, and the application closes back, at boot, whichever half
you did not ask for. The address bar therefore always says which promise is
in force, and once closed a door cannot be reopened for the life of the page.
What each one actually gives away:
- Pictures from the web — the site hosting the picture learns your address and that you are reading that page, every time you open it. Nook always offers to copy the picture into the vault instead, which asks the host once and never again; the note is then self-contained, works offline and travels with an export. Some hosts refuse to be copied, and Nook says so rather than failing quietly.
- Players from the web — opening a note with a player in it tells YouTube or Vimeo that you opened it. The page around it still cannot make a request; the player is a separate document, and that one can. The frame sends the site's origin and never the path, so what leaves is "somebody on this site opened a player" — which is implied by embedding one. YouTube refuses to play without it, so withholding it bought no privacy and lost the feature.
With both off, a block that points at the web shows what it points at and contacts nobody.
One directive is absent rather than present-but-useless: frame-ancestors.
A <meta> element cannot carry it — the specification excludes it, and Chrome
ignores it with an error — and a static host cannot set response headers. It
used to sit in the policy doing nothing while looking like it was doing
something. The guard now refuses any directive a <meta> cannot enforce.
There is no server, so a shared page is a copy. Everything else follows from that, and Nook says it on the screen where you press the button:
- There is no revocation. Once it has left, nothing can reach it. No such button is offered, because it could only be a lie.
- There is no expiry. You can ask that a page not be read after a date; Nook shows your reader that request and declines to open the page past it. That is a courtesy applied by the reader's copy of Nook, not a lock, and the reader is told exactly that — with a working button underneath.
- The passphrase is the real protection, and it is recommended rather than required. With one: Argon2id and AES-GCM-256, the same as the vault, and you send it by some other route than the link. Without one the page travels in the clear and anyone holding the link can read it — allowed, because that is a fine choice for a shopping list, and said plainly on both ends rather than made impossible. A short passphrase is refused outright: it looks like protection without being any. Nook will suggest a strong one if you want it.
A share carries the page, its pictures, and nothing else — in particular not its history, so the recipient does not receive every draft and every deleted sentence. Under about 256 KB it goes as a link, in the URL fragment, which is never sent to any server; above that Nook offers a file instead and explains why, because long links get truncated in transit and fail at the far end with nothing to say why.
Opening a share link does not create a notebook on the reader's machine. The reader screen is rendered before the vault is ever opened; storage is touched only if they press Import into my notebook — and then Nook opens the page it has just created, so the copy is somewhere you can edit rather than somewhere you have been told to go and look for.
By default the encryption key is generated on the device and kept in the browser in a form JavaScript cannot read.
What that protects: anyone who copies your disk, your browser profile or a backup gets nothing but unusable bytes.
What it does not protect: anyone who opens your browser with your unlocked session gets exactly the rights you have.
Turning on a passphrase closes that gap. Everything is re-encrypted under a new key derived with Argon2id, the old key is destroyed, and the vault locks itself after 15 idle minutes. There is no recovery — no server holds a copy — so Nook makes you export before it will let you switch.
One directive is worth naming, because it looks like a compromise and is not.
script-src allows 'wasm-unsafe-eval': Chrome refuses to compile a
WebAssembly module without it, and Argon2id is one. It permits WebAssembly and
nothing else — not eval(), not new Function() — and the no-network promise
rests on connect-src, which is untouched. Falling back to PBKDF2 would have
traded real resistance to GPU cracking for a directive that buys nothing here.
The build gate fails if plain 'unsafe-eval' ever appears.
Two halves, separated by a rule that CI enforces.
src/core/ Plain TypeScript — storage, encryption, model, search
src/ui/ React — components, editor, layout
The core imports neither React nor the editor; the interface never touches IndexedDB or WebCrypto. That boundary is not a style convention — a test enforces it and breaks CI when it is crossed. It is what lets the whole domain be tested without a browser, and the interface be rebuilt without touching the data.
The core also does not know what a document's content is. It stores, encrypts and indexes opaque JSON, which is why a database is simply a document with a different shape inside — and why it inherits encryption, search, history and export without a line of storage code of its own.
pnpm install
pnpm dev # development server
pnpm test # core tests, no browser
pnpm verify # types, lint, tests, build, network guard
pnpm smoke # drives a real browser through the whole productpnpm smoke writes into .smoke/, which is gitignored: the browser profile it
drives, and the files the application genuinely downloads during the run. Those
used to land in the user's Downloads folder, which is not somewhere a test gets
to write.
pnpm verify is exactly what CI runs. pnpm smoke writes a note, links it,
searches it, maps it, locks the vault behind a passphrase, unlocks it, shares a
page and opens the share as a stranger would — against the built bundle, because
some defects only exist once the code is served. It caught the CSP blocking
WebAssembly, and a title field that looked like it lagged.
It also holds the checks the two-document design rests on, which are the kind of claim that is worthless unless a browser is asked: that a policy inserted at runtime is really enforced, that turning on pictures leaves players refused, and that the strict document refuses both.
docker compose up # then open http://localhost:8080Or without compose:
docker build -t nook .
docker run --rm -p 8080:8080 nookThe image and the published site serve the same files. The security policy travels inside the HTML rather than in a response header — it has to, because a static host cannot send headers — so the promise holds identically whoever is serving it, and the guard runs inside the build so an image cannot ship a bundle without it. What changes is only where the application itself comes from, which for a program whose claim is that nothing leaves your machine is a reasonable thing to want.
Your notes live in that browser, encrypted, exactly as they would on the published site. Stopping the container does not touch them, and neither does deleting the image.
- Fork this repository.
- Settings → Pages → Source: GitHub Actions.
- Push to
main.
The sub-path is derived from the repository name, so renaming your fork is enough — there is nothing to change in the code.
MIT.