23 Commits

Author SHA1 Message Date
a73f0abbe8 Check formatting once per make check, in the container (closes #29)
All checks were successful
check / check (push) Successful in 59s
script/check ran script/test, script/lint and script/fmt-check. Since
linting moved into Docker, script/lint is a build of Dockerfile.lint,
which runs `prettier --check .` as a build step — so make check checked
formatting twice over the same tree: once in the container and once on
the host. script/precommit had the same pair.

Drop the script/fmt-check call from both. The container keeps the check,
because a successful Dockerfile.lint build is what CI treats as proof of
a clean tree, and it is the stronger of the two verdicts: its prettier is
digest-pinned and installed under --frozen-lockfile, while the host's is
whatever the working tree happens to have. The pre-commit hook is
unchanged in what it catches — script/lint still fails a badly formatted
tree, and therefore the commit.

script/fmt-check survives as a standalone entrypoint, as REPO_POLICIES.md
requires, for asking the formatting question by itself without docker.
Its verdict cannot drift from the container's: prettier is pinned to an
exact version, installed from yarn.lock in both places, and reads
.gitignore as its default ignore file, which is why .dockerignore keeps
.gitignore in the build context.

The count is asserted rather than promised. test/packaging/lint-once.test.ts
walks the invocation graph from each entrypoint — through the Makefile
shims, the script/ calls, the package.json scripts and the docker build
into Dockerfile.lint's RUN steps — and counts prettier invocations: one
per make check, one per script/precommit, and one each for make lint and
make fmt-check alone, so neither can become a no-op that satisfies the
count trivially. The walk also asserts which nodes it reached, so a
restructure that defeats the resolver fails the test instead of quietly
counting zero.

Observed: 2 prettier invocations per make check before, 1 after.
2026-08-10 13:05:03 +00:00
fed39d19cf Run all linting in Docker via Dockerfile.lint (closes #30)
All checks were successful
check / check (push) Successful in 1m2s
Linting now happens in one place only: a new root Dockerfile.lint copies
the repo into the digest-pinned node image already used by Dockerfile and
runs eslint and prettier as build steps, so a successful build is a clean
lint. script/lint is reduced to building it, which also works where the
docker daemon is remote and bind mounts are impossible. No host lint path
survives: the "lint" script is gone from package.json, so there is no
second, unpinned way to get a lint verdict.

Caching is waived for lint, because a lint build over an unchanged tree
returns success in well under a second having linted nothing. LINT_EPOCH
is the cache buster and it fails closed exactly as CHECK_EPOCH does: an
unset ARG is the empty string, which is a perfectly stable cache key, so
the guard rejects it and a bare `docker build -f Dockerfile.lint .` errors
out instead of serving a green it did not earn. Both linters sit below the
guard, so a fresh epoch forces them to execute while the bootstrap and
dependency layers above stay cached.

That makes script/lint a docker build, which nothing inside a container
may call. script/check calls script/lint, so the Dockerfile image can no
longer run make check: the lint stage and its COPY --from=lint ordering
hack are deleted, and the remaining stage runs make test and make build
under the existing CHECK_EPOCH guard. script/cibuild is now the composite
gate and builds the lint image first, so a lint failure is reported before
the slower suite runs.

The .dockerignore exclusions are unchanged and still apply to the lint
build, including the .claude/ exclusion (eslint's flat config does not
ignore dot-directories, so a nested worktree in the context would be
linted) and the deliberate exception that keeps .gitignore in the context
for prettier. A new test asserts no per-Dockerfile ignore file shadows the
root one for either image, and test/packaging/lint-docker.test.ts asserts
the whole shape: the docker-only lint path, the digest pin, manifests
copied before sources, the fail-closed guard with both linters below it,
the absence of a lint stage or make check in Dockerfile, and the build
order in script/cibuild.
2026-08-10 12:39:23 +00:00
156fe871e8 Make the image build multi-stage and cache-proof (closes #4)
All checks were successful
check / check (push) Successful in 23s
The image build reported a green it had not earned. `script/cibuild` is a
bare `docker build .`, and with `COPY . .` followed by `RUN make check`,
an unchanged tree served that layer from cache: the suite never ran and
the build still exited 0, while the script's header comment asserted the
opposite.

CHECK_EPOCH, passed by `script/cibuild` and `script/docker`, changes the
cache key of the check and build layers on every invocation. It is
guarded, because an unset ARG is the empty string and therefore a stable
key: without the guard a plain `docker build .` — the command the policy
names, and the one anyone debugging types — would still get the false
green. A missing argument is now a hard failure rather than a silent
degradation to the behaviour the epoch was added to prevent.

The Dockerfile is now two stages: `fmt-check` and `lint` run first, and
the check stage takes a `COPY --from=lint` dependency on them, so a
formatting mistake fails the build in seconds instead of racing the
suite to the finish. Both stages stay pinned to the same digest.

The remaining fixes are one-liners that had made the target unusable:
`script/projectname` still printed the pre-rename name, so `make docker`
tagged its image after a name this project dropped in May;
`script/bootstrap` installed without fetching apt's package lists, which
cannot work on a Debian base; and `.dockerignore` had drifted far enough
from `.gitignore` to ship a ~100 MB compiled binary and any agent
worktree under `.claude/` into the build context. The second of those is
a correctness problem, not a size one — vitest globs a copied worktree's
tests alongside the real ones and runs the suite twice over. `.gitignore`
itself stays in the context, because prettier reads it as a default
ignore file and dropping it would change what `make fmt-check` sees.
2026-08-09 14:38:32 +00:00
69bd6d1539 Compile bin/ alongside src/ so the package can be built (closes #3)
All checks were successful
check / check (push) Successful in 5s
rootDir was ./src while include also matched bin/**/*, which is TS6059: tsc
refuses to emit at all when a compiled file sits outside rootDir. rootDir is
now the repository root, which is the smallest change that makes the two
agree and leaves the source layout the README documents alone. Output keeps
the shape of the source tree, so main and types move to dist/src/index.js and
dist/src/index.d.ts while bin.quak stays at dist/bin/quak.js. The alternative,
moving the CLI body into src/ behind a shim in bin/, would hold main at
dist/index.js at the cost of churning the CLI and contradicting the layout
diagram in the README.

Clearing TS6059 exposed two type errors that had never been reached, because
the config error aborts before checking: StateAddress was read as a namespace
member off the default import, and the secretstream pull was called without
the additional-data argument, which libsodium does not make optional. The type
is now taken from the module's named export and the pull passes null for ad,
matching the null already passed on the push side in encryptBlob. Neither
changes what runs.

noEmitOnError stops a failed build from leaving output behind. It emitted
despite the error before, which is how a stale bin/quak.js came to sit next to
bin/quak.ts in a working tree, where eslint then read it and failed make check
on a generated file.

script/build compiles and then checks that the files package.json advertises
are among the ones the compiler wrote, since tsc knows nothing about the
manifest and a green build could still ship a package whose main resolves to
nothing. It also sets the executable bit on the bin entries, which tsc does
not carry over from the source even though it does copy the shebang. The
Makefile target is now a shim over it, as the other targets are, and
package.json's build script points at it so yarn build gets the same checks.

The Dockerfile runs make build after make check, so a branch that does not
compile cannot reach main. What make check itself runs is unchanged.

package.json gains a quak script, so the yarn quak commands the README's
Getting Started block has always listed resolve to the built CLI.
2026-08-09 10:06:31 +00:00
348f23bac9 Narrow the replay errno set to failures that prove no connection existed
All checks were successful
check / check (push) Successful in 4s
`CONNECT_CODES` drives `isSafeToReplay`, which is the only thing standing
between a transport failure and a replayed `POST /users/two-factor/verify`.
It included `EHOSTUNREACH`, `ENETUNREACH` and `ENETDOWN` on the stated
grounds that those errnos can only be reported before any request byte was
written. That is not true on Linux: an ICMP destination-unreachable
delivered on an already-established connection sets the socket error and
the next read or write returns `EHOSTUNREACH` or `ENETUNREACH`, and a local
interface going down after the request was fully written surfaces as
`ENETDOWN` the same way. In each case the server may already have received
and acted on the request -- exactly the ambiguity the rule exists to
exclude, on the paths that consume a second-factor attempt or register a
thumbnail.

The three are dropped from `CONNECT_CODES` and stay in `TRANSPORT_CODES`,
so they remain retryable for the idempotent calls; only replay eligibility
narrows. What is left -- `ENOTFOUND`, `EAI_AGAIN`, `ECONNREFUSED` -- means
no TCP connection to the server ever existed, so no request byte can have
been transmitted.

The justification is corrected everywhere it was stated: the comment on
`CONNECT_CODES`, the one on `isSafeToReplay`, the `postJSON` call site, the
README's idempotency section and the `client.test.ts` docblock. All of them
now describe what the narrowed set actually establishes rather than
claiming a proof it did not support.

The narrowing is enforced by the suite rather than asserted in a comment:
the three errnos join `ECONNRESET`/`EPIPE`/`ETIMEDOUT` in the
`isSafeToReplay`-returns-false test, with companion `isRetryable` assertions
so a future edit cannot make them non-retryable by accident. Putting the
three back into `CONNECT_CODES` turns that test red (1 failure, verified).
2026-08-09 05:41:59 +00:00
f3cf4af833 Retry transient network failures with exponential backoff (closes #2)
All checks were successful
check / check (push) Successful in 20s
No retry on 4xx, backoff on 5xx and transport failures, and a deadline on
every request. Before this, one transient 503 or TCP reset failed a file
for good, and a CDN connection that went quiet after accepting the request
blocked `quak backup` forever, because there was no timeout anywhere.

src/retry.ts holds the policy: a classifier that decides whether another
attempt could produce a different answer, and a loop that acts on it with
exponential backoff and full jitter. Retried: 5xx, 408, 429, transport
failures (the errno is read out of the cause chain, which is where Node's
fetch puts it), deadline aborts, and truncated transfers. Not retried:
every other 4xx, and anything unrecognised — a wrongly retried permanent
failure delays every remaining file, while a wrongly abandoned transient
one costs a single file the next run picks up. Attempt count, delays,
sleep and jitter source are all configurable through ApiClientOptions;
sleep being injectable is what lets the suite exercise the policy without
waiting.

Truncation needed a type before it could be classified. streamDecrypt
threw plain Errors whose messages began "download: stream truncated", and
classifying on message text would mean the next reword silently turned
every truncated download into a permanent failure. It now throws
TruncatedStreamError, which lives in src/errors.ts alongside ApiError so
the classifier can recognise both without importing the modules that
import it; api/client.ts re-exports ApiError, so it stays one class and
every existing import path still resolves.

Downloads retry the request, the stream consumption and the decryption
together. Only the first of those happens inside ApiClient: a socket
reset after the headers arrived throws in streamDecrypt, and retrying the
request alone would never see it. The client's own retry is switched off
for those two calls so the budgets do not multiply into sixteen requests
per file, and the atomic write stays outside the loop so a download that
took three attempts still performs one write and one rename.

Non-idempotent requests are not blindly replayed. postJSON and putJSON
reach create-session, two-factor/verify — which burns one of a few
second-factor attempts — and files/thumbnail, so they retry only when the
connection was never established and the server provably never saw the
request. putFile is exempt and retries fully: a presigned PUT stores one
whole object at one key, with no partial state to damage. It now throws
ApiError with the status, as do the two null-body paths, which previously
threw bare Errors that nothing could classify.

Timeouts come from AbortSignal.timeout(), renewed per attempt: 30s for
JSON and upload calls, 10 minutes for file bodies, since a value short
enough to keep a hung API call from stalling a backup would cancel a
legitimate multi-gigabyte download. The download deadline is enforced
over the body rather than only the headers, by racing each read against
the signal, so the guarantee does not depend on the fetch implementation
tearing down a stream it already handed over.

listMissingThumbnails now separates a genuine 404 from an exhausted
retry. Its bare catch reported both as missing, which after this change
would have let a few minutes of 500s talk fix-missing-thumbnails into
regenerating and re-uploading thumbnails that were fine. runBackup and
runMetadataBackup are untouched: the retry sits below them and their
per-file resilience is unchanged.
2026-08-09 05:21:31 +00:00
4f506b0155 Adopt scripts-to-rule-them-all: script/ entrypoints, Makefile shims 2026-07-07 00:20:19 +02:00
16ea7b1f03 Rewrite README to match current implementation
Fixes accumulated drift from the original spec-first README:

- Intro: added paragraph describing backup, metadata decryption, and
  thumbnail repair capabilities
- Rationale: removed the 'deliberately scoped to read operations'
  claim (no longer true since thumbnail upload exists); called out
  the Go CLI's crash-on-failure bug as explicit motivation
- Getting Started: fixed library example to use actual Client.login()
  API, removed nonexistent fromSavedSession/getFile methods, added
  backup CLI example
- Layout: fixed to match actual directory structure (download/,
  backup.ts, thumbnails.ts; removed nonexistent session/)
- Session handling: replaced the fictional encrypted-keychain
  SessionStore description with the actual implementation (plain JSON
  via env-paths, consumer-managed persistence via toJSON/fromJSON)
- CLI surface: added backup, helper list-missing-thumbnails, and
  helper fix-missing-thumbnails commands
- Backup layout: new section documenting the originals/ + collections/
  symlink structure
- API reference: replaced the stale type declarations with pointers
  to the actual source files and a note that test/client/usage.test.ts
  is the authoritative API tutorial
- TODO: collapsed completed phases, kept only open items
- For LLMs: new section summarizing repo policies, TDD workflow,
  required checks, formatting rules, and pointers to REPO_POLICIES.md
  and LLM_PROSE_TELLS.md
2026-06-09 12:35:40 -04:00
ec2d12b986 Add collections, files, get, get-thumb CLI commands
Complete CLI surface:
  quak login          interactive or QUAK_EMAIL/QUAK_PASSWORD
  quak whoami         print logged-in account
  quak logout         delete session
  quak collections    list all albums (--json)
  quak files          list files in a collection (--json)
  quak get <id>       download+decrypt a file (--out, --collection)
  quak get-thumb <id> download+decrypt a thumbnail
  quak backup <dir>   full incremental backup

get/get-thumb search all collections for the file ID when --collection
is not specified. All listing commands support --json.

Live-tested: collections list, file list, single file download (472 KB
JPEG from the dev account, verified as valid JPEG with EXIF intact).
2026-05-13 20:49:13 -07:00
d8a4b0291e Rename quack to quak
German for 'quack', matching the Ente (German for 'duck') naming. All
references updated: package name, CLI binary, X-Client-Package header,
test descriptions, temp dir prefixes, README, Makefile docker tag.
2026-05-13 18:02:55 -07:00
58c8db4ea9 Update public exports and README for Client class
Exports Client, all lower-level modules, and all types from
src/index.ts. Replaces Phase 7 (on-disk session persistence) with
the Client class phase: session lives in the object, consumer
handles persistence via toJSON/fromJSON.
2026-05-13 18:01:18 -07:00
75af244815 Tick off Phase 6 download TODO 2026-05-13 17:54:19 -07:00
9ea829aaa6 Tick off Phase 5 collection/file decryption TODO 2026-05-13 17:38:36 -07:00
22260c142f Tick off Phase 3 SRP + auth TODO 2026-05-11 10:11:37 -07:00
87ff5f3108 Tick off Phase 4 ApiClient TODO 2026-05-11 01:02:13 -07:00
78fdabe54a Phase 3a green: implement auth.unwrapAuth
The implementation is exactly the decryption chain documented in the
test file: deriveKEK -> decryptBox(masterKey) -> decryptBox(secretKey)
-> decryptSealed(token) -> toBase64URL. Errors from the underlying
crypto primitives propagate; the only added validation is the up-front
check that the response actually contains both keyAttributes and
encryptedToken (caller bug if not).

Also re-exports the auth/unwrap and auth/types public surface from
src/index.ts.

All 38 tests pass; make check and make docker are green.
2026-05-11 00:59:43 -07:00
52c2fa844c Tick off Phase 2 crypto primitives TODO 2026-05-09 12:45:53 -07:00
0d504294b5 Pre-commit hook runs lint+fmt-check, not full check (TDD)
Tests are deliberately excluded from the pre-commit hook so that the
TDD red-phase commit (failing tests landing before implementation) can
land on a feature branch. CI still runs full make check via docker build
so a red branch cannot reach main.
2026-05-09 21:39:32 +02:00
3ccb00d6c8 Mandate TDD in README development workflow
All changes go on a feature branch; the first commit on the branch is the
failing test suite for the change; the branch can only merge to main when
make check is green. Tests are the canonical API documentation and must be
commented thoroughly so a reader can learn the library from them.
2026-05-09 21:37:33 +02:00
42cc1f4a77 Tick off Phase 1 scaffolding TODO items in README 2026-05-09 21:32:32 +02:00
ed8b3a8fff Reflow README to prettier 80-column prose wrap 2026-05-09 21:29:08 +02:00
64108536fb Expand README: desktop-client motivation and API reference
Reframes the project as the protocol foundation for a future Electron-based
Ente desktop client (the existing official clients are unsatisfactory).
Adds an API reference section with TypeScript declarations for every
exported name across crypto, auth, model, api, session, and Client modules.
Adds Phase 10 desktop-client TODO items so a future agent can pick up the
plan.
2026-05-09 21:25:05 +02:00
0006af4f70 Initial commit: README with project spec and implementation plan 2026-05-09 18:57:58 +02:00