diff --git a/cayceops/components/monitor/monitor.py b/cayceops/components/monitor/monitor.py index 2f2115d..cd61b5a 100644 --- a/cayceops/components/monitor/monitor.py +++ b/cayceops/components/monitor/monitor.py @@ -147,6 +147,85 @@ def _build(cfg: Config, sess) -> str: return cfg.monitor_output +def _anchor(cfg: Config, sess) -> int: + """Anchor core (injectable session for tests). Returns 0 ok / 0 skip.""" + from cayceops.components.publisher import nostr + from cayceops.config import ConfigError + from cayceops.models import AuditEntry, audit + + if not cfg.nostr_private_key or not cfg.nostr_relays: + raise ConfigError( + "anchor requires CAYCE_OPS_NOSTR_PRIVATE_KEY and CAYCE_OPS_NOSTR_RELAYS" + ) + if not cfg.deployment_id: + raise ConfigError("anchor requires CAYCE_OPS_DEPLOYMENT_ID") + + last = ( + sess.query(AuditEntry) + .filter(AuditEntry.entry_hash.isnot(None)) + .order_by(AuditEntry.id.desc()) + .first() + ) + if last is None: + print("nothing to anchor: chain empty") + return 0 + today = datetime.now(UTC).date() + last_anchor = ( + sess.query(AuditEntry) + .filter(AuditEntry.action == "anchor") + .order_by(AuditEntry.id.desc()) + .first() + ) + if last_anchor is not None and last_anchor.ts.date() == today: + print("already anchored today") + return 0 + + secret = nostr.normalize_key(cfg.nostr_private_key) + event = nostr.build_event( + secret, + f"cayce-ops audit anchor deployment={cfg.deployment_id} " + f"last_hash={last.entry_hash} entries={last.id}", + tags=[["t", "cayce-ops"], ["t", "audit-anchor"]], + ) + if not nostr.verify_event(event): + raise RuntimeError("anchor event failed self-verification") + results = nostr.publish(cfg.nostr_relays, event) + audit( + sess, + actor="daemon:monitor", + action="anchor", + detail={ + "last_hash": last.entry_hash, + "last_entry": last.id, + "event_id": event["id"], + "relays": results, + "deployment": cfg.deployment_id, + }, + ) + print(f"anchored: {event['id']}") + return 0 + + +def anchor(cfg: Config | None = None) -> int: + """CLI: anchor once per day (external anchoring, audit round 2 point 4).""" + from cayceops.config import ConfigError, load + from cayceops.db import engine + + cfg = cfg or load() + try: + with session_scope() as sess: + try: + return _anchor(cfg, sess) + except ConfigError as exc: + print(f"anchor refused: {exc}") + return 2 + except Exception as exc: # noqa: BLE001 — CLI boundary + print(f"anchor failed: {str(exc)[:200]}") + return 1 + finally: + engine().dispose() + + def build(cfg: Config | None = None) -> int: """CLI entry: render the status page inside a DaemonRun bookkeeping wrap.""" from cayceops.config import load diff --git a/tests/test_monitor.py b/tests/test_monitor.py index a6e2222..5b8fa91 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -67,3 +67,42 @@ def test_build_escapes_injected_content(cfg, sess, tmp_path): page = out.read_text() assert "" not in page assert "<script>x</script>" in page + + +def test_anchor_signs_and_logs_once_per_day(cfg, sess, monkeypatch): + from cayceops.components.monitor import monitor + from cayceops.models import AuditEntry + + # seed the chain + from cayceops.models import audit as audit_fn + + audit_fn(sess, actor="daemon:executor", action="execute_done", detail={}) + sess.flush() + + published = [] + + def fake_publish(relays, event, timeout=10.0): + published.append(event) + return [{"relay": r, "ok": "true"} for r in relays] + + import cayceops.components.publisher.nostr as nostr_mod + + monkeypatch.setattr(nostr_mod, "publish", fake_publish) + + config = cfg( + CAYCE_OPS_NOSTR_PRIVATE_KEY="11" * 32, + CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org", + CAYCE_OPS_DEPLOYMENT_ID="itest", + ) + code = monitor._anchor(config, sess) + assert code == 0 + assert len(published) == 1 + content = published[0]["content"] + assert "audit anchor" in content and "itest" in content + entry = sess.query(AuditEntry).filter_by(action="anchor").one() + assert entry.detail["last_hash"] + + # second call same day -> skipped, no new nostr event + code2 = monitor._anchor(config, sess) + assert code2 == 0 + assert len(published) == 1