A session cookie now carries the id of a row in the new sessions table,
and is refused once that row is gone. :sessions lists the browsers
signed in, newest first, and :sessions rm <n> signs one out without
touching the rest; it locks the vault too, since the vault is open for
the whole workspace. Logging out deletes the row, :logout! and a login
password change delete them all, and the sweep drops expired rows.
last_seen is kept in memory and written at most every five minutes.
Cookies issued before this carry no id and are refused: every browser
logs in once more after the upgrade.
Since e4e3e2b every registered SSH key could read sealed files over the
mount while the vault was open in the browser. A key is a standing
credential on another machine, so it now needs vault access, given and
taken per key with :sshkey vault <name> on|off (PATCH /api/ssh-keys/{id},
migration 0016). Existing keys start without it. The access is asked on
every read of a sealed file, so taking it away cuts off a read under way,
as locking does. That includes a write handle opened over a sealed file:
its .part holds the decrypted bytes and could be read back through it.
A mount no longer holds the vault open: it takes the key through the new
Vault::peek_key, which does not count as use. Opening sealed files over
and over from an indexer kept the idle lock from ever firing.
The rest of the audit's findings on this side:
- An unfinished upload's .part is removed when it is dropped, so a sealed
file's plaintext does not sit in uploads/ after a client goes away.
- A deleted key can no longer write, setstat or close-and-save through a
session that was already open.
- A session holds at most 256 handles and 64 MiB in memory, the server at
most 32 connections; keepalives drop a peer that stopped answering.
- A sealed download in the browser ends when the vault locks, the way a
read over the mount already did.
- Certificate inspection refuses a key or keystore that asks for more
than ten million rounds, or scrypt beyond 2^20 and r = 8, before running
the derivation: the file chooses those numbers, and the work could not
be stopped once begun.
m now adds the row under the cursor to a set of marks and steps on, so
mmm picks up three; M takes the whole folder, Esc or :unmark puts it all
back. Marks stay while the list walks, so they can be gathered from
several folders. p, :mv and Shift+digit move the whole set.
With a pointer, Ctrl- or Cmd-click marks a row and Shift-click every row
from the cursor to it. Rows drag onto a folder row, a crumb or a space
pill; a marked row carries every marked node with it.
POST /api/nodes/move takes { ids, parent_id } and moves them all or
none: every node is checked before any moves, sealing into a sealed
folder happens before the one transaction that moves them, and two of a
name get slugs of their own. The single move goes the same way.
`:encrypt` on a folder sealed the files in it once; whatever arrived
afterwards was plaintext. A folder or space that is encrypted now
carries `sealed` (0015), and so does every folder under it, so "is
this place sealed?" is one column of the parent. What lands there is
sealed on the way in, or refused with 423 while the vault is shut:
- uploads, with or without `?encrypt`, encrypted as they stream
- imports, sealed where they lie before any row names them
- SFTP writes, sealed out of their `.part`, and refused at the open
when the vault is shut
- files and folders moved in, through the web or SFTP: seal::enter
seals them first and the move happens only if that worked
Triggers make it hold for any path: a folder made inside a sealed one
is sealed, and a file node whose media is not encrypted cannot be
inserted into or moved into a sealed folder (a 400, not a 500).
Decrypting the sealed folder lifts the flag on its subtree and opens
its files; a file or folder inside it cannot be decrypted on its own.
An empty folder can be sealed. Documents are not files and stay as
they are.
The list shows a lock on sealed folders, the touch menu offers to
unseal one, and an upload or import into one asks for the vault
password first. Tests cover sealing, inheritance, the trigger, moves
with the vault open and shut, unsealing, an SFTP write and an import.
The file pane decided what to show by MIME type, and uploads keep a
type only when it cannot execute, so a .md, a .json or a .pem arrived
as application/octet-stream and got "no preview". It now goes by name
first (preview.ts), and a file with a name it does not know has its
first 8 KiB looked at: no NUL bytes and valid UTF-8 means text.
- text and code: the first 256 KiB with line numbers, as before
- markdown: rendered through the same sanitiser as documents, with a
source toggle
- CSV/TSV: a table of the first 500 rows, quotes respected
- PDF: the browser's own viewer in a frame. Only a PDF is served
without the sandbox CSP (Chrome's viewer will not start under it),
with frame-ancestors 'self' and X-Frame-Options SAMEORIGIN; the app
CSP allows same-origin frames.
- certificates, keys, keystores: POST /api/nodes/{id}/inspect reads
PEM (chains, keys, requests, public keys), DER and PKCS#12 on the
server, in pure Rust, including the RC2/3DES keystores that
openssl -legacy and older keychains write. It answers with subject,
issuer, validity, key, names, usage and fingerprints, and pairs each
private key with its certificate by public key. A password is asked
for only when needed, used for that one answer and never stored;
no key material is ever sent back. Sealed files are read through the
vault, and only while it is open.
The tests read throwaway fixtures under src/routes/testdata, made with
the openssl commands listed beside them; .gitignore lets their .pem
files past a global *.pem rule.
README: the API table lists every route, twelve of which were missing;
an SFTP section says how to turn the mount on and register a key; the
keys table and the command list cover what was only in the code
(digits, Tab, w, f, Alt-h/l, Ctrl-D/U, m/p, :today, :edit, :next and
the rest, :search!, :set clipboard, :logout!). Three claims that were
wrong are fixed: there is no board button above the list, R does not
rename a space, and Esc/q close the document.
Help gains the agenda keys and the board's gg/G and r. :t no longer
means :today, which read as the agenda's key.
make check runs cargo fmt --check, after the one formatting commit
before this, which .git-blame-ignore-revs names (git config
blame.ignoreRevsFile .git-blame-ignore-revs). Cargo.toml states
rust-version 1.96, the floor the Proton crates set, and the build is
checked on it. make build installs the frontend from the lockfile.
A test reads the route table out of routes/mod.rs and asks every route
without a cookie: all but the login, the health check and the share
links must answer 401, including any route added later.
Media answers Range requests with 206 and Content-Range, so videos seek
and Safari plays them. Sealed files too: the vault's opener now decrypts
by record position (STREAM BE32 through StreamPrimitive, the same
construction and the same files), so a range starts at the record that
holds its first byte. Compression is limited to text, because a gzipped
body no longer matches the byte ranges a player asks for.
GET /api/health answers without a session once the database does, and
the compose healthcheck asks it instead of only opening the port.
Requests are logged at info with share tokens and keys left out of the
path and no query strings. A request that is not answered in 30 seconds
gets a 503, except uploads, imports, the export, re-sealing and backups.
The backup, mail, sweep and SFTP loops run under a supervisor that logs
a panic and starts the loop again. The backup loop starts without a
Proton session and picks up a later proton-login without a restart. A
lock file keeps backup-now and the server from mirroring at once, and an
unreadable session key file turns backups off instead of failing boot.
Numbers in the configuration are checked against bounds at startup, so
a session length or an upload limit can no longer overflow later, and a
switch that is neither on nor off is an error rather than off. Config
and MailConfig print with the password, secret and SMTP URL redacted.
A session epoch is signed into each cookie (migration 0014). :logout!
advances it, which ends every session, and a changed WORKSPACE_PASSWORD
advances it on the next start. Cookies issued before this keep working
in the first epoch.
An export used to lose what plain files cannot carry: dates, names as
typed, the boards. It now writes .workspace.json beside the tree (format
1), and an import that finds one restores them. The markdown stays
plain, so the archive still reads without the application.
POST /api/import (:import! / in the UI) imports at the root: each
top-level directory becomes a space, or merges into the space already
there by that name, with its board and cards and the diary role. With a
manifest, media/ comes back as embedded files rather than a folder, a
file whose name ends in .md stays a file, and empty folders come back.
Export claims every path in one set, folders, then documents, then
files, so a file named notes.md beside a document notes no longer makes
a duplicate entry that failed the whole zip. Links to folders are
rewritten like links to documents, in one regex pass per kind instead
of a replace per file and document. A sealed file that will not open
now fails the export instead of being left out silently. The temporary
zip is made in the data directory, not in /tmp.
DELETE /api/media/{id} joined the raw, percent-decoded id onto uploads/,
so ..%2Fdiary.db deleted the database. Ids are now parsed as uuids before
they reach the filesystem, and a file is only removed when its row was.
Import panicked on a link like %aé: percent_decode sliced the string
between the bytes of a character. Hex digits are now read as bytes. The
SFTP write_at offset is checked before it is cast, so an offset near
u64::MAX no longer wraps past the size limit and panics the session.
The global body limit was the upload ceiling, about a gigabyte, and
applied to the login too. Only the upload routes take that much now;
other bodies stop at 16 MiB and the login at 16 KiB.
Writes a browser says came from another origin are refused, which
SameSite=Lax alone does not cover for sibling subdomains or for
multipart forms. Logout only locks the vault for a caller who is
logged in, so a cross-site form can no longer shut it.
Login and vault attempts go through one at a time, so a burst of
parallel guesses cannot all pass the throttle before the first failure
is recorded, and parallel Argon2 runs cannot pile up.
Sealed files are served with no-store instead of a year of cache, so
their plaintext does not outlive :lock in the browser. The SFTP host key
is created 0600 rather than narrowed after being written.
SIGTERM is handled: as PID 1 in the container it was ignored, and every
docker stop ended in SIGKILL. Requests get eight seconds to finish, the
pool is closed so the WAL is checkpointed, and compose runs tini.
Argon2id turns the vault password into a key that seals a random master
key; only the sealed copy is kept. Each file gets its own key, sealed
under the master key in its header with its media id, and its bytes are
AES-256-GCM STREAM records, so a swapped, cut or altered file refuses
to open. Uploads with ?encrypt=true are sealed as they stream in.
:vault, :unlock, :lock, :passwd, :encrypt, :decrypt and :upload! drive
it. The vault locks after WORKSPACE_VAULT_IDLE_MIN idle minutes, on
logout and on restart; while locked, sealed files answer 423, share
links never serve them, and :export! refuses rather than leave them out.
Sealing an existing file rewrites it under a new id and retires the old
one: the mirror deletes the plaintext from Proton Drive outright,
trash included, whatever WORKSPACE_BACKUP_PRUNE says, and drops it from
the readable files/ view. The mirror now compares on-disk sizes, so
sealed files are not re-hashed every run.
A file node points at one media row and owns it the way a document owns
what it embeds, so folder deletes, the media browser and exports keep
working. POST /api/nodes/{id}/files streams uploads into a folder;
imports turn non-markdown files into file nodes; exports put them at
their tree path. The browser previews images, video, audio and text,
offers the rest as a download, and takes files dropped on the list.
Migration 0010 widens the kind CHECK by editing the stored schema, as
SQLite documents for loosening a constraint: sqlx runs migrations in a
transaction, where foreign keys cannot be switched off for a rebuild.
GET /api/agenda gathers unfinished cards from every board that are late,
due within a week, or in hand, plus whether the diary has today; the
reminder mail and the agenda share one diary check. t opens it (and it
opens itself once a day when something is late or due), T writes today's
entry, the bar counts late and due cards, Ctrl-P searches every space,
and a document o made that is left empty is removed on the way out.
Add an in-process SFTP server (russh) backed directly by the nodes
table, so a space can be edited from nvim or synced with sshfs without
a separate filesystem mirror. Access is authenticated by public key
against a new ssh_keys table, managed via :sshkey in the app; the
feature stays off until WORKSPACE_SFTP_BIND is set.
The diary grows into a workspace: a flat list of dated entries becomes a
tree of spaces, and the app is renamed to match what it now is.
Spaces and nodes
One recursive `nodes` table replaces `entries`: a space is a root node,
and folders and documents nest under it without limit. Entries keep
their ids through the migration, so share tokens, media links and the
drafts in localStorage all still resolve.
Folder import
A folder or a zip is uploaded as a document tree. Markdown becomes
documents titled from their first heading, everything else becomes
media, and relative links between the imported files are rewritten to
the paths they landed at, so a set of cross-linked notes stays
navigable after the upload.
Boards
A kanban board per space, with the space as the board: lists, cards,
due dates and an optional link to a document. `diary` has none.
Mail
SMTP notifications through an outbox. A mail is enqueued under a key
naming the occurrence it belongs to, so a restart cannot resend and a
relay that refuses is retried with a widening backoff. Four kinds: the
diary reminder, cards due and overdue, a weekly digest, and logins
from a browser that has not been seen before or a burst of failures.
All of it is dormant until WORKSPACE_SMTP_URL is set.
Rename
Everything user-visible says workspace; everything that already exists
outside this repo keeps its name, because renaming it would break a
running deployment for tidiness alone: the database file, the docker
volume, the session cookie, the Proton device, the keyring entry and
the localStorage keys. Configuration reads WORKSPACE_* first and falls
back to DIARY_*, so the deployed .env keeps working untouched.
The data directory is the whole diary, and until now keeping a copy of it
was someone's manual job. The server can now register itself in Proton
Drive as a device — its own sync root, next to the desktop clients — and
keep that folder equal to DIARY_DATA_DIR, encrypted client-side by the
same Rust SDK the Linux client uses.
One interactive `narl-diary proton-login`, then nothing prompts again.
The stored session carries the tokens, the mailbox password (the key
chain is rebuilt from it on every start) and the account key salts, which
have to be captured at login: `core/v4/keys/salts` needs the `locked`
scope, and only a password-login token has it. Refresh tokens are
single-use, so every rotation is written back before it can be needed.
It lives as a 0600 file next to the database, on the same volume as the
data it protects; `--features keyring` puts it in the OS keyring instead,
for hosts that have a Secret Service.
The mirror is one-way and change-driven: writes mark the diary dirty and
it is mirrored once the writing stops, with an interval as the backstop —
an hour of writing is one backup, not sixty. `diary.db` goes up as a
VACUUM INTO snapshot, so it is a real point in time rather than a file
copied out from under a writer, and as a new revision each time, so
Proton keeps the older ones. Uploads are immutable, so they are written
once and skipped by size afterwards. Deletions do not propagate unless
asked: a backup that forgets on command is one accident away from being
no backup at all.
Identification follows Proton's third-party rules — an honest
`external-drive-narl_diary@<version>-alpha`, the disclosure printed
before any credential is asked for, and remembered folder uids instead of
walking the tree every run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D5Gk8FPkRXu1e6Nj2BCRE1
There was no way to get everything out — only one entry at a time, by
hand. A diary you cannot leave is not a diary you can trust.
The archive is entries/ as one markdown file per entry named by its day,
media/ with every embedded file under its real name, and a README. Media
links are rewritten to relative paths, so the entries render in any
markdown reader with no help from this application. Names are restricted
to characters that need no escaping inside a markdown link.
It is built on the blocking pool into a temporary file and streamed, so a
diary with photographs in it never has to fit in memory.
Single-user diary served as one Rust binary with the Svelte frontend
embedded.
- axum 0.8 + SQLite (sqlx), migrations run at startup
- stateless HMAC session cookie; credentials from .env, compared in
constant time
- FTS5 full-text search, user input rewritten into quoted prefix terms
- media uploads linked to entries by scanning bodies for /api/media/<id>,
which scopes share-token media access and entry deletion
- unguessable share links (192-bit token) serving a public read-only page
- vim keybindings via CodeMirror plus a modal browse layer and : command
line; disabled on touch devices