Compare commits

..
1 Commits
Author SHA1 Message Date
sneak b89254f1b0 Bring README and TODO.md in line with next after the milestone merge (closes #132)
check / check (push) Successful in 1m6s
Docs only. TODO.md's Next Step no longer says open issues wait on
`next`: no implementation work is open, and the cache design waits on
sneak's review. The README is corrected where the code contradicts it:
where SRP lives and how the login subkey is derived, when email OTP is
used, how the auth token is encoded, the download retry's temporary
files (two for a live photo), which CLI commands open a library, what
`--exif` records, which commands take `--json`, the ML data fetch
during `quak backup`, the default cache directory, and the `408`/`429`
retries.

Model: opus-5-5
2026-09-29 02:00:23 +00:00
2 changed files with 75 additions and 123 deletions
+63 -83
View File
@@ -8,9 +8,9 @@ and downloads individual images while decrypting them on the way to disk.
quak also includes a resilient backup command that downloads every file in the
account into a deduplicated local directory tree, skipping files that already
exist on disk and continuing past individual download failures instead of
crashing. For each file it persists the basic metadata fields quak keeps (title,
file type, creation and modification time, latitude, longitude, content hash),
and the private and public magic metadata in full. A helper subcommand can
crashing. It decrypts and persists all three metadata layers (basic, private
magic, public magic) per file, including camera info, GPS coordinates, captions,
and any face/keyword labels the Ente clients have added. A helper subcommand can
detect and regenerate missing thumbnails, encrypting and uploading them back to
the server.
@@ -51,8 +51,7 @@ const client = await Client.login({
// Open a cache-backed library. On an empty cache this awaits one server
// refresh; on an existing cache it returns immediately and refreshes in the
// background. Later refreshes start `refreshIntervalSeconds` (default 3) after
// the previous one ends.
// background every `refreshIntervalSeconds` (default 3).
const lib = await Library.open({ client });
// Default reads answer synchronously from the local cache — no network.
@@ -63,7 +62,7 @@ for (const album of lib.albums.list()) {
}
}
// Fresh reads await a server round-trip, joining one already running.
// Fresh reads await a server round-trip and answer with current state.
const { albums } = await lib.fresh();
console.log(`${albums.list().length} albums as of now`);
@@ -85,7 +84,7 @@ enumeration/download calls) is exported too and documented under Design below.
This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and most Makefile targets are thin shims that call them.
development workflow, and the Makefile targets are thin shims that call them.
The scripts are POSIX sh (not bash) so they run in minimal containers such as
alpine. We provide:
@@ -171,19 +170,16 @@ local cache is reliable, and the UI is responsive on a five-year-old laptop.
All work on quak is test-driven. No exceptions.
1. Every change starts on a feature branch off `next`, and its pull request
targets `next`.
1. Every change starts on a feature branch off `main`.
2. The first commit on the branch is the test suite for what is being added or
changed. Those tests must fail at that commit; the branch is red until the
implementation lands.
3. Subsequent commits add the implementation and any refactors needed to make
the tests pass.
4. A pull request can only be merged into `next` when `make check` is green.
Once it has passed review, the repository manager squash-merges it into
`next`. Only sneak merges `next` into `main`. `main` and `next` are always
green. CI runs `script/cibuild`, which builds the `Dockerfile`: its `lint`
and `test` phases, then the compile, so neither a red branch nor one that
does not compile can pass CI.
4. A feature branch can only be merged into `main` when `make check` is green.
`main` is always green. CI runs `script/cibuild`, which builds the
`Dockerfile`: its `lint` and `test` phases, then the compile, so neither a
red branch nor one that does not compile can pass CI.
5. Tests are the canonical API documentation for this library. Every test file
is commented thoroughly enough that a reader who has never seen quak can
learn how to use it from the tests alone. Comments explain why a behavior
@@ -201,7 +197,7 @@ All work on quak is test-driven. No exceptions.
not the tests, and so not the full `make check`. This is deliberate so the
TDD red-phase commit (failing tests, no implementation yet) can land. The
`test` phase is part of the image build, which is what CI executes via
`script/cibuild`, so a red branch still cannot reach `next`.
`script/cibuild`, so a red branch still cannot reach `main`.
## Design
@@ -223,7 +219,7 @@ quak/
and search, request pools
backup.ts resilient full-account backup with dedup
metadata-backup.ts
backup-metadata: the metadata quak keeps, as JSON
backup-metadata: all decrypted metadata as JSON
mldata-fetch.ts fetch + decrypt per-file ML data
filename.ts safe file names from server metadata
errors.ts error types shared across layers
@@ -255,8 +251,7 @@ the repository root rather than `src/`, because `bin/` is compiled too and
All cryptography is done by `libsodium-wrappers-sumo` (the "sumo" build is
required for `crypto_pwhash` / Argon2id), except the SRP handshake, which uses
`fast-srp-hap`, and the MD5 checksum sent with a thumbnail upload, which uses
Node's built-in `node:crypto`. No hand-rolled crypto.
`fast-srp-hap`. No hand-rolled crypto.
The key hierarchy, derived during login, is:
@@ -267,12 +262,9 @@ The key hierarchy, derived during login, is:
3. SRP login: `crypto_kdf_derive_from_key` (BLAKE2b) derives a 32-byte subkey
from the KEK with subkey id 1 and context `loginctx`. Its first 16 bytes are
the SRP password.
4. When SRP completes, the server returns a blob of "key attributes" plus an
encrypted auth token, or first asks for a second factor. quak answers a TOTP
request with the code (`POST /users/two-factor/verify`), after which the
server returns them, and cannot answer a passkey request. When the account
has email MFA on (`isEmailMFAEnabled`), an email OTP replaces SRP and the
server returns them after it.
4. After SRP completes, or after the email OTP that replaces SRP when the
account has email MFA on (`isEmailMFAEnabled`), the server returns a blob of
"key attributes" plus an encrypted auth token.
5. `crypto_secretbox_open_easy` over the encrypted master key with the KEK
yields the 32-byte master key.
6. `crypto_secretbox_open_easy` over the encrypted secret key with the master
@@ -339,9 +331,8 @@ request is repeated only when repeating it could produce a different answer:
- Every other 4xx: not retried. A 404 in particular is an answer, and
`listMissingThumbnails` depends on getting it promptly and once.
- Transport failures — a `fetch` rejection, `ECONNRESET`, `ETIMEDOUT`, a DNS or
TLS failure — and deadline aborts: retried. Node's `fetch` rejects with a
plain `TypeError`, so every `TypeError` is retried. The errno is looked for in
the error's `cause` chain, because that is where Node's `fetch` puts it.
TLS failure — and deadline aborts: retried. The errno is looked for in the
error's `cause` chain, because that is where Node's `fetch` puts it.
- A truncated download: retried.
- Anything else, including a secretstream authentication failure that is not
truncation: not retried. The default answer is no. For a backup tool, retrying
@@ -410,10 +401,9 @@ and these endpoints have no Range support, so a retry starts the file over. The
atomic write is part of the retried unit: each attempt writes its own temporary
files, one for most files and two for a live photo (its image and its video),
and removes them if it fails. Only the attempt that completes renames anything
into place. The retry sits below `runBackup` and `runMetadataBackup`.
`runBackup` logs, counts and steps over a file that still fails after its
retries; `runMetadataBackup`, which downloads only with `--exif`, records the
error in that file's JSON as `imageMetadataError` and goes on.
into place. `runBackup` and `runMetadataBackup` are unchanged: the retry sits
below them, and a file that fails after exhausting it is still logged, counted,
and stepped over.
One imprecision is deliberate and worth knowing about. When a body ends part-way
through a secretstream chunk, Poly1305 fails and carries no framing signal, so a
@@ -437,11 +427,10 @@ base64-encoded keys) that the consumer can write to disk, a database, or
whatever else fits their use case. `Client.fromJSON(snapshot)` restores a
working client from that snapshot without re-authenticating; it checks every
field and each key's length first, and throws an error naming the bad field.
`client.logout()` clears the token and zeroes the key buffers in place; after
it, every other method on that client throws. It does not contact the server, so
the token stays valid there and in any saved snapshot;
`await client.logoutOnServer()` first ends the session on the server
(`POST /users/logout`).
`client.logout()` clears the token and zeroes the key buffers in place; every
later call on that client throws. It does not contact the server, so the token
stays valid there and in any saved snapshot; `await client.logoutOnServer()`
first ends the session on the server (`POST /users/logout`).
The CLI stores the snapshot at the platform-appropriate data directory via
`env-paths`: `~/Library/Application Support/quak/session.json` on macOS,
@@ -449,8 +438,7 @@ The CLI stores the snapshot at the platform-appropriate data directory via
`0600`. The key material is stored in cleartext in the JSON; treat this file as
you would treat the password itself. A missing file is reported as "not logged
in"; a file that exists but is corrupt is reported as such, naming the bad
field. Both exit with status 1, except that `quak logout` with no file says
there is no session and exits 0.
field. Both exit with status 1.
`quak logout` ends the session on the server, so the token in `session.json`
stops working even in a copy of the file, and then deletes the file. If the
@@ -473,7 +461,7 @@ quak files --collection <id> [--json] list files in a collec
quak get <fileID> [--out path] [--collection] download and decrypt a file
quak get-thumb <fileID> [--out] [--collection] download and decrypt a thumbnail
quak backup <dir> [--json] full incremental backup
quak backup-metadata <dir> [--exif] dump the metadata quak keeps as JSON
quak backup-metadata <dir> [--exif] dump all decrypted metadata as JSON
quak helper list-missing-thumbnails [--json] find files with missing thumbnails
quak helper fix-missing-thumbnails [--file ids] [--json] generate + upload missing thumbnails
```
@@ -500,13 +488,13 @@ its EXIF metadata and dimensions. `collections`, `files`, `backup`,
`--json` for machine-readable output.
`backup-metadata` fetches ML data in requests of up to 200 files. When a request
fails, the error is logged, each of its files is written with the reason in an
`mlDataError` field instead of `mlData`, and the dump goes on. The exit code is
non-zero if any ML data request failed.
still fails after its retries, the error is logged, each of its files is written
with the reason in an `mlDataError` field instead of `mlData`, and the dump goes
on. The exit code is non-zero if any ML data request failed.
`helper fix-missing-thumbnails` regenerates thumbnails for JPEG images only,
because the bundled decoder (`jpeg-js`) decodes only JPEG. A non-JPEG image
(PNG, HEIC) or a video is reported as `skipped` (unsupported format), kept
`helper fix-missing-thumbnails` regenerates thumbnails for baseline JPEG images
only, because the bundled decoder (`jpeg-js`) decodes only JPEG. A non-JPEG
image (PNG, HEIC) or a video is reported as `skipped` (unsupported format), kept
distinct from a `failed` repair, and does not affect the exit code; a genuine
failure still exits non-zero. The server accepts a new thumbnail only from the
file's owner and only when it is no larger than the thumbnail size it records
@@ -525,9 +513,7 @@ the smallest does not.
originals/
<fileID>.<ext> actual file content (one per unique file,
two for a live photo: see below)
<fileID>.json the file's basic metadata fields quak
keeps, and its private and public magic
metadata
<fileID>.json all decrypted metadata for that file
<fileID>.livephoto.json which of a live photo's two files is which
collections/
<name>/
@@ -615,8 +601,7 @@ Future (desktop client, separate repo):
The library's primary surface is the cache-backed `Library`; the lower-level
`Client` sits underneath it and is covered by the Design sections above. The
test suite is the canonical, executable documentation — `test/library/` and
`test/client/usage.test.ts` walk most operations, `test/cli/backup.test.ts`
walks `lib.backup()`, and `yarn test` verifies them.
`test/client/usage.test.ts` walk every operation, and `yarn test` verifies them.
### Opening a library
@@ -638,7 +623,7 @@ background, so an unreachable server does not block opening.
| `precacheThumbnails` | `true` | prefetch every thumbnail, newest first |
| `precacheOriginals` | `true` | prefetch the favorites album and the latest-window originals |
| `precacheOriginalsDays` | `7` | length in days of that latest window |
| `cacheOriginalsMaxBytes` | 100 GiB | size limit on the originals cache; pinned originals can exceed it |
| `cacheOriginalsMaxBytes` | 100 GiB | hard ceiling on the originals cache |
| `freeBelowBytes` | 50 GiB | free space to protect on the volume; the effective limit adapts down |
| `isOriginalPinned` | none | extra predicate for originals that must never be evicted |
| `pools` | fresh `RequestPools` | the bounded request pools (sets concurrency) |
@@ -660,21 +645,17 @@ can then be removed.
### Default reads vs. fresh reads
Default reads — `lib.albums`, `lib.photos`, `lib.timeline` — answer
synchronously from the copy held in RAM and never touch the network. A refresh
changes that copy only once all its server requests have succeeded, and the
background timer starts the next refresh `refreshIntervalSeconds` after the
previous one ends. So a default read is immediate, but only as current as the
last refresh whose requests all succeeded; right after opening an existing
cache, it is the copy on disk.
synchronously from the last refreshed copy held in RAM and never touch the
network. The background timer refreshes that copy every
`refreshIntervalSeconds`, so a default read is immediate but may be up to one
interval stale.
`await lib.fresh()` waits for a refresh to complete and persist, and returns the
same `{ albums, photos, timeline }` namespaces, which then reflect a completed
server round-trip. When a refresh is already running, background or not,
`fresh()` waits for that one, so its answer can come from requests made before
the call; only when none is running does it start one. A refresh that fails
rejects the caller (default reads stay silent and keep serving the last good
copy). The CLI's read commands use fresh reads (issue
https://git.eeqj.de/sneak/quak/issues/75).
`await lib.fresh()` forces a refresh, waits for it to complete and persist, and
returns the same `{ albums, photos, timeline }` namespaces — now guaranteed to
reflect a completed server round-trip. Concurrent `fresh()` calls coalesce onto
one refresh, and a refresh that fails rejects the caller (default reads stay
silent and keep serving the last good copy). The CLI's read commands use fresh
reads (issue https://git.eeqj.de/sneak/quak/issues/75).
### Read surface
@@ -691,8 +672,8 @@ https://git.eeqj.de/sneak/quak/issues/75).
`includeArchived`; hidden photos are always excluded.
An `Album` exposes its record fields and `album.photos.list()` → `Photo[]`
(newest first). A `Photo` exposes its record fields other than `thumbnailPath`
and `originalPath`, `photo.record()` → `PhotoRecord`, and two content methods:
(newest first). A `Photo` exposes its record fields, `photo.record()` →
`PhotoRecord`, and two content methods:
- `await photo.original(opts?)` → `{ path, bytes, videoPath? }` — the
full-resolution file. For a live photo, `path` and `bytes` are its image's and
@@ -741,16 +722,16 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange`
`SimilarResult[]` (`{ fileID, score }`, cosine similarity, most similar first,
default limit 20). quak bundles no text encoder, so `searchByEmbedding` takes
a query vector the caller produced elsewhere.
- `await lib.backup(opts?)` → `BackupResult`. It waits for a refresh as
`fresh()` does, fetches every in-scope original not already in the backup
(and, with `includeThumbnails`, thumbnails) through the content cache, and
rebuilds the on-disk backup tree with a durable failure ledger. A fetched
original is written straight into the backup's `originals/` and not into the
cache, which then counts it as present; one the cache already held is copied
from there. `BackupOptions`: `downloadDirectory` (falls back to the one
`open()` was given), `includeOriginals` (default `true`), `includeThumbnails`
(default `false`), `onlyAlbumNames`, and `onProgress`. See Backup layout above
for the tree it writes.
- `await lib.backup(opts?)` → `BackupResult`. It refreshes, fetches every
in-scope original not already in the backup (and, with `includeThumbnails`,
thumbnails) through the content cache, and rebuilds the on-disk backup tree
with a durable failure ledger. A fetched original is written straight into the
backup's `originals/` and not into the cache, which then counts it as present;
one the cache already held is copied from there. `BackupOptions`:
`downloadDirectory` (falls back to the one `open()` was given),
`includeOriginals` (default `true`), `includeThumbnails` (default `false`),
`onlyAlbumNames`, and `onProgress`. See Backup layout above for the tree it
writes.
### Request pools
@@ -842,15 +823,14 @@ documents:
`yarn.lock`. Never `git add -A`. Never force-push to main.
- **The "Development workflow" section above.** All changes go on feature
branches off `next`, and every pull request targets `next`; only sneak merges
`next` into `main`. Tests are written first and committed in a failing state
before the implementation. Tests are the canonical API documentation and must
be commented thoroughly. `main` and `next` are always green.
branches. Tests are written first and committed in a failing state before the
implementation. Tests are the canonical API documentation and must be
commented thoroughly. `main` is always green.
- **Required checks before every commit:** `make lint` must pass — that is
eslint plus the prettier check, and it builds the `lint` phase of the
`Dockerfile`, so it needs docker. The pre-commit hook enforces exactly that.
`make check` (which also runs the tests) must pass before merging into `next`.
`make check` (which also runs the tests) must pass before merging to `main`.
`make fmt-check` is available for a host-side formatting check on its own, but
it is not a separate requirement: `make lint` already covers it, and running
both would check formatting twice. Never invoke eslint or prettier directly;
+12 -40
View File
@@ -1,15 +1,12 @@
# Workflow
- branch from `next`
- branch (from `main`)
- do the work in Next Step
- move Next Step to the top of Completed Steps
- move the top item of Future Steps into Next Step
- commit (`TODO.md` changes in the same commit as the work)
- merge to `main` if the branch is not protected, otherwise open a PR
- push
- open a pull request that targets `next`
- once the pull request has passed review, the repository manager squash-merges
it into `next`
- only sneak merges `next` into `main`
# Status
@@ -25,44 +22,19 @@ declares one.
# Completed Steps
- 2026-09-29: The "Workflow" list at the top of this file now says to branch
from `next` and open a pull request that targets `next`, that the repository
manager squash-merges a reviewed pull request into `next`, and that only sneak
merges `next` into `main`, as the README does (issue 137).
- 2026-09-29: The README's "Development workflow" and "For LLMs" sections now
say work branches from `next`, every pull request targets `next`, the
repository manager squash-merges reviewed pull requests into `next`, and only
sneak merges `next` into `main` (issue 135).
- 2026-09-29: Brought the README and this file in line with `next` after the
milestone merge (issue 132). The Next Step says no implementation work is open
and the cache design (issue 36) waits on sneak's review. README corrections:
the Getting Started comments say when the library refreshes; most, not all,
Makefile targets call a script; `script/lint` and `script/test` have no host
path, though `yarn test` does; the SRP handshake uses `fast-srp-hap`, outside
`crypto/`, and a thumbnail upload's MD5 uses `node:crypto`; the SRP password
is the first 16 bytes of a 32-byte subkey; the key attributes and token come
after SRP, after the TOTP code SRP may ask for, or after the email OTP that
replaces SRP when the account has email MFA on, and quak cannot answer a
passkey; the auth token is sent as URL-safe base64 with padding; every
`TypeError` is retried; each download attempt writes its own temporary files,
two for a live photo, and only the attempt that completes renames them into
place; `runMetadataBackup` records a failed download in the file's JSON; a
second `client.logout()` does not throw; `quak logout` with no session exits
0, and names the cache directory only when it knows it; `login`, `whoami` and
`logout` open no library; `--exif` records XMP and, for a JPEG, EXIF, and no
IPTC; which commands take `--json`; a failed ML data request may not have been
retried; the thumbnail fixer is not limited to baseline JPEG; `quak backup`
still fetches ML data; a backup's JSON holds the basic metadata fields quak
keeps and the private and public magic metadata, not every decrypted field,
and the README no longer lists what the magic metadata holds; the default
cache directory is the per-user one, not an XDG path on macOS; pinned
originals can exceed `cacheOriginalsMaxBytes`; which tests cover which
operations; a default read is only as current as the last refresh whose
requests all succeeded; `fresh()` and `lib.backup()` join a refresh already
running; a `Photo` has no `thumbnailPath` or `originalPath`; and `408` and
`429` are retried.
`script/lint` and `script/test` have no host path, though `yarn test` does;
the SRP handshake uses `fast-srp-hap`, outside `crypto/`; the SRP password is
the first 16 bytes of a 32-byte subkey; email OTP replaces SRP when the
account has email MFA on; the auth token is sent as URL-safe base64 with
padding; each download attempt writes its own temporary files, two for a live
photo, and only the attempt that completes renames them into place; `login`,
`whoami` and `logout` open no library; `--exif` records XMP and, for a JPEG,
EXIF, and no IPTC; which commands take `--json`; `quak backup` still fetches
ML data; the default cache directory is the per-user one, not an XDG path on
macOS; and `408` and `429` are retried.
- 2026-09-28: Tested the live-photo writer's fsyncs (issue 130). A test checks
that the image's and the video's temp files are fsynced before either is