diff --git a/.env.production.example b/.env.production.example index d56fe8d..644d4f5 100644 --- a/.env.production.example +++ b/.env.production.example @@ -3,16 +3,20 @@ # # Usage: # cp .env.production.example .env -# # replace every "change-me-..." below +# # generate the two required values below # docker compose -f docker-compose.prod.yml up -d --build +# +# The two required values ship empty on purpose and the compose refuses to start while +# either is missing, so a skipped step is a clear error rather than a stack running on a +# password published in this repository. # === REQUIRED === # JWT signing secret (min 32 chars) — generate with: openssl rand -hex 32 -JWT_SECRET=change-me-openssl-rand-hex-32 +JWT_SECRET= # Postgres password — generate with: openssl rand -base64 32 -POSTGRES_PASSWORD=change-me-openssl-rand-base64-32 +POSTGRES_PASSWORD= # === OPTIONAL === diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b2fbe54 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,112 @@ +name: CI + +# Nothing ran the test suite before this workflow existed, so a regression could +# reach main with every check green simply because there were no checks. This runs +# the backend build, vet and tests, the frontend type-check, build and tests, the compose +# guards, and the language check. +# +# CGO is disabled to match the released image: the Dockerfile builds with +# CGO_ENABLED=0. The suite needs no database — the integration tests that use one skip +# themselves unless DATABASE_URL is set. +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + backend: + runs-on: ubuntu-latest + env: + CGO_ENABLED: "0" + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: backend/go.mod + cache-dependency-path: backend/go.sum + + - name: Build + working-directory: backend + run: go build ./... + + - name: Vet + working-directory: backend + run: go vet ./... + + - name: Test + working-directory: backend + run: go test ./... + + frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install + working-directory: frontend + run: npm ci + + # `npm run build` runs the type-check and the production build together. + - name: Type-check and build + working-directory: frontend + run: npm run build + + - name: Test + working-directory: frontend + run: npm test + + compose: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # A compose file that fills in a default for POSTGRES_PASSWORD starts a database + # with a password anyone can read, which is how docker-compose.prod.yml used to + # resolve its DSN to senddock:senddock. Each production compose has to refuse to + # interpolate without it and to interoperate once it is provided. + - name: The production composes require POSTGRES_PASSWORD + run: | + set -euo pipefail + export JWT_SECRET="$(head -c 48 /dev/urandom | base64)" + unset POSTGRES_PASSWORD + + for file in docker-compose.prod.yml docker-compose.image.yml; do + if docker compose -f "$file" config >/dev/null 2>&1; then + echo "::error file=$file::$file resolved without POSTGRES_PASSWORD, so it carries a default someone can guess" + exit 1 + fi + POSTGRES_PASSWORD=probe docker compose -f "$file" config >/dev/null + done + + # The same trap one step earlier: an example env whose required values are + # placeholders boots a stack on a password published in the repository. + - name: The example env ships no secret + run: | + set -euo pipefail + for key in JWT_SECRET POSTGRES_PASSWORD; do + line="$(grep -E "^${key}=" .env.production.example || true)" + if [ "$line" != "${key}=" ]; then + echo "::error file=.env.production.example::$key must ship empty, found: ${line:-nothing}" + exit 1 + fi + done + + hygiene: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # The repository ships in English. A file mixing two languages is unreadable in + # review and cannot be translated later without guessing which half is current, so + # the rule is checked rather than trusted. It also runs locally: + # scripts/check-language.sh + - name: Everything tracked is in English + run: scripts/check-language.sh diff --git a/.gitignore b/.gitignore index 9d8851a..bd50b86 100644 --- a/.gitignore +++ b/.gitignore @@ -35,7 +35,6 @@ Thumbs.db # Logs *.log -.claude/ # Internal / sensitive — never commit or publish .internal/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 0679d60..0ce93db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Releases are also published on [GitHub](https://github.com/arkhe-systems/senddock/releases) and the `ghcr.io/arkhe-systems/senddock` image carries the matching tag. +## [0.8.3] — 2026-09-27 + +### Removed + +- **The `member` workspace role.** The API accepted it and this changelog advertised it, but the database constraint never allowed it, so assigning it failed with a 500 after leaving an account behind. Its capability set was also identical to `admin`. The roles are `owner`, `admin`, `developer` and `viewer`, which is what the database has permitted all along. + +### Fixed + +- **A failed registration no longer leaves a half-created account behind.** Registering wrote the account first and its workspace afterwards, as separate statements, so a failure on the second write left an account that nobody could see or sign into — while holding an email that could never be registered again. The same shape sat in the paths that create a workspace (a workspace with no owner is invisible to everyone and cannot be deleted, and it blocks deleting its creator) and in the flow that adds a member to a workspace. Each of them now writes its rows in one transaction: either the account and its workspace and its membership all land, or none of them do. + +- **A log row no longer claims the send succeeded before it did.** The row is written before the SMTP call so the tracking pixel and the click links have an id to point at, and it was recorded as `sent` at that moment. A crash or a hung attempt therefore left a row that claimed a delivery which never happened, and the stats endpoint counted it. Rows now start as `pending` and settle to `sent` once the relay accepts the message. + +- **Date rules no longer break a segment.** `greater than` and `less than` compiled a custom field to a numeric cast, but date fields are stored as `YYYY-MM-DD`, so a rule that saved without complaint failed when the segment actually ran: the preview errored and any broadcast targeting it never sent. The comparison now casts to match the value — dates compare as dates, numbers as numbers — and a value that cannot be ordered is refused while the segment is being saved rather than when it is used. + +- **Demoting the last workspace owner no longer leaves the workspace ownerless.** The guard that refuses to remove the last owner ran only when the target role was `member` — a value the database rejects — so demoting an owner to `admin`, `developer` or `viewer` went through and left the workspace with no owner, unrecoverable from the UI. The guard now runs for every target role except `owner`. + +- **A restart no longer re-sends a broadcast another instance is delivering.** Broadcast jobs left in `sending` were reset to `retry` on every startup regardless of who owned them, so starting or rolling one instance re-queued jobs another instance was still sending, and those recipients received the same email twice. A claimed job now carries the worker's id and a lease, and startup recovery only reclaims jobs whose lease has expired — work in flight is left alone. + +- **The documentation was corrected against the product.** The API reference and the guides described fields, status codes, response shapes, defaults and permissions that the code does not have: an invented `403` body, a refresh token lifetime of seven days, a retry on every `5xx`, a ULID where the id is a UUID, `deliverability_pct` where the field is `acceptance_pct`, an `ip_address` in the click detail that is never returned, a `status` claimed absent from a webhook payload that carries it, and an import that offers to map CSV columns which are matched automatically. Each correction was checked against the handler, the query or the schema it describes. + +- **Two editor fixes that missed the 0.8.2 image.** The visual canvas mirroring the template's `