All notable changes to zapiska are documented here. The format follows Keep a Changelog; versions follow SemVer.
Public read API gains a field and a query parameter, webmention receipt gains a
discovery document, and the worker gains retry plus a shutdown drain. No schema
migration: schema_version stays 9 and export_version stays 1.
- A rejected webmention no longer charges the sender's domain quota. The
per-domain hourly cap ran before the target was parsed and validated, so an
unauthenticated caller could send
source=<victim-host>/xwith an invalid target, receive400each time, and still exhaust that host's hourly budget, after which the victim's legitimate webmentions met429. The job is now enqueued before the cap is charged, so every refusal path — bad scheme, wrong target origin, source equal to target, a malformed form, and a full worker backlog — costs the sender nothing. The cap still applies to accepted requests, so a noisy sender still spends its own budget.
GET /.well-known/webmention: the W3C webmention discovery document, served only with thewebmentionsfeature. The body is the relative receipt path/api/webmention(text/plain), so the consuming site's static file carries the absolute URL and no new configuration is needed.GET /api/commentsnow returnssource_url(null for a native comment, the full source page URL for a webmention) and accepts atypefilter (native,webmention, orall, defaultall).totalcounts the filtered set, so pagination stays consistent for each filter value.GET /api/admin/status: versions, database health, and non-secret configuration (honeypot field, webhook/notify/proxy/reactions/limits) in one body for the dashboard OPS tab. Secret values never leave the server.CF-Connecting-IPis preferred first underTRUST_PROXY=true: the Cloudflare edge sets it to the single visitor IP, unlike the append-onlyX-Forwarded-Forwhose leftmost entry a client can spoof. Only safe when every byte arrives via the trusted edge (direct origin access must be impossible).- Admin dashboard rebuild: comments (exact path totals, per-item batch results, true undo, ancestor chains, extracted URLs, duplicate lookup), reactions moderation with reviewed-emoji pinning, author/URL lookup, export download and import restore with force, and an OPS tab.
- Verified owner identity: owner profile (
GET/PUT /api/admin/profile),POST /api/admin/commentsauthoring approved+verified comments with a public ✓ checkmark, migration v9 (verifiedcolumn,admin_profiletable), and dashboard PROFILE tab with impostor flagging. - Versioned SQLite migrations via
PRAGMA user_version(LATEST_SCHEMA_VERSION = 9insrc/db/pool.rs). Fresh databases get the canonical snapshot and a stamp. Legacyuser_version = 0databases run an idempotent existence-checked catch-up and are stamped. A database newer than the binary refuses startup instead of running against unknown schema. GET /api/versionreturning{ version, schema_version, export_version }, all from their single source of truth. Listed in the OpenAPI document.zapiska --version/-Vflag. Prints the crate version without needing any configuration (checked beforeADMIN_TOKENload).- Admin export records
ip_hash_salted(whetherIP_HASH_SECRETwas set). The secret itself is never exported. - Admin import re-derives each comment IP hash from its raw IP with the
importing server's secret (
ip_hashes_recomputedin the response) and returns awarningwhen the export salt flag mismatches this server while salted identities are present. - Startup warning when the database holds IP hashes but
IP_HASH_SECRETis unset (lost or rotated secret splits IP-hash continuity). DB_QUICK_CHECK(defaulttrue): startup runsPRAGMA quick_checkand refuses to start when the database reports corruption, with a message pointing at backup restore. Set tofalseonly to bypass the gate for recovery.- This
CHANGELOG.md. AppState::start(config)/start_with_github(config, github)(T22): one assembly path formainand every test (pool, migrations with newer-than-binary refusal,PRAGMA quick_checkgate, repo, notifier, language gate, GitHub adapter, webmention worker). Covered by a temp-DB →healthzsmoke test, a newer-schema refusal test, andIP_HASH_SECRET-warning tests.- The webmention worker spawn path now carries the moderation webhook sink
(T20-F1: URL + signing secret from config via
Config::worker_moderation_sink, shared with the comment/reaction paths): production gone-deletes POST a signedcomment.status_changedevent, pinned by a prod-wiring test (wiremock on the webhook URL asserts exactly one signed emission from a processor carrying the shared builder's sink — the spawned worker itself stays idle, so the spawn call's argument plumbing is review-only). - The webmention worker retries transient failures with bounded exponential
backoff (three attempts, 250 ms base, a capped delay, and a total time
budget). A retryable failure is a fetch timeout, a connect/transport
error, a 5xx response, or transient storage contention. A terminal
failure (missing backlink, any 4xx including 410, bad or blocked URL,
redirect/body cap, other storage or moderation error) is dropped on the
first try. A retry re-enters the bounded queue with
try_sendonly, so a full backlog drops the job with a warning instead of blocking the only consumer. - The OpenAPI document now covers every public and admin API route
(previously a fraction of the surface). Protected admin operations
declare a shared
bearerAuthHTTP security scheme, the JSON endpoints carry request-body schemas, andreactions::add_reaction— which had a#[utoipa::path]macro but was registered in neitherpaths(...)list — is now documented. UI-only paths stay out of the JSON API document. - OpenAPI drift tests:
openapi_documents_every_routeasserts the document contains exactly the listed routes (newPUBLIC_ROUTE_PATHSplus the pinnedADMIN_ROUTE_PATHSplus the feature-gatedWEBMENTION_ROUTE_PATHS), andopenapi_admin_routes_use_bearer_schemeasserts every admin operation requiresbearerAuth. A route added without documenting it, or documented without a route, now fails loudly. - Pentests for
POST /api/webmentionpin the handler/worker split against its actual behaviour: scheme, target-origin, and source/target inequality reject with400; missing fields answer422; a non-form content type answers415; an oversized body answers413; an SSRF-shaped source (loopback, link-local, private) and a credential-bearing source are accepted (202) and refused later by the worker's guarded fetcher; and an injected-fetcher idempotency test proves a repeated identical ping updates one row instead of inserting a second.
-
CI gains a compose smoke job (build, up, poll
/healthztook, check/api/version, tear down) and per-target release smokes: natively runnable Linux binaries boot against a scratch database and must serve/healthzand/api/version, while theaarch64binary runs its--versionunderqemu-aarch64instead of a boot.docs/development.mddescribes the new jobs and no longer hardcodes test counts (it gives the commands that print them). -
SPEC.md and every guide reconciled with shipped behavior in one sweep: the 13-step ingress ordering matches the code (governors and body limits live in the route layers, not in
submit);TRUST_PROXYandDB_QUICK_CHECKjoin the SPEC config table; shutdown and notification sections record the drain instead of loss; the security-rules list covers login/batch/export throttles; the API reference records the configured honeypot name, 32-character delete tokens, sanitized-content URL extraction, the503health case, and the full per-section import report with overlap refusal; the moderation guide records worker webhook emission, JSON 429s, and the real 30-request admin budget; deployment and getting-started fill the empty command blocks, repair the GHCRdocker runsnippet, qualify the peer-IP statement withTRUST_PROXY, and document the single-instance lock. -
GET /healthzis now a readiness probe: it issuesSELECT 1through the connection pool (bounded to two seconds) and answers200 okwhen healthy,503 unavailablewhen the database does not answer. Docker and compose health checks flip unhealthy on database failure instead of staying green. -
The admin session cookie is now
__Host-admin_tokenwithSecure(keepingPath=/,HttpOnly,SameSite=Lax). Existing sessions are invalidated: log in again after upgrading. Serve the admin dashboard over HTTPS so theSecurecookie is accepted. -
POST /api/admin/login,POST /api/admin/moderate/batch,POST /api/admin/reactions/moderate,POST /api/admin/reactions/moderate/batch, andGET /api/admin/exportare now throttled per IP on the admin moderation budget (same burst and window values, separate per-route buckets). Rapid login guessing and bulk dump/modify abuse trip429. -
A legacy database holding duplicate
(source_url, target_path)rows no longer aborts boot with a raw SQLite unique-index error: startup fails loud with the offending pairs and the dedup remedy (keep the newest row per pair, see the deployment docs). -
Rate-limit default bursts raised (windows stay 60 s): native
50to100, webmention30to60, read60to300, admin moderate10to30. Operators with an existing.envcopied from the old.env.examplekeep their pinned values: update or remove theRATE_LIMIT_*lines to pick up the new defaults. -
OpenAPI
info.versionnow readsenv!("CARGO_PKG_VERSION")instead of a hardcoded duplicate of the crate version. -
The v7 IP-hash backfill now calls the single
hash_ipimplementation insrc/ip_hash.rsinstead of a duplicated inline hash.run_migrationstakes the secret as a parameter (fromConfig) instead of readingIP_HASH_SECRETfrom the environment. Stored hashes are unchanged for the same input and secret. -
Configis typed at the boundary (T21):PUBLIC_TARGET_ORIGINis aurl::Url(the two stale "validated at config load" panics in the webmention handler and worker are gone), andNOTIFY_BATCH_GRANULARITY,MODERATION_WEBHOOK_MODE,DEFAULT_COMMENT_STATUS(reusingmoderation::Status),REACTIONS_ALLOWED, andCOMMENT_LANG_ALLOW_EMOJIare enums parsed once at load — consumers (NotificationBatcher,Ingress, comment/reaction handlers,LanguageGate) read the typed values instead of re-deriving strings. Every env parse now fails loud:MAX_COMMENTS_PER_IP_PER_DAY,MAX_WEBMENTIONS_PER_DOMAIN_PER_HOUR, andMAX_THREAD_DEPTHrefuse boot on garbage instead of silently keeping defaults, and unvalidatedMODERATION_WEBHOOK_MODE/DEFAULT_COMMENT_STATUSvalues are rejected (matching is case-insensitive like the other enums:SYNC/APPROVEDnow parse as such — previously they fell silently into the defaults — while garbage still fails loud). The deadrust_logfield is gone (tracingalways readRUST_LOGdirectly viaEnvFilter::from_default_env(); runtime behavior unchanged —RUST_LOGin.env/compose still works). No renames, no default-value changes.
- Graceful shutdown now drains the webmention queue instead of losing it.
The worker watches a shutdown signal, processes the jobs that are already
buffered until the queue is empty or a bounded deadline expires, and
mainwaits for that drain before it releases the database lock. A restart drains most queued webmentions: the wait is bounded, so a job still buffered at the deadline, or one parked in a retry backoff, is still lost.SPEC.mdanddocs/architecture.mdlost the old "does not drain" statement in the same change. - Webmention gone/resurrection lifecycle: a re-ping after
gonere-fetches and re-verifies instead of deleting blindly — gone→alive is representable again (a restored backlink brings the comment back aspendingthrough the moderation machine: deleted→pending fires, clearing tokens per B10). A single backlink-less 200 flips the ledger but keeps the comment; deletion needs a second consecutive observation and then removes EVERY comment the source owns (all target paths) through the moderation machine, firing onecomment.status_changedevent per deleted comment. Mention updates keep their status and now refreshcontent_hash(computed by the worker over the raw e-content through the shared pipeline);is_newnotifications key on the (source, target) pair, so a second page mentioned by the same source notifies on its own. Backlink matching stays fragment-ignored / query-significant / case-sensitive-path / significant-trailing-slash (deliberately no utm-stripping). - Notification batching pins the fixed-window deadline
(
opened_at + NOTIFY_BATCH_SECS) with clock-driven tests — the old per-push timers were effectively fixed-window too (the first timer won), but armed O(N) tasks and the deadline was never pinned. One timer task is now armed per window (when it opens) instead of one per push. Open windows drain as final digests on graceful shutdown (bounded to 12 s, in-flight sends awaited) instead of being lost on restart. - Batch comment moderation now emits one
comment.status_changedevent per changed item (the documented engine polling path previously fired nothing), and self-service delete by token emits one as well (previously silent). Same-status changes emit nothing on every path. Re-approving a self-deleted comment through the admin API clears its delete token in the same commit, so the old token cannot delete the revived comment. - Closed the unauthenticated SSRF hole in native-comment avatar fetch:
author_urlpages are now fetched throughSafeFetcher(src/fetch.rs), the single guarded door also used by webmention fetches. Every hop is resolved and checked against the blocklist (named hosts and trailing-dot forms included), redirects are capped at 5 hops (fail-closed), and bodies are capped at 1 MiB while streaming. Any refusal falls back to the dicebear avatar like every other fetch failure. The old redirect-policy client no longer exists as a fetch path (src/http/reqwest_client.rsis a re-export shim); the shared client is for operator-configured endpoints only. Blocklist gaps closed alongside: trailing-dot hostnames (localhost.), TEST-NET-2/3 documentation ranges, and IPv6 unspecified/documentation addresses. DNS-rebinding TOCTOU (check-then-connect without address pinning) and IPv6 translation ranges (NAT64/6to4/Teredo) remain documented limitations, not claimed defenses. Follow-up hardening: webmention fetches honor the configuredFETCH_TIMEOUT_MS(threaded from the worker spawn args, pinned by test); URLs with userinfo credentials are refused before connecting without echoing the credentials into errors; the now-unreferenced synchronous redirect policy was deleted. PUBLIC_TARGET_ORIGINis validated at startup (absolutehttp(s)URL with a host). A typo'd origin now fails boot with aConfigErrorinstead of panicking per webmention request and per queued worker job.STORE_IP_ADDRESSacceptstrue(any case) and1, likeTURNSTILE_ENABLED.STORE_IP_ADDRESS=TRUEno longer silently disables IP storage.- Startup configuration output redacts Slack, Discord, and moderation webhook URLs (host plus a truncated path prefix). These URLs embody bearer posting tokens and were previously printed verbatim.
- Setting only one of
TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_IDlogs a startup warning instead of silently disabling Telegram delivery. - Discord notifications escape author names and comment text:
@everyoneand@hereare broken with a zero-width space and Discord markdown (**,||,`,<>, and others) is backslash-escaped, so a comment cannot ping or reformat the admin channel. - Discord payloads are capped at the 2000-character channel limit. Previews and name lists shrink first and the moderation footer is kept through every shrink stage; only the last-resort cut, reached when the unshrunk fields alone exceed the limit, can remove it.
- GitHub lookup now honors its timeout argument:
RealGitHubappliestimeout_msas a per-request timeout instead of silently ignoring it. - GitHub 403/429 (rate-limit) responses are now negative-cached like 404s, so a burst cannot burn the operator's API quota with repeated lookups. Cache keys are lowercased once per lookup, so case variants of a username share one row instead of bypassing the negative cache.
- Legacy upgrade ordering: column additions now run before the canonical
schema snapshot, whose indexes (for example
idx_comments_parent) fail on databases still missing their columns. - Migration DDL errors propagate instead of being swallowed by
let _ =. Only the already-exists case is skipped, via existence checks. Confignow has aDefaultimplementation mirroring the documented environment defaults;from_envoverlays environment variables on top of it. Test setups build on..Config::default()with per-test overrides.NOTIFY_BATCH_SECSandNOTIFY_BATCH_THRESHOLDparse failures now reportInvalidNotifyBatchSecs/InvalidNotifyBatchThreshold(naming the right variable) instead of reusing theRATE_LIMIT_*error variants.- Comment reads now build on one
COMMENT_COLUMNSlist plus a singleselect_commentsquery builder (src/db/repo/), withrow_to_commentas the only row mapper. The twelve duplicated 18-columnSELECTstrings and the dual-query cursor branches collapse into one parameterized statement per read path ((?N IS NULL OR ...)predicates). No behavior change: rows and ordering are identical, pinned by round-trip tests asserting all eighteen fields per query variant. - Schema upgrades are now data:
src/db/pool.rsholds aMIGRATIONSarray (index is the version, the stamp is the index,LATESTis derived from its length) applied by iterate-and-apply instead ofif current < Nblocks. Column additions still route through the existence-checkedadd_column_if_missingsafety net; every other statement isIF NOT EXISTS.migrations/schema.sqlstays the fresh-install snapshot artifact. A new parity test upgrades a fabricated v0 database through all steps and diffssqlite_masterplusPRAGMA table_infoagainst a fresh install, so step/snapshot drift (the v8 reactions near-miss class) fails the suite. Refuse-newer-DB, idempotent catch-up, the duplicate-pair pre-flight, and the v7 backfill are unchanged. - Rate-limit governors are now honest about
(burst, window): the GCRA refill interval iswindow / burstper cell, so the sustained rate after the burst matches the documentedburst / windowper second (native100/60 s ≈ 1.67/s, webmention60/60 s = 1/s, read300/60 s = 5/s, admin30/60 s = 0.5/s). Previously the window fedper_seconddirectly (one cell per window), throttling sustained traffic far below the documented rates. Env var names are unchanged. - Route groups own their layer recipe (
src/http/routes.rs): native write, public read, session, webmention, and protected admin constructors bundle routes with their governor and body-limit layers in one order. CORS wraps the public router and the protected admin group merges after it inroutes::compose, so the no-CORS invariant is a function boundary. - Governor 429s now return the documented JSON shape
(
{"error", "code": "rate_limited"}) withRetry-After, matching handler-side quota 429s, instead of plain-text "Too Many Requests". docs/architecture.mdanddocs/security.mdrate tables list the real defaults (100/60/300/30, not the stale 50/30/60/10) with sustained-rate columns and login/batch/export rows. A config-level test re-derives the tables fromConfig::default, so doc/code drift fails the suite.- One client-identity seam (
src/http/peer.rs): each request resolves to a single normalized peer address (IPv4-mapped IPv6 canonicalizes to IPv4) consumed by the governors, the in-memoryLimiter, and IP hashing (submission, anyone-mode reactions, import re-derivation). A client on a mapped address no longer gets a double quota or a mismatched hash. - New
TRUST_PROXYflag (defaultfalse): unset,X-Forwarded-For,X-Real-IP, andForwardedare ignored (spoof-proof); set, the leftmostX-Forwarded-Forentry (elseX-Real-IP, elseForwarded for=) identifies the client. Enable only behind a proxy you control that overwrites those headers. - Upgrade note (client-identity normalization):
::ffff:a.b.c.dpeers now key and hash asa.b.c.d. The in-memory per-IP daily caps reset on the upgrade restart (as on any restart), so no quota action is needed. Anyone-mode reaction identifiers stored under the old mapped hash no longer match — affected users re-react and the orphaned rows stay. WithSTORE_IP_ADDRESS=true, newsubmitter_ip_hashrows use the normalized form while history keeps the mapped form. No code migration is provided: this bites only non-default dual-stack binds combined with anyone-mode reactions or IP storage. RepoError::Internal(String)is now typed (src/db/mod.rs): SQLite failures classify once, at the rusqlite boundary, intoConstraint(UNIQUE/FOREIGN KEY/CHECK — the row is invalid),Busy(SQLITE_BUSY/SQLITE_LOCKED— retry with backoff),Io(SQLITE_IOERR/SQLITE_FULL/SQLITE_CANTOPEN), andOther(everything else, including row-decode errors).Displayand theAppErrormapping are byte-identical (all four still500), so no handler, import, or reaction behavior changes: existing?-aborts and upsert logic are untouched. Retry/skip policies now have a type to match on instead of error substrings.- Related storage writes are now atomic (
src/db/repo/):Repo::with_connruns N statements on one pooled connection andRepo::with_txwraps them inBEGIN IMMEDIATE(contention surfaces asBusyfor backoff retry). Native comment store (insert plus auto-approve status plus extracted-URL rows), webmention store (upsert plus ledger row), and gone handling (ledgergoneplus comment deletion) each commit or roll back as one unit — a mid-unit failure leaves no torn comment-without-URLs or mention-without-seen. Invalid URL rows (empty fields,Constraint) are skipped with a warn log while the comment still commits;Busy/Io/Otherabort the whole unit. Reaction approval is a compare-and-swap on the seen emoji, so an emoji change racing an approval keeps the new emoji pending. The admin export reads all five tables on one connection in one read transaction (a single WAL snapshot), and the public read API batches list plus total plus reaction counts per request the same way. Import stays per-row. Follow-up (not fixed here): poolmax_size/get_timeout(B-12) — pool acquisition still waits without a timeout. - Moderation is now one status machine (
src/moderation.rs): aStatusenum (pending/approved/spam/deleted, parsed at the boundary, stored as text as before) replaces the five hand-written action whitelists, andModeration::transitionowns validity, the status write, and the webhook emission.deletedis terminal except via admin re-approve (the webhook can never revive, owners can only delete); an admin revive clears the delete token in the same commit. OneModerationSink(async fire + 10 s sync decision adapters) with one payload builder replaces the two copy-pasted sync loops and four hand-built payloads; event schemas are unchanged. Pending→approved reaction approvals compare-and-swap on the reviewed emoji (expected_emojion the single and batch reaction routes: stale values are rejected with 400, absent behaves as before), so an emoji change racing an approval keeps the new emoji pending; approvals fromspam/deletedare explicit overrides via plain write. - Import is now a storage-layer restore service (
Repo::restoreinsrc/db/repo/restore.rs;POST /api/admin/importkeeps auth plus JSON only). Per-section handling is uniform: invalid and orphaned rows in every section (comments, ledger, URLs, profiles, reactions) skip with a per-section*_skippedcount — a single bad reaction row no longer aborts the whole import with a 500 and lost counts. Row failures branch structurally on the typed storage error (Constraintskips;Busy/Io/Otherabort for retry). Restoring into a live database whose comment or reaction IDs hold different data is refused with400before the first write instead of silently reverting live moderation decisions; pass"force": truein the import body to overwrite colliding rows explicitly. Re-importing the same document stays idempotent, each comment's URL rows are replaced in one atomic commit, restored statuses write directly with no moderation webhook emission and no compare-and-swap, and URL/ledger/profile rows get structural validation (shapes plus byte-length caps). The response gainswebmention_seen_skipped,comment_urls_skipped,github_profiles_skipped, andcomment_reactions_skippedalongside the existing counts. - Notifications now go through one
Channeladapter seam (src/notify/channel.rs): each channel owns only its wire format and escape rules (policies unchanged), while dispatch, retry, and size budgets are shared — the three near-identicalsendfunctions and the two hand-synced dispatch branches collapse into one loop overNotifier::channels, so a fourth channel is one new adapter file plus the registration checklist (Configfields,Notifierfields plus anis_emptyarm, onepushline). Delivery retries transient failures (network errors, 429, 5xx) up to 3 attempts with backoff, then logs and drops; permanent failures (other 4xx, Telegramok: false) are attempted once. Bounded retry (duplicates possible on timeout), log-and-drop: submission never blocks on delivery.Retry-Afteron 429s is honored up to a 2 s cap per wait. Telegram and Slack payloads are now capped at their channel limits (4096 / 3000 characters) with the same shrink-cheapest-first policy Discord already had; the moderation footer survives every shrink stage. Digest content and grouping are unchanged. - Native submission is now one deep module (
CommentIngress::submitinsrc/ingress.rs, placed at the crate root besidemoderationbecause both HTTP and storage depend on the pipeline, never the reverse): the 300-line handler thins to form→submit→response whileIngressowns the 13-step ordering (honeypot → Turnstile → daily cap → validate → hash-on-raw → sanitize → language gate on sanitized text → author/avatar resolve → parent check → T15 atomic store → notify → moderation sink) behindNotify/ModerationSink(T16's, via its one shared sync/asyncdeliveradapter — the comment and reactionif sync/else emitduplications collapse to one line each) /UrlStoreeffect seams with in-memory fakes in tests. Ordering invariants are pinned: the content hash reflects the raw input, the language gate sees sanitized text, and URL rows come from the sanitized content only (URLs inside tags ammonia strips no longer persist — B12 fixed). Double submits still store twice with one shared hash (B9, deliberate: no idempotency keys — the engine dedups post-hoc viacontent_hashlookup). Follow-ups left open: language-gate quarantine tier (B6, hard block stays) and Unicode body-limit parity (B7, ~680-emoji math documented, not fixed); sync-webhook decisions still apply on the plain write path (no machine transition event). - Honeypot detection honors
HONEYPOT_FIELD(fallbackwebsite): the configured field flags, any other trap-looking field is inert. The widget emits the configured name via server-side substitution into the served/embed/comments.js(the alternative — accepting both names — would keep the old name live as an unflagged bypass), and reply forms now actually send the trap value (previously created but never serialized, so JS replies could never trip it).HONEYPOT_FIELDis restricted to 1–64 chars of[A-Za-z0-9_-]at startup (anything else refuses boot rather than shipping a broken widget). The served script carries an ETag over (file bytes, trap name): renames change the ETag and clients revalidate (If-None-Match→304), so the hour-longmax-agecache turns over correctly instead of reusing a stale trap name blindly. - Author identity is one shared module (
src/identity.rs, crate root so native and import share it without HTTP↔storage inversion): control AND bidi/format spoof characters (U+202E overrides, U+200B zero-width, isolates — ZWJ/ZWNJ deliberately kept for emoji) strip from names,github_usernameis shape-checked (1–39 chars, alphanumeric or single hyphens, never leading/trailing) before URL interpolation (hostile values are a400, never part of a URL),MAX_AUTHOR_LENapplies everywhere (the import path's hardcoded100is gone — over-long backup rows clamp, live input rejects), andvalidate_http_urlnow enforces the host its docs always claimed (defense in depth over theurlcrate's empty-host parse errors). Native/import parity is pinned by a table test. - Delete tokens are 128-bit CSPRNG secrets (32 lowercase hex chars from the
OS RNG via the
getrandomcrate — now a direct dependency, already vendored transitively, so no new audit surface — zero inputs, not derived from peer address or time), replacing the deterministic 64-bitDefaultHashertoken that was crackable offline over the (IP, time) window. Uniqueness is statistical; the delete route stays rate-limited with same-404 semantics. An OS RNG failure aborts the submission with a 500 (fail-closed — no weak token is ever minted). - Moderation webhooks can be HMAC-signed (additive
WEBHOOK_SIGNING_SECRET, empty/unset sends the historical unsigned body): every emission — async and sync,*.createdand*.status_changed— carriesX-Zapiska-TimestampandX-Zapiska-Signature: v1=<hex>(HMAC-SHA256 over"<timestamp>.<raw JSON body>", no new dependencies — built onsha2+subtle). Consumers recompute, compare in constant time, and enforce a ±300 s replay window; a secret-holding consumer rejects unsigned or tampered bodies (seedocs/moderation-engine.mdfor the verify sketch). - Webmention processing is now a
WebmentionProcessorbehind aSourceFetcheradapter (src/worker.rs+src/fetch.rs): the spawn loop is a thin drain, production fetches go throughSafeFetcherbuilt with the configuredFETCH_TIMEOUT_MS(the deadfrom_configconstructor is gone), and tests inject a canned mock with no network. The oldprocess_job/process_job_with_timeoutfunctions and theallow_loopbackproduction parameter are gone: nothing flips the SSRF check from the call side anymore. - Single-instance enforcement (T23, ADR-0001):
AppState::startclaims a<database>.locksibling file holding its PID before the pool opens and refuses when another live instance holds it, with the PID, the lock path, and the remedy in the error. A stale file from a crash is reclaimed by PID liveness on Linux (/procplus a PID-reuse command-line guard); elsewhere existence alone refuses. A clean shutdown releases the lock after the notification drain (release_db_lock, called frommain). Two instances with different database files never block each other. Pinned by lock-contention tests (second start refuses, dead-PID reclaim, release-then-restart, per-database independence). No new dependencies (deliberately std-only). - Architecture decision records in
docs/adr/: single-process topology, export v1 as the compatibility contract, notification bounded retry (duplicates possible on timeout — not at-most-once), and the per-ticket calls worth keeping (SafeFetcher as the only outbound door, restore without events, gone grace of two, refuse-by-default import, the 13-step ingress ordering).
0.2.0 - 2026-08-07
- Reactions with moderation status and public approved counts.
- Language filtering for native comments (allow/block lists, emoji policy).
- RSS feeds (
/feed.xml, global and per-path). - Sortable public comment reads (
newest/oldestwith cursors). - JSON export and import for all five tables.
- Telegram, Slack, and Discord notifications with batching.
- Pentest suite, Docker/CI hardening, ASD-STE100 docs rewrite.
0.1.0 - 2026-08-07
- Native comments with sanitization, threading, and honeypot flagging.
- W3C webmention receipt with backlink verification and h-entry parsing.
- Admin moderation, lookup, and bulk-context API with token/cookie auth.
- Content hashing, moderation webhooks, and daily/domain caps.
- Submitter IP storage with salted SHA-256 hashing (
IP_HASH_SECRET). - Cloudflare Turnstile verification for native comments.
- Per-route rate limits, CORS, and request body limits.
- Single-command Docker Compose deploy, GHCR multi-arch images, cross-platform release binaries, systemd/OpenRC templates.
- Swagger UI with OpenAPI document.