Ctrl K
ComunidadGuías…

CHANGELOG.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Changelog

Notable changes to the Secret app, published as the `ghcr.io/largerio/secret`
image. Each version links to its GitHub release. The SDK and the command
line have their own changelogs ([SDK](packages/sdk-js/CHANGELOG.md),
[CLI](packages/cli/CHANGELOG.md)), and
[SECURITY.md](SECURITY.md#supported-versions) lists the supported versions.

## [Unreleased]

## [1.8.0] - 2026-11-07

### Security

- Every page's `Content-Security-Policy` lists the inline scripts the page may run by their SHA-256, taken from the build, where SvelteKit drew a nonce for each response. Scripts come from the instance alone, and pages still cannot be framed. What a page may run is the same on every instance of a release, and can be checked against it. (#272)
- The web app is a reproducible bundle: built from the same sources, anywhere, it gives the same files, which CI checks on every change by building it on two systems. Each release publishes its digest; `/api/health` reports as `bundle` the digest of the web app the instance serves, and `/bundle.json` lists its files with their SHA-256. Anyone can check what an instance serves them against a release, its pages and their policy included: `scripts/verify-web-app.sh` does it all from a checkout of the release's tag, as [SECURITY.md](https://github.com/largerio/secret/blob/main/SECURITY.md#verifying-the-web-app) explains, and CI runs it on every change against the image it builds. (#272)
- The API's responses carry the pages' `Permissions-Policy`, which they sent without `payment` and `usb`. (#272)

### Added

- A command line, `secret`, published to npm as `@largerio/secret-cli`, sends and reads notes from a terminal or a script: text on stdin, files as arguments, `--json` for scripts. It writes with an API key, or without one by solving the instance's proof of work, as the browser does, which [PROTOCOL.md](https://github.com/largerio/secret/blob/main/PROTOCOL.md#proof-of-work) now specifies. Received files never overwrite anything and show only once the whole note is checked. (#270)
- The SDK exports the protocol's limits (`MAX_TEXT_SIZE`, `MAX_READS`, `NOTE_ID_LENGTH`…), to check a note before it is sent. (#270)

### Changed

- The image runs one process instead of two: the API serves the web app and the API on `PORT` (3000), without the web server that stood in front of it and proxied every API request. The web app's dependencies no longer run on the server at all. (#272)
- The web app is a static bundle, and pages are no longer rendered on the server. For each page, the server writes into the one HTML document the visitor's language, theme and accent, the page's title and meta tags, and the instance's settings; everything else is static files, cached for good when their name carries their hash. (#272)
- A note's page checks its note from the browser, with `/api/v1/notes/{id}/exists`, instead of while the server rendered the page. (#272)
- The app refuses to start when `messages/en.json`, which every language falls back to, lacks a key the pages' heads need, rather than writing the key's name into them. (#272)

### Fixed

- With the SDK, a format 0 link whose key was cut short in transit spent the note's read before the key was refused, which burned a note read once; and a malformed note id could aim a request at another endpoint of the instance, so that a check could spend a format 0 note's read. The SDK now refuses both before any request. The web app already checked a link's shape. (#270)
- `APP_DESCRIPTION` was read but used nowhere. Set, it is now the home page's description in every language: its meta description, `og:description`, `twitter:description` and JSON-LD. Unset, the default, each language keeps its translated description. A note's page keeps its own. (#272)
- The page that deletes a note said "Note not found" when the instance could not be reached or limited the visitor's requests, so that a note's creator could be told it was gone while it could still be read. It now shows the notices of the note's page. (#272)
- A language that `Accept-Language` gives `q=0`, meaning not wanted, could be chosen for the pages, and a three-letter code read by its first two letters: Konkani (`kok`) as Korean, Jamaican Creole (`jam`) as Japanese. Neither happens now; English stays the default when the header wants none of the app's languages. (#272)
- Files an operator adds to the web app's build, `.ico`, `.jpg` and `.jpeg`, `.webp`, `.avif`, `.gif` and `.webmanifest`, were served as `application/octet-stream`. They get their content types. (#272)
- A `KEY_FILE` in a folder that did not exist stopped the container at every start: the entrypoint created the database's folder, not the key's. (#272)
- `/sitemap.xml` offered the home page in English and French only; it names every language the app speaks, as the pages' `hreflang` links do. (#272)

### Upgrade notes

- Port 3001 is gone: the API no longer listens on a port of its own, inside the container, or outside it with `API_HOST=0.0.0.0`. Anything that reached it there must use the public port, `PORT`, where the API answers under `/api/` as before. `API_HOST`, `API_PORT` and `API_URL` are no longer read, and the logs warn when one is set. (#272)
- `TRUSTED_PROXIES` no longer defaults to `127.0.0.1/32`, which trusted the web server inside the image. Behind a reverse proxy, `ADDRESS_HEADER` and `XFF_DEPTH` work as before ([Making rate limiting work behind a proxy](https://github.com/largerio/secret/blob/main/docs/self-hosting.md#making-rate-limiting-work-behind-a-proxy)). Without `ADDRESS_HEADER`, `TRUSTED_PROXIES` now applies to whatever connects to the container, your proxy, and no longer to a web server inside it. (#272)
- With neither `ADDRESS_HEADER` nor `TRUSTED_PROXIES` set, the app now logs `WARNING: neither ADDRESS_HEADER nor TRUSTED_PROXIES is set` at every start, since the image no longer sets `TRUSTED_PROXIES`, which kept it quiet. That includes `docker compose up` with the default files, and a platform such as Render, whose own proxy sits in front. Behind a proxy, set `ADDRESS_HEADER` and `XFF_DEPTH`, as the warning says; exposed directly, with no proxy in front, ignore it. (#272)
- The app checks `ADDRESS_HEADER` and `XFF_DEPTH` at startup, and refuses to start on an invalid one: `ADDRESS_HEADER` must be a header name, and `XFF_DEPTH` a whole number from 1, even with `ADDRESS_HEADER` unset; blank still means 1. Up to 1.7 the web server took them as they came: `XFF_DEPTH=0`, junk or a header name with a space gave every visitor the one shared bucket, and `2x` counted as 2. Check both before upgrading. (#272)
- The app refuses to start when `MAX_FILE_SIZE`, `CHUNK_SIZE` or `MAX_CHUNKED_FILE_SIZE` is not a whole number of bytes. Up to 1.7, `CHUNK_SIZE=1048576.5` or `MAX_CHUNKED_FILE_SIZE=Infinity` were taken, and handed clients limits they could not use: the web app rounded them, `/api/v1/config` answered them as they were, and the command line refused such an instance. (#272)
- If you set `APP_DESCRIPTION`, your instance now shows it: in every language, as the home page's description in search results and link previews. Up to 1.7, `.env.example` set it to `Secure zero-knowledge note and file sharing`, and `docker-compose.yml` defaulted to the same sentence: remove that line from a `.env` copied from it, and download `docker-compose.yml` again (or change its line to `APP_DESCRIPTION=${APP_DESCRIPTION:-}`), to keep each language's own description. (#272)
- `/__data.json` no longer exists: a login in front of note creation ([Restricting who can create notes](https://github.com/largerio/secret/blob/main/docs/self-hosting.md#restricting-who-can-create-notes)) only needs `/` and `/api/cap/`. (#272)

## [1.7.0] - 2026-10-30

### Added

- Large files no longer need to fit in the browser's memory. The web app reads a file from disk as it encrypts it, a chunk at a time, after opening the upload session, so a refused upload costs no encryption. On read, it decrypts a note's files to the browser's private storage for the site, previews and downloads them from there, and deletes them once the page lets go of the note; where that storage is missing (a private window, an instance served over plain HTTP), files stay in memory as before. ([SECURITY.md](https://github.com/largerio/secret/blob/main/SECURITY.md#known-limitations)) (#266)
- The web app encrypts and decrypts in a worker, off the page's thread: the page no longer freezes while a password is stretched (Argon2id, 256 MiB) or a file encrypted, and no page loads libsodium itself any more, about 200 KB compressed. (#266)
- The SDK takes a file's bytes as a `Blob` (a browser's `File`, Node's `fs.openAsBlob()`), read a slice at a time as it is encrypted, and `readNoteInto` writes a note's files to streams of the caller's, each closed once the whole note is read and checked, aborted otherwise. [PROTOCOL.md](https://github.com/largerio/secret/blob/main/PROTOCOL.md#reading-strictly) says how a reader that writes files out as it reads keeps them unseen until then. (#266)
- Every API error answers [problem details](https://github.com/largerio/secret/blob/main/PROTOCOL.md#errors) (RFC 9457, `application/problem+json`) with a stable `code`, such as `note_not_found`, `wrong_password` or `rate_limited`, for clients to tell errors apart by; `error` still carries the message for older ones. The API reference documents them, and PROTOCOL.md lists every code, with what a `429` and its `Retry-After`, a `HEAD` on a read, a missing note's `/exists` and an empty `/complete` answer. (#263)

### Changed

- A create and the start of an upload session answer the expiry the instance keeps, to the second, as every read does (`.000Z`): they used to add milliseconds that no later read answered. (#263)

### Fixed

- Downloading a file from a note could fail in some browsers: the page revoked the file's address as soon as the download started. Each file now keeps one address, for its preview and its download, until the page lets go of the note. (#266)
- Through the web server, a request the API refused before reading its body (a `401` for a missing token, a `404` for an unknown upload, a `413`) often answered `502`, or nothing at all. The API, then the web server, closed the connection while the body was still arriving, and the reset that drew could make the refusal be thrown away unread. Both now keep reading for up to 2 seconds before closing. (#265)
- With the default `CHUNK_SIZE`, files between 496 and 500 MB were refused once the browser had encrypted them, since their padding counted against `MAX_CHUNKED_FILE_SIZE`. The limit now bounds the files, and a note may take the room its padding needs on top: 0.8% at 500 MB. (#265)
- A `403` from a firewall or a proxy in front of the instance was taken for a wrong password when opening a note, and for a wrong token on the deletion page. The web app now says that something on the way blocked the request; with the SDK, it is an API error without a code. (#263)

### Upgrade notes

- Errors answer `application/problem+json` instead of `application/json`: a client that checks the type of an error's body should accept both. The body keeps `error`.

## [1.6.0] - 2026-10-30

### Added

- [PROTOCOL.md](https://github.com/largerio/secret/blob/main/PROTOCOL.md) specifies format 0, the encryption format of every release before this one: the keys and the password derivation, the payload, single-shot and chunked notes, the API flows, what the server learns and the limits of this format. With the test vectors, it is enough to write a client in another language. (#249)
- [Format 1](https://github.com/largerio/secret/blob/main/PROTOCOL.md#format-1), the new encryption format, which the web app now writes: links that name their format (`#1.<key>`), a key derived with HKDF, the Argon2id parameters stored with the note, passwords normalized to NFC, and the note's settings in an envelope that the ciphertext authenticates. The API accepts and serves format 1 notes next to format 0 ones, which it keeps reading and accepting. A read names the format it expects, so a client that knows only format 0 is refused a format 1 note without spending a read. (#253, #258)
- Format 1 notes are read with a [proof](https://github.com/largerio/secret/blob/main/PROTOCOL.md#proof-of-reading): the reader proves that it holds the link, and the password of a password note, with `POST /api/v1/notes/{id}/reads`, and the server serves nothing before. A wrong password spends no read, even of a burn-after-read note: the web app says how many attempts are left, and the fifth wrong password deletes the note. Whoever knows only a note's id can no longer read it or burn it. `/exists` serves the envelope a reader derives its proofs with, and the password attempts left. The server keeps what checks the proofs under a key derived from `SERVER_ENCRYPTION_KEY`, so a copy of the database alone gives nothing to test passwords against. The GET reads serve format 0 only. (#256, #258)
- Format 1 notes are [padded](https://github.com/largerio/secret/blob/main/PROTOCOL.md#the-body): a note is always one secretstream, whose content is padded to a size class (Padmé, at least 4,096 bytes), so the instance no longer learns the length of a text or the size of each file, only the class, at most 6.25% above the content. Every note up to 4 KiB, a password as much as a private key, has the same size. A note sent in one request is one message, and `POST /notes/{id}/reads` answers every note as messages. The API refuses a format 1 note whose length is not a size class, or whose messages differ in length. The envelope loses its kind byte (33 bytes, 57 with a password). `/exists` answers `chunked: false` for every format 1 note, which would otherwise tell anyone who knows its id whether it went up in a session. (#257)

### Changed

- Every API route checks its request against the schema the API reference publishes for it, the uploads and binary reads included, which used to check theirs by hand. A malformed request answers `400` with `Invalid request`, and a body of the wrong type `415`. (#252)

### Fixed

- A chunk sent again while its upload was being completed could end up in the note, its new size counted nowhere: against the storage quota, it weighed what the chunk it replaced did. A completion now answers `409` while a chunk of its upload is still being written, and so does a second sending of a chunk still being written. (#257)
- A `HEAD` request on a note's reads (`/api/v1/notes/{id}`, `/raw`, `/stream`) spent one of its reads, and destroyed a single-read note, without delivering anything. It now answers `405`. (#254)
- An authenticated create sent without a body, or without a `Content-Type`, answered `500` and logged an error; it now answers `400` or `415`. (#252)
- A storage failure while a note was read answered "Failed to decrypt note" and left nothing in the log. It is now logged like any server error, and every `500`, a note that does not decrypt included, carries an `errorId` to find it in the log. (#252)
- A large note whose last attachment was an empty file, after one with content, was created but could not be opened: its first read failed with "Decryption failed" and still spent a read. (#250)
- On an instance whose `CHUNK_SIZE` was set below the default, a long text note could fail with "Payload too large": it was uploaded in chunks, all its text in the first one. A note without files is now always uploaded in one piece. (#250)

### Upgrade notes

- Migrations 5 and 6 add columns to `notes`, for format 1 notes. Back up the data volume first: once migrated, the database no longer starts on 1.5.0 or older.
- The web app's links are now format 1. A script that reads them with the SDK needs `@largerio/secret-sdk` 1.3.0 or later: an older SDK is refused them with a `400`, without spending a read. SDK 1.3.0 writes format 1 by default, which instances older than 1.6.0 refuse: update the instance first, or pass `format: 0`. Links already shared, in format 0, keep working.

## [1.5.0] - 2026-10-28

### Security

- The database now syncs every commit to disk. With the previous setting, a
  power cut could undo the last commits, a read that burned a note among them,
  and the note became readable again.
- `/api/health` no longer reports `storage.usedBytes`: it told anyone how much
  the instance stores. The value is in the metrics, as
  `secret_storage_used_bytes`.

### Added

- Liveness and readiness probes. `/api/health/live` answers as long as the API
  process does; `/api/health/ready` checks the database and the storage. See
  [Monitoring](docs/self-hosting.md#monitoring).
- `/api/health` and the API reference report the version, and `/api/health`
  the commit the image was built from. An image of `main` built after a
  release reports that release suffixed `-dev`.
- Prometheus metrics at `/api/metrics`, off unless `METRICS_TOKEN` is set:
  requests by route, notes created, read, burned, deleted and expired, storage
  used, owed and failed deletions, the storage sweep. No metric carries a note
  id, an address or a path.
- `node apps/api/dist/backup.js <destination>` copies the database while the
  app runs: one consistent snapshot, without the notes already deleted,
  checked, written under a temporary name and renamed into place, never over
  an existing file, readable by its owner alone. Ctrl-C or a job's timeout lets
  the copy finish rather than leave a partial one behind. See
  [Backup & restore](docs/self-hosting.md#backup--restore),
  which now covers the files and the key, the order to back them up in, the
  restore, and what a stolen backup reveals.
- Secrets can be read from files, the way Docker and Kubernetes hand them out:
  `SERVER_ENCRYPTION_KEY_FILE`, `API_KEY_FILE` (and `API_KEY_1_FILE`…),
  `S3_ACCESS_KEY_ID_FILE`, `S3_SECRET_ACCESS_KEY_FILE`, `METRICS_TOKEN_FILE`.
  `docker-compose.yml` passes them all on from `.env`, `API_KEY_1_FILE` to
  `API_KEY_3_FILE` included. See [Secrets in files](docs/self-hosting.md#secrets-in-files).
- The self-hosting guide explains why an instance runs alone on its database
  ([One instance per database](docs/self-hosting.md#one-instance-per-database)),
  and how to keep note creation to your own users behind a login
  ([Restricting who can create notes](docs/self-hosting.md#restricting-who-can-create-notes)),
  with examples for Caddy and Traefik.

### Changed

- The cleanup job deletes expired notes and upload sessions 200 at a time, each
  batch in its own transaction, and lets requests through between batches.
  After a long outage it no longer holds up every request until the whole
  backlog is gone.
- The database waits up to 5 seconds for a lock another connection holds (a
  backup, an operator's `sqlite3` shell) instead of failing at once, and
  truncates its WAL back to 64 MiB after a burst.
- The image hands the generated server key to the API by its path rather than
  its value: no process environment holds the key any more. The web server gets
  none of the `_FILE` variables, nor the metrics token.
- Once the container is stopping, it refuses new connections, and a health
  check still arriving on an open one gets `503` with `"status":"draining"`.
- The `linux/arm64` image is scanned and smoke-tested before any tag points at
  it, like the `linux/amd64` one: it was built, but never run nor scanned.

### Fixed

- Behind a proxy that routes by Docker's health status, such as Traefik, a
  slow or unreachable storage took the whole site down, text notes included:
  the image's `HEALTHCHECK` checked the storage, and an unhealthy container
  gets no traffic. It now polls `/api/health/live`, which only checks that the
  app answers. Point an uptime monitor at `/api/health/ready` to hear about the
  storage; the deploy workflow checks it before it accepts a new image.
- An S3 endpoint that stopped answering held requests, the health check,
  deletions and the storage sweep indefinitely; a stuck deletion also stopped
  the cleanup job until a restart. Connections now give up after 5 seconds and
  requests after 30 seconds without a byte, on each of three attempts; reading
  a note gives up on content that stops arriving for 30 seconds; listings and
  deletions give up after 2 minutes in all, even when the answer stalls after
  its first bytes; the health probe gives up after 5 seconds.
- On shutdown, the API cut in-flight requests after 8 seconds whatever the stop
  grace period: raising `stop_grace_period`, as the reference production
  compose file did, gave long uploads no more time. The API now waits
  `SHUTDOWN_TIMEOUT` seconds (8 by default), like the web server.
- The "Encrypt and create the link" button, disabled until there is something
  to encrypt, lit up as the page appeared, then dimmed: its entrance ended at
  full opacity. Entrances now end on each element's own look.
- The arc that turns around the note's card when it appears or takes the
  focus went unseen while the card had the focus, on arrival included, since
  on a computer the text field takes it on load: it was as light as the rest
  of the ring. It is now lighter than the ring on the light theme, darker on
  the dark one.
- A failed request showed the server's message, in English and meant for
  developers ("Too many requests", "Network request failed"), whatever the
  page's language. It now says what happened in the reader's language: no
  connection, too many requests, the anti-spam check refused, a note larger
  than the server accepts, no room left, a server error. A deletion link whose
  token does not match the note says so, and a note gone in the meantime shows
  "Note not found".
- Opening a note while the server was unreachable or failing said "Decryption
  failed". It now says the server is unavailable, or that the note is gone
  when the failed read was its last.
- Back, after leaving for another site, could show a note already read, or a
  new note's link with the only copy of its key: the browser kept the page as
  it stood (the back-forward cache). The link's page now starts over, and a
  note's page reloads to show where the note stands.
- "Readable N times in total" and the message about ignored attachments now
  agree with their number in every language, Russian's three forms included.
- A request sent right after one the server refused before reading its body
  (a chunk or note too large, a write without a valid token) could fail as a
  network error: the API, and the web server in front of it, announced the
  connection as kept alive, then dropped it while discarding the rest of the
  body. Both now close it with the refusal, and the client opens a new one.
- The read that burns a file note waited for its files to be deleted from the
  storage before answering: a slow S3 kept the reader waiting, for minutes at
  worst. The deletion now runs after the answer; one cut short by a restart is
  retried by the cleanup job, as before.
- An upload to S3 that kept trickling never timed out: only silence did. It
  now gives up after 2 minutes plus 1 second per MiB of the payload, retries
  included.
- `secret_notes_expired_total` missed the expired notes a read found and
  deleted before the cleanup job did.

### Removed

- The inline preview of PDF attachments. It never showed in Chrome, which
  draws a PDF with a plugin, and plugins never run in the sandboxed frame an
  attachment gets. A PDF comes with a download button, like other files, and
  the CSP no longer allows `blob:` frames.

### Upgrade notes

- `/api/health` no longer has `storage.usedBytes`. A script or dashboard that
  read it should scrape `secret_storage_used_bytes` from `/api/metrics`, after
  setting `METRICS_TOKEN`. `/api/health` keeps working otherwise; a health
  check of your own can move to `/api/health/ready` once this version runs.
- The image's `HEALTHCHECK` no longer checks the database and the storage. A
  deploy that waits for the container to turn healthy should then check
  `/api/health/ready` too, as the workflow in
  [Deploying from CI](docs/self-hosting.md#deploying-from-ci) does. A compose
  file that overrides the health check, as the reference production one does,
  should poll `/api/health/live`.
- Keep backups no longer than `MAX_EXPIRY`: a restore brings back every note
  burned or deleted since, for anyone who still has its link. Back up the key
  apart from the data. The volume archive this guide used to recommend held
  both.
- Each write now waits for the disk. On slow storage (spinning disks, a network
  volume), creating and reading notes takes a few milliseconds longer.
- A secret set both as a variable and as a `_FILE`, or a `_FILE` naming a
  missing, unreadable or empty file, stops the server at startup. `KEY_FILE`,
  which was undocumented, still moves the generated key, and plays no part once
  `SERVER_ENCRYPTION_KEY` or `SERVER_ENCRYPTION_KEY_FILE` is set.
- To give in-flight requests more than 8 seconds on shutdown, set
  `SHUTDOWN_TIMEOUT` and raise `stop_grace_period` a couple of seconds above
  it.
- No database migration.

## [1.4.1] - 2026-10-26

### Security

- Note IDs no longer appear in the logs. The cleanup job and the deletion
  retries logged them.

### Added

- A daily storage sweep removes stored files that no note points to any more,
  such as those a failed deletion left behind. Every instance claims its
  storage at startup, and the sweep deletes nothing on storage another database
  has claimed, nor during its first day on a storage, when it only logs what it
  would delete. `STORAGE_RECONCILE=false` turns it off. See
  [Storage cleanup](docs/self-hosting.md#storage-cleanup).

### Fixed

- Deleting a note's files from storage could fail silently: both backends
  swallowed every error, and S3 ignored the keys it refused to delete. Nothing
  was retried, so the ciphertext of burned or expired notes could stay on disk
  or in the bucket. Failures are now logged and retried.
- A deletion is recorded in the same transaction that removes the note, so a
  restart in between no longer strands its files. The same holds while a file
  is being saved, and for a chunk that lands after its upload session expired.
  Such a chunk used to get a `500` and stay; it is now removed and gets a
  `404`, or a `409` if the upload was completed in the meantime.
- The Content Security Policy blocked one of the interface's fonts (the
  monospace font's Cyrillic Extended range), which the build had inlined as a
  `data:` URI: browsers logged a violation and fell back to another font for
  those characters. Every font now ships as a file.

### Upgrade notes

- Migration 4 drops two columns from `pending_deletions`. Back up the data
  volume first: once migrated, the database no longer starts on 1.4.0 or older.
- A day after the upgrade, the storage sweep starts removing files older than a
  day that the database does not know; until then it only logs their count. If
  several instances share a bucket or `FILES_PATH`, give each its own, or set
  `STORAGE_RECONCILE=false` on all of them, before upgrading any: an instance
  that has not upgraded yet leaves no claim, so the others cannot tell its
  files from orphans.
- On S3, the key now also needs to list the bucket and to write under
  `instances/`. Turn versioning off and add the lifecycle rule in
  [S3 bucket settings](docs/self-hosting.md#s3-bucket-settings).

## [1.4.0] - 2026-10-26

### Security

- Images are signed (cosign, keyless) and carry a SLSA build provenance
  attestation, from the workflow that built them and only once they have
  passed the scan and the smoke test. A release only tags a signed image. See
  [Verifying the image](docs/self-hosting.md#verifying-the-image).

### Changed

- A redesigned interface. Every page now has a single card in the middle,
  in a light and a dark theme that follows the system until a visitor picks one.
  Instance controls (language, theme) move to the footer.
- Creating a note:
  - The password generator is a tool of the note, not a separate tab: it puts
    the password where the caret is. A note made of the generated password
    alone still reaches the reader as a password to copy.
  - Markdown is a "Formatting" toggle.
  - Files are attached from the toolbar or dropped onto the note.
  - Expiry, reads and the opening password read as one sentence under the
    note, and the note field has focus on arrival (keyboard and mouse only).
- Password managers no longer fill the opening password with saved
  credentials.
- Opening a note shows the reads it has left, and says so before the last one
  is spent. A single-read note opens in one click, its warning next to the
  button. After a wrong password the page says that the attempt spent a read,
  and how many are left.
- A link whose key is missing or cut short is caught before it spends a read.
  Pasting the full link into the same tab opens the note.
- Deleting a note asks for confirmation in the page, not in a browser dialog.
- Motion stops within five seconds (WCAG 2.2.2): the accent arc turns once when
  a card appears and once each time it takes the focus, and the glow settles
  once. Only the progress card keeps turning while it works. Nothing moves
  under `prefers-reduced-motion`.
- Accessibility:
  - Text and controls reach WCAG AA contrast in both themes, whatever
    `APP_PRIMARY_COLOR` is.
  - The focus is visible everywhere, and forced-colors mode keeps controls
    outlined.
  - Screen readers hear the progress steps, the copy confirmations, and a
    generated password as one string.
- Sizes follow the reader's language ("2,5 Mo", "2.5 MB"), counts use each
  language's plural forms, and the translations were reviewed in all ten
  languages.
- The QR code carries the instance's accent and a logo, drawn at error
  correction level H so the logo costs nothing to readers.
- The default accent is indigo instead of red. `APP_PRIMARY_COLOR` still sets
  it, and also derives the gradients and the dark-theme shade.

### Added

- `GET /api/v1/notes/:id/exists` also returns `readCount`, the reads a note has
  already spent, so a client can say how many are left before opening it. It
  stays at 0 on a note without a read limit. The SDK reads it from 1.2.0.

### Fixed

- A generated password always holds a character of every class it draws from.
  About one in five had no symbol.

### Removed

- `POST /api/cap/verify`, which nothing called.

### Upgrade notes

- Migration 3 drops two unused columns and an index. Back up the data volume
  first: once migrated, the database no longer starts on 1.3.2 or older.

## [1.3.2] - 2026-10-24

### Security

- The web server no longer receives the API's secrets: the entrypoint removes
  `SERVER_ENCRYPTION_KEY`, `API_KEY`/`API_KEY_<n>` and the S3 and AWS
  credentials from its environment. A compromised web dependency that dumps
  its environment finds none of them. This is not isolation: both processes
  still run as the same user.

### Changed

- Built on `node:26-alpine` with Node 26.12.1, Alpine 3.24.3 and OpenSSL 3.5.9.
  (#218)
- A release ships the exact image CI tested on `main`, and `:1` picks up
  base-image fixes between releases. (#215)

### Fixed

- A trailing slash in `APP_URL` no longer breaks the sitemap, the Open Graph
  image URL or CORS.
- `S3_FORCE_PATH_STYLE=1` now turns path-style on, like `true`.
- A `CLEANUP_INTERVAL_MS` above 2147483647 (about 24 days) is refused instead
  of running the cleanup every millisecond.
- On `docker stop`, the web server now exits within Docker's 10-second limit.
- Building the image from a working copy no longer copies its `node_modules`
  and build output.

### Upgrade notes

- The server stops at startup with an error on a value it cannot read: an
  on/off setting other than `true`, `false`, `1`, `0` or blank (such as `yes`),
  an `APP_URL` that is not an `http://` or `https://` URL, or an out-of-range
  `API_PORT` or `CLEANUP_INTERVAL_MS`.
- No database migration.

## [1.3.1] - 2026-10-23

### Fixed

- A failed read no longer burns a note: the read is counted, and a
  burn-after-read note deleted, only once its payload has been read and
  decrypted. (#214)
- For notes with files, a corrupt first chunk returns an error before the
  download starts. (#214)

## [1.3.0] - 2026-10-22

### Security

- Per-IP rate limits tell clients apart out of the box: the image trusts its
  own web app (`TRUSTED_PROXIES=127.0.0.1/32`), and IPv6 clients are grouped
  by /64. (#201)
- Every request body is bounded, including on unauthenticated routes. (#202)
- No third-party requests: fonts are served by the instance. (#199)

### Changed

- Instances no longer carry the public instance's branding, and
  `APP_PRIMARY_COLOR` takes effect. (#206)
- `:latest` follows releases; the tip of `main` is `:main`. (#197)

### Fixed

- Plain HTTP on a local network (`http://<ip>:3000`) works again. (#203)
- A wrong password on a read-once note says the note is gone. (#200)
- The web app uploads in the instance's `CHUNK_SIZE`. (#204)
- The Password tab no longer erases the note typed on another tab. (#207)
- Accessible names for the language switch, copy buttons and drop zone. (#210)

### Upgrade notes

- If your `.env` came from an older `.env.example`, `APP_PRIMARY_COLOR` turns
  the interface indigo: remove the line to keep the default.
- A note without files above 512 KiB of ciphertext gets a `413`.

## [1.2.1] - 2026-10-22

### Fixed

- The web server no longer crashes when a client leaves before the API
  answers. (#196)

## [1.2.0] - 2026-10-07

### Changed

- The web server streams request bodies to the API instead of holding them in
  memory.

### Removed

- `BODY_SIZE_LIMIT`: the API enforces the upload cap itself.

### Fixed

- File uploads from the SDK in Node are no longer rejected.

## [1.1.0] - 2026-10-07

### Added

- Storage quotas (`STORAGE_QUOTA_BYTES`), versioned database migrations,
  structured JSON logs, and health checks that probe the database and storage.

### Fixed

- A changed `SERVER_ENCRYPTION_KEY` is refused at startup instead of making
  every note unreadable (`ALLOW_SERVER_KEY_CHANGE` overrides).
- The site is served on `PORT`, as every PaaS expects.

### Upgrade notes

- Back up the data volume first: migrations run on first boot, and a migrated
  database cannot go back to 1.0.0.
- Logs are JSON lines, and the API binds `127.0.0.1` inside the container.

## [1.0.0] - 2026-06-18

First public release.

[Unreleased]: https://github.com/largerio/secret/compare/v1.8.0...HEAD
[1.8.0]: https://github.com/largerio/secret/releases/tag/v1.8.0
[1.7.0]: https://github.com/largerio/secret/releases/tag/v1.7.0
[1.6.0]: https://github.com/largerio/secret/releases/tag/v1.6.0
[1.5.0]: https://github.com/largerio/secret/releases/tag/v1.5.0
[1.4.1]: https://github.com/largerio/secret/releases/tag/v1.4.1
[1.4.0]: https://github.com/largerio/secret/releases/tag/v1.4.0
[1.3.2]: https://github.com/largerio/secret/releases/tag/v1.3.2
[1.3.1]: https://github.com/largerio/secret/releases/tag/v1.3.1
[1.3.0]: https://github.com/largerio/secret/releases/tag/v1.3.0
[1.2.1]: https://github.com/largerio/secret/releases/tag/v1.2.1
[1.2.0]: https://github.com/largerio/secret/releases/tag/v1.2.0
[1.1.0]: https://github.com/largerio/secret/releases/tag/v1.1.0
[1.0.0]: https://github.com/largerio/secret/releases/tag/v1.0.0