Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ dist
.git
.github
.claude
test
*.md
LICENSE
Dockerfile
Expand Down
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,16 @@ DECKLE_SESSION_SECRET=
# How long a sign-in lasts, in days (default 30).
# DECKLE_SESSION_TTL_DAYS=30

# Behind a reverse proxy — Caddy, Traefik, nginx, a Cloudflare Tunnel? Say so,
# so sign-in throttling sees each visitor's address rather than the proxy's.
# "true" means one proxy; use a number for a chain (Cloudflare in front of
# Caddy is 2). Leave it unset when browsers connect to Deckle directly:
# X-Forwarded-For is then ignored, because any client can write it.
#
# Unset behind a proxy, every visitor shares the proxy's address — so ten
# wrong passwords from anyone lock everyone out for fifteen minutes.
# DECKLE_TRUST_PROXY=true

# Optional. Where Deckle keeps its own state — currently the assistant settings
# shared by every device that signs in, API key included. Defaults to
# ".deckle-state" inside the library directory, which is the volume you already
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,9 @@ jobs:
# fails if the two have drifted, which is the point of running it here.
- run: npm ci

# The server through its real HTTP handler, the API, every storage path,
# the assistant's tools and autosave. Before the build, because a failing
# test says more about a change than a bundle that compiles.
- run: npm test

- run: npm run build
90 changes: 89 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,95 @@ here.

## [Unreleased]

Nothing yet.
A hardening release: security fixes, several ways data could be lost, and a
test suite. **If Deckle sits behind a reverse proxy, read the first item under
Changed** — one new setting keeps sign-in throttling working as it should.

### Security

- **The assistant's API key could be read through the file API.** Shared
assistant settings live in `.deckle-state`, which the library API is meant
to refuse — but a path written as `./.deckle-state/assistant.json` got past
the check. Any path that resolves into that folder is now refused, including
other capitalisations on case-insensitive disks and a `DECKLE_STATE_DIR` set
somewhere else inside the library. Reaching it took a signed-in session, but
it also put the key within reach of the assistant's read-only tools.
- **Password guessing was not really throttled.** Sign-in and API-token
throttling believed `X-Forwarded-For` from anyone, so a script could claim a
new address on every guess and never be locked out — or lock a real person
out by claiming theirs. The header is now ignored unless you set
`DECKLE_TRUST_PROXY`, and the number of addresses tracked is capped.
- **Changing `DECKLE_PASSWORD` signs everyone out**, even with a fixed
`DECKLE_SESSION_SECRET`. Sessions issued under the old password used to stay
valid until they expired.
- **One unreadable file could take the server down.** Downloading a note the
container isn't allowed to read — a root-owned file in a bind mount — crashed
the process for every user. That request now gets an error and nothing else
notices.
- **A Content-Security-Policy on every response.** Only the app's own scripts
run, whatever finds its way into the page.
- Bookmark links are drawn only for `http` and `https` URLs. A `javascript:`
URL in a synced or hand-edited `bookmarks.json` no longer runs when clicked.
- A ZIP import can no longer exhaust the tab's memory by claiming small sizes
and inflating to gigabytes.
- The assistant's file tools refuse `..` and absolute paths and won't write into
hidden folders, whatever the storage underneath would allow; its memory tools
can't climb out of the memory folder. A queued run's inbox fence no longer
counts `Assistant inbox/../Projects/plan.md` as inside the inbox.
- Signing out requires the app's own request header, like every other
state-changing call, so another site can't sign you out.
- Checking the password no longer reveals its length through timing.
- Updated dependencies with published advisories (`linkify-it`, `postcss`,
`nanoid`, `browserslist`).

### Changed

- **`DECKLE_TRUST_PROXY` — set it to `true` behind Caddy, Traefik or nginx.**
Without it, Deckle now throttles by the address that connected, which behind
a proxy is the proxy: every visitor shares one counter, and ten wrong
passwords from anyone lock everyone out for fifteen minutes. For a chain of
proxies (Cloudflare in front of Caddy), give the number instead. See
[Security](README.md#security).
- Completing a recurring task through `PATCH /api/v1/tasks/{id}` rolls its due
date forward, exactly as ticking it in the app does. It used to complete the
task and end the series.

### Fixed

- **Autosave could leave a note a paragraph behind the editor.** Two saves could
overlap whenever a write was slow — a server library on a slow link, or a save
that also took a history snapshot — and the older one could finish last,
while the indicator said *Saved*. Saves now happen strictly in order.
- **Tasks and bookmarks could be replaced by an empty list.**
- A change made while `tasks.json` or `bookmarks.json` was still loading was
saved on its own, over the file. It is now applied to what loads.
- A file that wouldn't parse, or came from a newer Deckle, loaded as empty
and was overwritten by the next change. The app now keeps a copy first
(`.deckle/tasks.unreadable-<time>.json`), and the API refuses to write
over it with `500 store_unreadable`.
- Switching libraries with a change still pending now saves it to the library
it belongs to, rather than dropping it.
- **Queued runs could lose their place with more than one running at once.**
Overlapping updates to the run index dropped rows or restored an old status,
and a run put back to *queued* could be executed twice.
- `/api/v1`: a `PATCH` that renamed a note and carried an invalid edit moved the
note and then answered `400`; it now changes nothing. A body that is valid
JSON but not an object (`null`, `[]`) is a `400` rather than a `500`. Due dates
must be real calendar dates, project and collection colours must be hex, and
ids must be strings.
- A malformed session cookie or login body is answered as such, not with a
`500`.
- An export whose client disconnected mid-download stayed in memory for the life
of the process.
- Saving shared assistant settings close to the size limit could be refused
because of the file's indentation, and two devices saving in the same moment
could fail.

### Added

- A test suite — `npm test` — covering the server through its real HTTP handler,
the API, every storage path, the queue, the assistant's tools and autosave. CI
runs it on every pull request.

## [1.6.0] — 2026-08-29

Expand Down
33 changes: 31 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,14 @@ DECKLE_SERVER_LIBRARY=true DECKLE_LIBRARY_DIR=./library node server/index.mjs &
npm run dev # → http://localhost:5173
```

To run the tests — the server through its real HTTP handler, the API, every
storage path, the assistant's tools and autosave:

```bash
npm test # once, as CI runs it
npm run test:watch # re-run on change
```

## Configuration

Only relevant when the server library is enabled.
Expand All @@ -283,6 +291,7 @@ Only relevant when the server library is enabled.
| `DECKLE_LIBRARY_NAME` | `My Notes` | Name shown in the app |
| `DECKLE_SESSION_SECRET` | *(random)* | Fixed cookie-signing key, so restarts don't sign everyone out |
| `DECKLE_SESSION_TTL_DAYS` | `30` | How long a sign-in lasts |
| `DECKLE_TRUST_PROXY` | *(none)* | `true` behind a reverse proxy (a number for a chain of them), so sign-in throttling sees real client addresses |
| `DECKLE_LIBRARY_DIR` | `/data` | Where the notes live inside the container |
| `DECKLE_STATE_DIR` | `<library>/.deckle-state` | Deckle's own state — the shared assistant settings. Never served as part of the library |
| `DECKLE_API_TOKENS` | *(none)* | Bearer tokens for the [API](#api). Blank leaves it switched off |
Expand Down Expand Up @@ -491,12 +500,15 @@ beyond Node itself.

```
server/ # Container runtime (Node built-ins only, no deps)
index.mjs # HTTP entry: routing, config, graceful shutdown
index.mjs # Entry point: boot, listen, graceful shutdown
app.mjs # The HTTP app: configuration, routing, error handling
library-api.mjs # Server library file API (tree/read/write/mkdir/delete)
library-store.mjs # Library semantics server-side: trash, history, tasks…
auth.mjs # Optional password gate + signed session cookies
api.mjs # /api/v1 REST API for agents and scripts
api-auth.mjs # Bearer tokens for the API, with read-only scopes
throttle.mjs # Failed-attempt throttling, and trusted-proxy client addresses
dates.mjs # Recurring-task dates, mirroring src/tasks/dates.ts
openapi.mjs # The API's self-served OpenAPI 3.1 description
search.mjs # BM25 ranking behind GET /api/v1/search
zip.mjs # Streaming ZIP writer behind GET /api/v1/export
Expand All @@ -518,6 +530,11 @@ src/
queue/ # Background run queue (.deckle/runs/), executed in the browser
lib/ # Markdown, PDF and ZIP export; Markdown import; BM25
styles/ # theme / global / editor / print CSS

test/ # npm test (Vitest)
server/ # The server through its real HTTP handler: auth, API, storage
client/ # Library, storage, queue, assistant tools, hooks and autosave
helpers/ # An in-memory File System Access API, and a test server
```

## Security
Expand All @@ -535,7 +552,19 @@ Relevant when you enable the server library.
isn't stored in the browser and isn't sent again after sign-in. Repeated
failures from one address are throttled (10 per 15 minutes). Sessions last
`DECKLE_SESSION_TTL_DAYS`; if one expires while the app is open, Deckle returns to
the unlock screen rather than failing saves silently.
the unlock screen rather than failing saves silently. Changing
`DECKLE_PASSWORD` signs every session out.
- **Behind a reverse proxy, set `DECKLE_TRUST_PROXY=true`.** Throttling counts
failures per client address. Without the setting Deckle uses the address that
connected — which, behind Caddy or nginx, is the proxy for everyone — and
ignores `X-Forwarded-For`, because any client can write that header and would
otherwise dodge the throttle by naming a new address on every guess. With it,
only the entries your proxy appended are believed.
- **The page runs only its own scripts.** Every response carries a
Content-Security-Policy with `script-src 'self'`, so script that reaches the
page by any route — a note, a bookmark, a dependency bug — cannot run. It
still allows the browser to call any AI provider, including an LM Studio on
your network.
- **AI keys.** The server never proxies AI requests — your browser always calls
the provider itself. Where the key is *kept* depends on the deployment: with a
password set, the server holds one copy for every device that signs in, stored
Expand Down
4 changes: 4 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ services:
# Optional: keeps sign-ins valid across restarts. Without it, a restart
# generates a new signing key and everyone signs in again.
DECKLE_SESSION_SECRET: "${DECKLE_SESSION_SECRET:-}"
# Behind a reverse proxy (Caddy, Traefik, nginx)? Set "true" — or the
# number of proxies in a chain — so sign-in throttling sees each visitor
# rather than the proxy. Unset, X-Forwarded-For is ignored.
DECKLE_TRUST_PROXY: "${DECKLE_TRUST_PROXY:-}"
# Bearer tokens for the /api/v1 REST API, so an agent or script can read
# and write the library. Blank (the default) keeps the API switched off.
# Format: "name:scope:secret" (scope "r" or "rw"), comma-separated.
Expand Down
Loading
Loading