reze: Fix unicode splitting, strict booleans, symbol validation and entropy

Six fixes, each with a test:

1. Character pools are built as arrays of code points, so astral characters
   (emoji) are never split into lone surrogates. Entropy counts code points.
2. Boolean options are parsed strictly: true/false, 1/0, "true"/"false",
   "1"/"0", "yes"/"no", "on"/"off" (case-insensitive). Anything else
   is a 400 naming the option instead of a silent truthy fallback.
3. customSymbols is validated: at most 64 code points, duplicates dropped,
   control characters and whitespace rejected with a 400.
4. Random mode entropy with requireEach now counts only the passwords that
   contain a character of every enabled class (inclusion-exclusion). The
   value is an upper bound and is labelled as such in the API
   (entropyApproximate/entropyNote) and in the UI.
5. docker-compose.yml uses expose instead of ports, so it no longer
   collides with Dokploy's own port 3000.
6. SIGTERM/SIGINT shut the server down gracefully: close the listener, let
   in-flight requests finish, force-exit after 5s. Measured at ~60ms.

The server now exports createServer() so the API can be tested over HTTP
without binding a fixed port.
This commit is contained in:
2026-10-21 09:26:32 +00:00
parent dfe3e94aa4
commit 1965c6efaa
9 changed files with 793 additions and 74 deletions
+35 -8
View File
@@ -19,6 +19,8 @@ account, no login, no database, and nothing you generate is stored or logged.
- One-click copy with a clipboard fallback.
- Light/dark theme with a system-theme fallback, persisted in `localStorage`.
- Options persisted in `localStorage`; generated passwords are never persisted.
- Unicode-safe: character pools are code points, so emoji are never split.
- Strict option validation — bad booleans and symbol sets are rejected, not guessed.
- No npm dependencies at all — Node.js built-ins only.
## Requirements
@@ -100,12 +102,21 @@ curl -s -X POST localhost:3000/api/generate \
```
```json
{"mode":"memorable","count":2,"passwords":[{"value":"07Sample-29Breach","mode":"memorable","entropy":39.1},{"value":"69Reassign-85Probation","mode":"memorable","entropy":39.1}],"entropy":39.1}
{"mode":"memorable","count":2,"passwords":[{"value":"07sample-29breach","mode":"memorable","entropy":39.1,"entropyApproximate":false},{"value":"69reassign-85probation","mode":"memorable","entropy":39.1,"entropyApproximate":false}],"entropy":39.1,"entropyApproximate":false,"entropyNote":"exact: every character is drawn uniformly from the enabled sets"}
```
Random options: `length` (8–64), `useLower`, `useUpper`, `useDigits`, `useSymbols`,
`customSymbols` (replaces the default symbol set when non-empty), `excludeAmbiguous`
(removes `0 O 1 l I |`) and `requireEach`.
Random options: `length` (8–64, in code points), `useLower`, `useUpper`, `useDigits`,
`useSymbols`, `customSymbols` (replaces the default symbol set when non-empty),
`excludeAmbiguous` (removes `0 O 1 l I |`) and `requireEach`.
The boolean options are parsed strictly: `true`/`false`, `1`/`0`, `"true"`/`"false"`,
`"1"`/`"0"`, `"yes"`/`"no"` and `"on"`/`"off"` (case-insensitive) are accepted, anything
else is rejected with a `400` that names the option. `"no"` really means *off*, it is not
treated as a truthy string.
`customSymbols` is validated: at most 64 code points, duplicates are dropped, and
control characters or whitespace are rejected with a `400`. Characters are handled as
code points, so emoji and other astral characters are never split in half.
```sh
curl -s -X POST localhost:3000/api/generate \
@@ -114,15 +125,23 @@ curl -s -X POST localhost:3000/api/generate \
```
```json
{"mode":"random","count":1,"passwords":[{"value":"3V#P@bQ2C4c3;=igw}E(EpnG","mode":"random","entropy":154.6}],"entropy":154.6}
{"mode":"random","count":1,"passwords":[{"value":"3V#P@bQ2C4c3;=igw}E(EpnG","mode":"random","entropy":154.6,"entropyApproximate":true}],"entropy":154.6,"entropyApproximate":true,"entropyNote":"upper bound: the generator samples the valid passwords non-uniformly"}
```
### Entropy
- Random mode: `length * log2(poolSize)`, where `poolSize` is the size of the union of all
enabled character classes.
- Random mode without `requireEach`: `length * log2(poolSize)`, where `poolSize` is the
number of code points in the union of all enabled character classes.
- Random mode with `requireEach`: the number of passwords that contain at least one
character of every enabled class (inclusion-exclusion over the classes), converted to
bits. Because the generator samples those passwords non-uniformly, the value is an
**upper bound**; the response says so via `entropyApproximate` and `entropyNote`, and
the UI marks it with a `~` and "approx.".
- Memorable mode: `words * log2(listSize) + words * digits * log2(10)`. Separators and the
capitalization scheme add no entropy.
capitalization scheme add no entropy. This value is exact.
Every response carries `entropy`, `entropyApproximate` and `entropyNote`, and each
password carries its own `entropy` and `entropyApproximate`.
### Other paths
@@ -169,6 +188,14 @@ docker compose up --build -d
The image is a single stage based on `node:22-alpine`, runs as the non-root `node` user
and ships a `HEALTHCHECK` that polls `http://127.0.0.1:3000/healthz` with `wget`.
`docker-compose.yml` uses `expose` instead of `ports`, so the service is only reachable
on the container network — publishing it on the host would collide with Dokploy's own
port 3000.
`SIGTERM` and `SIGINT` shut the server down gracefully: it stops accepting connections,
lets the in-flight requests finish and exits, with a 5 second forced-exit fallback. That
keeps `docker stop` fast instead of waiting for the full 10 second timeout.
## Deployment (Dokploy)
- Repository: `druid520/reze-test` on Gitea (git.kg.boson.party).