Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,27 @@ the template commit.
other than `24:00:00` are rejected; `heron fetch` cannot overflow its
failure counter; the compose build context is the repository root; the
documented monitor defaults and robots.txt behaviour now match the code.
* Fourth review round (posted as #3 merged, fixed on a follow-up branch): a
configuration error message no longer points into memory freed before it
is printed; opening `/item/N` marks the item read only for a navigation
the browser labels same-origin or user-typed (Fetch Metadata), so another
site cannot mark items read by embedding the URL, and otherwise the page
offers a POST "Mark read" button; `check-config` rejects a `web.bind` that
is a host name or a non-loopback address without `allow_non_loopback`;
`cert_warn_days: 0` turns the certificate check off entirely, expired
certificates included; an RSS `<guid isPermaLink="false">` is never used
as the item URL and an `atom:link` is preferred to a guid; a `<` that
cannot open a tag (`Price < 10`) is text for monitor change detection; the
selur (Tier-C) stack mounts a config, sets `HERON_*`, publishes on host
loopback only and drops the unusable health check. Review of the follow-up
PR: a prerendered item page is declined rather than marked read; an RSS
text `<link>` is found even after an `atom:link`, and `rel` compares
case-insensitively; the selur config path starts with `./` as
selur-compose requires; status views hide certificate expiry while
`cert_warn_days` is 0; an alternate link the UI cannot follow (`ftp:`) no
longer displaces a permalink guid; the selur environment tables are
single-line so its TOML 1.0 parser accepts the file, and the proxy note
covers the Origin check on POST.

==== Changed

Expand Down
45 changes: 24 additions & 21 deletions build/container/stapeln/compose.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,30 +27,37 @@ version = "1.0"
# Primary application service
[services.heron]
image = "ghcr.io/hyperpolymath/heron:latest.ctp"
ports = ["8484:8484"]
environment = {
APP_HOST = "[::]",
APP_PORT = "8484",
APP_LOG_FORMAT = "json",
APP_DATA_DIR = "/data",
}
volumes = ["heron-data:/data"]
# The web UI has no authentication: publish it on the host loopback only.
ports = ["127.0.0.1:8484:8484"]
Comment thread
hyperpolymath marked this conversation as resolved.
Resolved
# heron reads only HERON_CONFIG and HERON_DATA; bind address and port come from the config file.
# Inline tables stay on one line: selur-compose parses with the toml 0.8 crate (TOML 1.0), which
# rejects a newline inside `{ }`.
environment = { HERON_CONFIG = "/config/heron.json", HERON_DATA = "/data" }
# Without a config heron exits with ConfigNotFound. This is the heron.json compose.yaml mounts: it
# binds the UI to 0.0.0.0 inside the container and relies on the loopback mapping above.
# selur-compose takes a volume as a bind mount only when it starts with "./" or "/" (anything else
# must be a declared named volume), hence "./../" rather than "../". Run it from this directory.
volumes = ["heron-data:/data", "./../heron.json:/config/heron.json:ro"]
restart = "always"
healthcheck = { test = "curl -sf http://localhost:8484/health", interval = "30s", timeout = "5s", retries = 3 }
# No healthcheck: heron has no /health route and the runtime image carries no curl. heron is PID 1
# and exits non-zero on a fatal error, which `restart` handles.
#
# The rokur/svalinn gateway services below are template extras and do NOT make the UI reachable as
# configured: the UI refuses a Host header that is not the bound address or a loopback name
# (DNS-rebinding defence), so a gateway must forward Host as 127.0.0.1:8484. Rokur's documented
# settings offer no Host rewrite, so either front heron with a proxy that can, or drop this tier
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
# (`just container-keep oci portable`) and use the loopback port above. Such a proxy must also make
# the forwarded Origin match the forwarded Host (http://127.0.0.1:8484), after validating the
# browser's Origin itself: the UI refuses a POST whose Origin differs from its Host (CSRF defence),
# so mark-read/unread and mark-all-read would otherwise answer 403.

# Rokur secrets gate: refuses to start (and refuses to proxy) unless the
# declared secrets are present; emits a structured audit log of every access.
# Sits between svalinn and the application. Remove if you do not gate secrets.
[services.rokur]
image = "ghcr.io/hyperpolymath/rokur:latest.ctp"
ports = ["8081:8081"]
environment = {
ROKUR_BACKEND = "http://heron:8484",
ROKUR_LISTEN = "[::]:8081",
ROKUR_REQUIRED_SECRETS = "",
ROKUR_AUDIT_LOG = "/var/log/rokur/audit.jsonl",
ROKUR_LOG_FORMAT = "json",
}
environment = { ROKUR_BACKEND = "http://heron:8484", ROKUR_LISTEN = "[::]:8081", ROKUR_REQUIRED_SECRETS = "", ROKUR_AUDIT_LOG = "/var/log/rokur/audit.jsonl", ROKUR_LOG_FORMAT = "json" }
volumes = ["rokur-audit:/var/log/rokur"]
depends_on = ["heron"]
restart = "always"
Expand All @@ -61,11 +68,7 @@ healthcheck = { test = "curl -sf http://localhost:8081/health", interval = "30s"
[services.svalinn]
image = "ghcr.io/hyperpolymath/svalinn:latest.ctp"
ports = ["443:443", "80:80"]
environment = {
SVALINN_BACKEND = "http://rokur:8081",
SVALINN_POLICY_FILE = "/etc/svalinn/gatekeeper.yaml",
SVALINN_TLS_AUTO = "true",
}
environment = { SVALINN_BACKEND = "http://rokur:8081", SVALINN_POLICY_FILE = "/etc/svalinn/gatekeeper.yaml", SVALINN_TLS_AUTO = "true" }
volumes = ["svalinn-config:/etc/svalinn:ro"]
depends_on = ["rokur"]
restart = "always"
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ crash recovery and `heron rebuild` would be lies.

| 4
| The web UI is reachable only from the machine unless explicitly allowed.
| `src/web.zig`: loopback bind by default, `allow_non_loopback` gate, Host-header allowlist, Origin check on POST, CSP, no JavaScript.
| `src/web.zig`: loopback bind by default, `allow_non_loopback` gate, Host-header allowlist, Origin check on POST, Fetch Metadata gate on the GET read mark, CSP, no JavaScript.
| DNS-rebinding or LAN exposure of an unauthenticated UI.

| 5
Expand Down
63 changes: 53 additions & 10 deletions docs/status/ROADMAP.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,33 @@ build and test instructions moved to `README.adoc` and `CONTRIBUTING.adoc`.
Milestones M0–M6 are complete and imported with their signed commit history.
Ground truth at import (2026-10-27, Zig 0.17.0 exactly, x86_64 Linux host):

* `zig build test --summary all`: 85/85 tests pass.
* `zig build test --summary all`: 85/85 tests pass at import; 94/94 after
the four review rounds (see below).
* `zig build -Doptimize=ReleaseSafe` and the static musl build both succeed.
* `zig fmt --check` was NOT clean on seven files (Zig 0.17 renames
`@intFromEnum` to `@backingInt` and re-wraps two literals); the formatter was
applied at import so that `just lint` passes.
* Idle RSS ~8 MB (ReleaseSafe, 24 feeds + 6 monitors + web UI) was measured
before import over one ~45 s window; not re-measured since.

=== Import review (2026-10-27 to 2026-11-04)

The import merged to `main` as hyperpolymath/heron-polyfeed-reader#3
(squash, 2026-10-27). Codex reviewed every push: 45 findings over four
rounds, each traced to a real input before it was fixed, all fixed with
tests, none declined. The fourth round (8 findings) was posted as #3 merged
and is fixed on a follow-up PR. Among them: panics or ReleaseSafe traps
on untrusted input (title-less RSS channel, page-number overflow,
out-of-range `cert_warn_days`), a use-after-free of configuration error
text, terminal-escape injection through feed titles printed by the CLI, a
cross-site GET that marked items read, mixed-content text order, index/log
consistency (stale read-only log length, replay limit, failed-commit replay,
truncated-log reindex), strict configuration (unknown keys, mistyped values,
unknown adapter types, unusable `web.bind`), robots.txt across redirects, and
the template container stacks (Tier-A image, compose and selur configs).
SonarCloud's quality gate passed. CodeRabbit did not review: the import's
216 files exceed its 100-file limit and the account had no credits.

== Milestones

[cols="5,1",options="header"]
Expand All @@ -37,7 +56,7 @@ Ground truth at import (2026-10-27, Zig 0.17.0 exactly, x86_64 Linux host):
| M4: HTTP transport (timeouts, manual redirects), config, engine, daemon, CLI | 100%
| M5: monitor adapter (up/down transitions, change detection, TLS expiry, robots) | 100%
| M6: localhost-only web UI (list/search/item/read marks/status), daemon integration, e2e test | 100%
| M7: integrate into the RSR estate (template merge, mint, CI) | 90%
| M7: integrate into the RSR estate (template merge, mint, CI, review) | 95%
| M8: second adapter (NNTP or Gemini) to prove the adapter seam | 0%
| M9: live-site soak test and real-CA certificate-probe check | 0%
| M10: web UI `monitors` summary and search pagination | 0%
Expand All @@ -49,10 +68,18 @@ Ground truth at import (2026-10-27, Zig 0.17.0 exactly, x86_64 Linux host):
. *Clade is UNASSIGNED* in the deed. Choosing it is the owner's act (the
template says so); `just validate-rsr` fails until it is chosen. The
ecosystem `:position-type` is UNASSIGNED for the same reason.
. *Signing.* The M7 integration commits were prepared in a session with no
signing key. They must be committed with `id_ed25519_signing` locally
before push; the eleven imported milestone commits keep their own
signatures.
. *Code-owner review of your own PR.* `main`'s Branch-Floor ruleset requires
a code-owner review, and CODEOWNERS names only `@hyperpolymath`, who also
authors these PRs; GitHub never counts an author's own review. The owner
merged #3 themselves. Every later PR from this account meets the same
wall unless a second code owner (a person or a team) is added or the rule
is relaxed for solo repositories; that is an estate-wide question for
`standards`, not this repo alone.
. *Commit signatures.* The M7 commits were signed by the cloud session's
key, not the owner's `id_ed25519_signing`; `main` holds the squash commit
GitHub made. The eleven milestone commits keep their own signatures in
#3's commits, which GitHub keeps at `refs/pull/3/head` (a squash
flattens them out of `main`; the source branch was deleted on merge).
. *Idris2 ABI seam.* `just test` and `just build` run every detected
language. The template's `src/interface/abi.ipkg` (Idris2) is detected
alongside `build.zig`; without `idris2` on PATH the Zig steps pass but the
Expand All @@ -67,8 +94,26 @@ Ground truth at import (2026-10-27, Zig 0.17.0 exactly, x86_64 Linux host):

== Next actions

* Push the branch, let `zig-ci.yml` and the estate gates run, triage every
review-app finding and CI anomaly, merge.
* Merge the fourth-round fixes; then M7 is done except the owner decisions
above.
* Feature raised in review, not built: an alert when robots.txt itself
changes (today only a change that blocks the monitored path alerts).
* Upstream, outside this repo:
** `hyperpolymath/standards`: `scripts/check-no-vlang.sh` should exclude
`vendor/` (an FTS3 identifier in SQLite's amalgamation matches its
case-insensitive pattern; patched locally).
** `hyperpolymath/rsr-template-repo`: `just repo-init`'s OPENSSF line delete
leaves a table row in `docs/governance/TEMPLATE-STANDARDS-AUDIT.adoc` one
cell short; the template `compose.yaml` build context `..` resolves to
`build/`, not the repository root; its container stacks assume an
`APP_*` environment and a `/health` route a minted project may not have,
and that rokur proxies to the app (`ROKUR_BACKEND`), which rokur's own
documented settings do not include. Its selur `compose.toml` and
`compose.example.toml` use multi-line inline tables, which selur's TOML 1.0
parser (toml 0.8) rejects, so neither loads (fixed here in `compose.toml`).
** `metadatastician/selur`: `selur-compose` treats a volume that does not
start with `./` or `/` as a named volume, so `../file` bind mounts fail
validation (heron works round it with `./../heron.json`).
* Owner: choose the clade (`ap` Applications is the obvious candidate: an
end-user desktop daemon), set `:position-type`, register in gv-clade-index.
* M8: second adapter (NNTP/NNTPS or Gemini with TOFU) with no core changes;
Expand All @@ -85,8 +130,6 @@ Ground truth at import (2026-10-27, Zig 0.17.0 exactly, x86_64 Linux host):
* Re-sync `src/vendor/tls_client.zig` whenever Zig is upgraded
(`src/vendor/NOTICE`); the `[sfr patch]` markers carry the old project
name and can be renamed then.
* Re-count the container `test` stage after the rename (host run was 85/85;
`zig-ci.yml` now builds that stage on every push).

== Known gaps / untested

Expand Down
5 changes: 3 additions & 2 deletions docs/usage.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ One JSON file (no YAML). `check-config` rejects unknown keys at every level and
| `user_agent` | string | built-in | Sent on every request; put a contact address in it.
| `timeout_s` | integer | 30 | Wall-clock timeout per request.
| `web.enabled` | boolean | `false` | Serve the web UI from the daemon.
| `web.bind` | string | `127.0.0.1` | Bind address. Anything but loopback also needs `allow_non_loopback`. Requests must carry a Host header naming `127.0.0.1`, `localhost`, `[::1]` or this bound address (with the port); a wildcard bind (`0.0.0.0`, `::`) is not special-cased, so name the address you expose.
| `web.bind` | string | `127.0.0.1` | Bind address, numeric (`127.0.0.1`, `::1`); a host name such as `localhost` is rejected. Anything but loopback also needs `allow_non_loopback`. Requests must carry a Host header naming `127.0.0.1`, `localhost`, `[::1]` or this bound address (with the port); a wildcard bind (`0.0.0.0`, `::`) is not special-cased, so name the address you expose.
| `web.port` | integer | 8484 | Listening port.
| `web.allow_non_loopback` | boolean | `false` | Required to bind a non-loopback address; the UI has no authentication.
| `sources[].name` | string | required | Short unique name used by the CLI.
Expand All @@ -83,7 +83,7 @@ One JSON file (no YAML). `check-config` rejects unknown keys at every level and
| `monitors[].expect_status` | integer | 200 | HTTP status that counts as UP.
| `monitors[].detect_changes` | boolean | `true` | Fetch the body (GET), hash its visible text and alert on change (rate-limited to one alert per hour). `false` checks reachability with HEAD only.
| `monitors[].check_robots` | boolean | `true` | Fetch and honour `robots.txt` (RFC 9309, cached 24 h): a BLOCKED alert when it starts disallowing the monitored path, and redirects to another origin are refused. Changes to robots.txt that leave the path allowed raise no alert.
| `monitors[].cert_warn_days` | integer | 14 | Alert when the leaf certificate expires within this many days.
| `monitors[].cert_warn_days` | integer | 14 | Alert when the leaf certificate expires within this many days, or has expired. `0` turns the certificate check off (no probe, no alerts).
|===

`examples/heron.json` is a complete, valid example.
Expand All @@ -106,6 +106,7 @@ One JSON file (no YAML). `check-config` rejects unknown keys at every level and
* No WebSub; feeds are polled.
* Relative entry links are resolved against the feed URL; `xml:base` is not consulted.
* The web UI serves one request at a time with a 10 s per-connection deadline; it is a personal UI, not a public server.
* Opening an item in the web UI marks it read only when the browser sends Fetch Metadata saying the navigation came from the UI itself or was typed by you (`Sec-Fetch-Site: same-origin` or `none`, `Sec-Fetch-Mode: navigate`; every current browser does). Otherwise, for example from an older browser or a link on another site, the item stays unread and the page shows a *Mark read* button. A prefetch or prerender (`Sec-Purpose: prefetch`) of an item page is declined with 503, so only the real visit marks it read.

== See also

Expand Down
2 changes: 1 addition & 1 deletion heron-polyfeed-reader_chora.deed
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
; equal canon.lock [canon.artifacts]; canon.lock [canon.lockstep].order is
; spine-adopts-then-canon-releases, so this clause moves FIRST.
(canon
:version "2.1.3"
:version "2.1.4"
:criteria-sha256 "6a5aa8857bd0d0d58ef48827938ca17c251b6b854dacf59d306388d61694d82a"
:gates-sha256 "2f31f46d4753432e0aa8c9c28f339e18e062c98dc6e6ef4ecbb76993cf37638b"
:lockstep-since "2026-09-17"
Expand Down
19 changes: 18 additions & 1 deletion src/app.zig
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ pub const App = struct {
break :blk try std.fmt.allocPrint(a, "{s}/.config/heron-polyfeed-reader/config.json", .{h});
};
const text = Io.Dir.cwd().readFileAlloc(io, config_path, a, .limited(1024 * 1024)) catch {
diag.message = try std.fmt.allocPrint(a, "cannot read config file {s}", .{config_path});
diag.set("cannot read config file {s}", .{config_path});
return error.ConfigNotFound;
};
const cfg = config.parse(a, text, diag) catch |e| switch (e) {
Expand Down Expand Up @@ -86,3 +86,20 @@ pub const App = struct {
self.arena.deinit();
}
};

test "a load failure's diagnostic outlives the arena load frees" {
var env = std.process.Environ.Map.init(std.testing.allocator);
defer env.deinit();
var diag: config.Diagnostic = .{};
// The path is allocated after the arena is gone, so the message must not point into it.
try std.testing.expectError(error.ConfigNotFound, App.load(std.testing.allocator, std.testing.io, &env, .{ .config_path = "/nonexistent/heron.json" }, &diag));
try std.testing.expectEqualStrings("cannot read config file /nonexistent/heron.json", diag.message());

var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "bad.json", .data = "{\"web\":{\"bind\":\"localhost\"}}" });
const path = try tmp.dir.realPathFileAlloc(std.testing.io, "bad.json", std.testing.allocator);
defer std.testing.allocator.free(path);
try std.testing.expectError(error.InvalidConfig, App.load(std.testing.allocator, std.testing.io, &env, .{ .config_path = path }, &diag));
try std.testing.expect(std.mem.startsWith(u8, diag.message(), "web.bind must be a numeric IP address"));
}
7 changes: 4 additions & 3 deletions src/cli.zig
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ fn run(init: std.process.Init) !u8 {
var diag: config.Diagnostic = .{};
var app = app_mod.App.load(gpa, io, init.environ_map, ov, &diag) catch |e| {
try out.flush();
std.log.err("{s}{s}{s}", .{ @errorName(e), if (diag.message.len > 0) ": " else "", diag.message });
std.log.err("{s}{s}{s}", .{ @errorName(e), if (diag.message().len > 0) ": " else "", diag.message() });
return 1;
};
defer app.deinit();
Expand Down Expand Up @@ -246,8 +246,9 @@ fn cmdMonitors(app: *app_mod.App, store: *store_mod.Store, out: *Io.Writer, aren
const state: []const u8 = if (ms.blocked) "blocked" else if (ms.up) |u| (if (u) "up" else "DOWN") else "unknown";
const since = if (ms.since > 0) util.fmtSpan(&b1, now - ms.since) else "-";
const http_s = if (ms.status > 0) try std.fmt.bufPrint(&b2, "{d}", .{ms.status}) else "-";
const cert = if (ms.cert_not_after > 0)
(if (ms.cert_not_after <= now) "EXPIRED" else util.fmtSpan(&b3, ms.cert_not_after - now))
const not_after = monitor_mod.shownCertNotAfter(arena, job.options, ms);
const cert = if (not_after > 0)
(if (not_after <= now) "EXPIRED" else util.fmtSpan(&b3, not_after - now))
else
"-";
try out.print("{s:<20} {s:<8} {s:>9} {s:>6} {s:>10} {s}\n", .{ job.name, state, since, http_s, cert, try util.termSafe(arena, ms.err) });
Expand Down
Loading
Loading