Security
- SSRF: async DNS resolver; allow_redirects=False on all outbound clients;
matrix homeserver_url validated on create/update/test; update_provider
and email_bot merge incoming config and reject ***-masked secrets.
- Auth: bcrypt offloaded to asyncio.to_thread; JWT now carries iss/aud +
leeway and rejects missing claims; setup TOCTOU closed inside a
transaction; rate limits extended (default 600/min, 10/min on password
change, 30/min on needs-setup); constant-time login to prevent username
enumeration.
- Config: rejects known dev secret keys; validates CORS origin schemes,
port range, token lifetimes.
- Webhook handlers stream-read body with a 1 MiB cap; Discord 429 retries
bounded (3 attempts, Retry-After capped at 60 s).
- CSP + HSTS added to SecurityHeadersMiddleware.
Async / runtime
- SQLite engine: WAL, synchronous=NORMAL, foreign_keys=ON, busy_timeout,
pool_pre_ping, dispose on shutdown.
- Lifespan shutdown now stops scheduler before closing HTTP session and
disposing the engine.
- Shared aiohttp session locked against concurrent first-caller races;
core NotificationDispatcher accepts and reuses it.
- Storage and scheduled backup writes wrapped in asyncio.to_thread.
- NUT client writes bounded by asyncio.wait_for.
- Telegram poller switched from 3 s short-poll to 30 s interval + 25 s
long-poll (~10x fewer API calls).
Database
- New performance-indexes migration covers every FK/owner column and
hot-path composite (notification_tracker(provider_id, enabled);
event_log(user_id, created_at DESC); webhook_payload_log(provider_id,
created_at DESC); action_execution(action_id, started_at DESC)).
- New schema_version table for future upgrade gating.
- __system__ placeholder user (id=0) seeded so user_id=0 system defaults
satisfy the newly enforced FK; filtered out of /auth/needs-setup,
/api/users, and setup.
- list_notification_trackers rewritten to batched loads (was 1+N+N*M).
- Retention job extended to event_log, webhook_payload_log, and
action_execution; retention days exposed as a setting.
Scheduler
- AsyncIOScheduler job_defaults: coalesce, misfire_grace_time=300,
max_instances=1.
Ops
- uvicorn runs with proxy_headers, forwarded_allow_ips,
timeout_graceful_shutdown; access log suppressed in non-debug.
- FastAPI version string now reads from importlib.metadata.
- New /api/ready endpoint separate from /api/health.
- docker-compose drops the ALLOW_PRIVATE_URLS=1 default, adds mem/cpu/pid
limits, read_only + tmpfs, cap_drop:ALL, no-new-privileges; healthcheck
targets /api/ready.
- CI now runs on push/PR with backend pytest, frontend svelte-check +
build, and a non-push image build; release workflow gated on tests,
publishes immutable sha-<commit> image tag, adds Trivy scan.
Tests
- New packages/server/tests/ with 29 passing tests: config validation,
JWT round-trip + aud/alg=none rejection, SSRF scheme and private-range
enforcement (sync + async), Discord bounded retry, and a lifespan-level
/api/health + /api/ready smoke check.
- Renamed the misnamed services/test_dispatch.py to manual_dispatch.py so
pytest never auto-collects production code.
Frontend
- /login now redirects already-authenticated users to /, shows a distinct
'backend unreachable' banner (en/ru) when /auth/needs-setup fails.
Previous attempt used `python3 -c "..." KEY=VALUE` which passes
KEY=VALUE as positional args, not environment variables — the python
block then crashed with KeyError: 'BODY' because nothing actually set
it in the environment.
Consolidate into a single heredoc-fed python3 block that reads
RELEASE_NOTES from the already-exported env var and reads TAG/VERSION/
IS_PRE after an explicit `export`. Uses <<'PY' so shell metachars in
the Python source (backticks, $, quotes) are not interpreted.
Also drops the redundant intermediate BODY variable — body is built
directly inside the single python invocation.
Previous implementation silently assumed any missing 'id' in POST
response meant "release already exists", then called an unguarded
python3 on the fallback response — which crashes (exit 1) if the
fallback also fails (e.g. release really doesn't exist).
New logic:
- Build JSON payload in Python (avoids shell escaping + CLI length limits)
- Capture HTTP status explicitly
- 201 → success
- 409 or "already exists" message → reuse existing (with HTTP check on fetch)
- Anything else → fail loudly with the response body printed
This also unblocks diagnosis of the current v0.1.0 failure by surfacing
the actual error the Gitea API is returning.
- Set fetch-depth: 0 so previous tag lookups work across full history.
- Use `-n 20` instead of HEAD~20..HEAD, which fails when the repo has
fewer than 20 commits (e.g. on the first release).
The two-step pattern (sparse-checkout RELEASE_NOTES.md, then full
checkout) left sparse-checkout config active on the workspace, so the
second checkout still only restored RELEASE_NOTES.md. Docker build
then failed with "open Dockerfile: no such file or directory".
Since both RELEASE_NOTES.md and the full source are needed in the same
job, one full checkout is simpler and correct.
- Use one DEPLOY_TOKEN for both registry login and Gitea release API,
matching the claude-code-facts convention.
- Rename "Trigger Portainer redeploy" to "Trigger redeploy webhook" —
the step calls a generic DOCKER_REDEPLOY_WEBHOOK_URL, not a
Portainer-specific endpoint.
- Add .facts-sync.json to pin this project to the facts repo commit.
- Fix github.* → gitea.* context consistency
- Add pre-release detection (skip :latest for alpha/beta/rc)
- Add release fallback (reuse existing if creation fails)
- Add prerelease field to release API call
- Use sparse-checkout for RELEASE_NOTES.md
- Skip Portainer redeploy for pre-releases
- Add version tag without v prefix
- Add manual build.yml for Docker image verification