ci / lint-test-leaks (pull_request) Canceled after 0s
Background: CAYCE_OPS_OPERATOR_PUBKEY was set to Cayce nostr pubkey — Cayce was approving itself. Signature was technically valid but semantically null: human approval signed by the agent is not human approval. - signing.py: validate_no_self_approval() rejects the operator pubkey when it matches (a) the pubkey derived from any private key in the config, or (b) any pubkey in CAYCE_OPS_FORBIDDEN_OPERATOR_PUBKEYS deny-list. - executor/maild/publisher _signing() now calls the validation, so every daemon and every CLI approve-* path refuses to operate (D16). - tests: 6 new (agent-key refusal, human key passes, empty passes, explicit denylist, end-to-end daemon refusal, derivation roundtrip). - docs/DECISIONS.md: D16. The operator signature is now produced OUTSIDE the VPS. CAYCE_OPS_OPERATOR_PUBKEY must stay empty until the external signer exists — daemons refuse, which is correct. The Cayce nsec itself is NOT touched (still the agent identity for publishing and anchoring).
431 lines
14 KiB
Python
431 lines
14 KiB
Python
"""publisher — themed content calendar with a hard human gate on publishing.
|
|
|
|
State machine: draft -> approved -> published. Only ``approved`` items ever
|
|
publish. AUTONOMOUS_MODE has NO effect on this component: content always
|
|
requires explicit human approval (documented in AUDIT.md).
|
|
|
|
Channels: nostr (NIP-01, primary) — facebook/instagram adapters exist but are
|
|
disabled unless explicitly enabled via env (DECISIONS: default deployment never
|
|
touches Meta).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
from cayceops.components.publisher import images, nostr
|
|
from cayceops.config import Config
|
|
from cayceops.db import session_scope
|
|
from cayceops.models import (
|
|
ContentItem,
|
|
ContentStatus,
|
|
DaemonRun,
|
|
_content_hash,
|
|
audit,
|
|
)
|
|
|
|
# rotation: one theme per slot; channel rotation nostr -> facebook -> instagram
|
|
THEMES = [
|
|
("lightning-abroad", "bitcoinhotels.org/guides/lightning-abroad"),
|
|
("seed-strategy", "hardcap.news/learn/travel-security-kit"),
|
|
("atm-fees", "bitcoinhotels.org/guides/best-bitcoin-atms-by-city"),
|
|
("elsalvador-budget", "bitcoinhotels.org/guides/living-on-sats-in-el-salvador"),
|
|
]
|
|
|
|
_CAPTION_TEMPLATE = "{hook}\n\n{body}\n\n{link}"
|
|
|
|
|
|
def _caption(theme: str, link: str) -> str:
|
|
hooks = {
|
|
"lightning-abroad": "Paying abroad with Lightning beats your bank.",
|
|
"seed-strategy": "The seed strategy you pick decides how the border story goes.",
|
|
"atm-fees": "The first ATM you find charges 10-15% over spot.",
|
|
"elsalvador-budget": "A real weekly budget on a Bitcoin standard.",
|
|
}
|
|
bodies = {
|
|
"lightning-abroad": (
|
|
"No foreign transaction fees, no dynamic currency conversion, "
|
|
"no declines. Field-tested guide."
|
|
),
|
|
"seed-strategy": (
|
|
"Steel plate, memorized + decoy, or split custody — "
|
|
"the full breakdown, brutally honest."
|
|
),
|
|
"atm-fees": (
|
|
"The prepared traveler pays 4-6%. The difference is knowing "
|
|
"the operator and the no-KYC cap."
|
|
),
|
|
"elsalvador-budget": (
|
|
"Surf hostels, comidas, coworking — the corners where you still "
|
|
"need dollars, and the 90-day protocol."
|
|
),
|
|
}
|
|
return _CAPTION_TEMPLATE.format(
|
|
hook=hooks.get(theme, theme.replace("-", " ").title()),
|
|
body=bodies.get(theme, "Field-tested notes."),
|
|
link=link,
|
|
)
|
|
|
|
|
|
# --- calendar planning ----------------------------------------------------------
|
|
|
|
|
|
def _plan(cfg: Config, sess, days: int = 4) -> dict:
|
|
"""Fill the calendar: one draft per theme/channel pair not already queued.
|
|
|
|
Idempotent by (theme, channel): existing draft/approved items block a
|
|
duplicate. Drafts are scheduled one per day, starting tomorrow.
|
|
"""
|
|
created = 0
|
|
for idx, (theme, link) in enumerate(THEMES[:days]):
|
|
channel = ["nostr", "facebook", "instagram", "nostr"][idx % 4]
|
|
exists = (
|
|
sess.query(ContentItem)
|
|
.filter(
|
|
ContentItem.theme == theme,
|
|
ContentItem.channel == channel,
|
|
ContentItem.status != ContentStatus.PUBLISHED,
|
|
)
|
|
.one_or_none()
|
|
)
|
|
if exists:
|
|
continue
|
|
item = ContentItem(
|
|
theme=theme,
|
|
channel=channel,
|
|
caption=_caption(theme, link),
|
|
link=link,
|
|
status=ContentStatus.DRAFT,
|
|
scheduled_for=datetime.now(UTC).replace(hour=13, minute=0, second=0, microsecond=0)
|
|
+ timedelta(days=idx + 1),
|
|
)
|
|
sess.add(item)
|
|
sess.flush()
|
|
audit(
|
|
sess,
|
|
actor="daemon:publisher",
|
|
action="plan_draft",
|
|
detail={"item": item.id, "theme": theme, "channel": channel},
|
|
)
|
|
created += 1
|
|
return {"created": created}
|
|
|
|
|
|
# --- human gate -------------------------------------------------------------------
|
|
|
|
|
|
GATE_ERROR = 2 # missing/invalid configuration or signature
|
|
|
|
|
|
def _signing(cfg: Config) -> tuple[str, str]:
|
|
"""(deployment_id, operator_pubkey) or ConfigError. No degraded mode.
|
|
|
|
Also rejects the agent's own identity as operator (D16): the operator
|
|
key must belong to a human and must never reside on this server.
|
|
"""
|
|
from cayceops.config import ConfigError
|
|
from cayceops.signing import validate_no_self_approval
|
|
|
|
if not cfg.operator_pubkey or not cfg.deployment_id:
|
|
raise ConfigError(
|
|
"approvals disabled: CAYCE_OPS_OPERATOR_PUBKEY and "
|
|
"CAYCE_OPS_DEPLOYMENT_ID are both required"
|
|
)
|
|
validate_no_self_approval(cfg)
|
|
return cfg.deployment_id, cfg.operator_pubkey
|
|
|
|
|
|
def _content_binding(item: ContentItem) -> str:
|
|
from cayceops.signing import content_content_hash
|
|
|
|
return content_content_hash(item.channel, item.caption, item.link)
|
|
|
|
|
|
def _approve(
|
|
item_id: int, actor: str, sess, signature: str | None = None
|
|
) -> int:
|
|
"""Signature MANDATORY, bound to (channel, caption, link) + deployment."""
|
|
from cayceops.config import ConfigError
|
|
from cayceops.signing import SignatureError, verify_signature
|
|
|
|
item = sess.get(ContentItem, item_id)
|
|
if item is None or item.status != ContentStatus.DRAFT:
|
|
audit(
|
|
sess,
|
|
actor=actor,
|
|
action="approve_content",
|
|
result="error",
|
|
detail={"reason": f"item {item_id} not in draft state"},
|
|
)
|
|
return 1
|
|
try:
|
|
deployment_id, pubkey = _signing(cfg_from(sess))
|
|
except ConfigError as exc:
|
|
audit(
|
|
sess,
|
|
actor=actor,
|
|
action="approve_content",
|
|
result="error",
|
|
detail={"reason": str(exc)},
|
|
)
|
|
return GATE_ERROR
|
|
chash = _content_binding(item)
|
|
try:
|
|
if not signature:
|
|
raise SignatureError("signature required")
|
|
if not verify_signature(
|
|
"content", deployment_id, item_id, chash, signature, pubkey
|
|
):
|
|
raise SignatureError("signature invalid for this content/deployment")
|
|
except SignatureError as exc:
|
|
audit(
|
|
sess,
|
|
actor=actor,
|
|
action="approve_content",
|
|
result="error",
|
|
detail={"reason": str(exc), "content_hash": chash},
|
|
)
|
|
return GATE_ERROR
|
|
item.status = ContentStatus.APPROVED
|
|
item.approved_hash = _content_hash(item.caption)
|
|
item.operator_signature = signature # FULL signature
|
|
audit(
|
|
sess,
|
|
actor=actor,
|
|
action="approve_content",
|
|
detail={
|
|
"item": item.id,
|
|
"channel": item.channel,
|
|
"hash": item.approved_hash,
|
|
"signature": signature,
|
|
},
|
|
)
|
|
return 0
|
|
|
|
|
|
def cfg_from(sess):
|
|
"""Config from env (publisher commands are single-invocation CLIs)."""
|
|
from cayceops.config import load
|
|
|
|
return load()
|
|
|
|
|
|
# --- image rendering ----------------------------------------------------------------
|
|
|
|
|
|
def _render_image(item: ContentItem, cfg: Config) -> str | None:
|
|
"""Render the brand image for a nostr item (per its theme)."""
|
|
brand = "hc" if "hardcap" in (item.link or "") else "bh"
|
|
title = item.theme.replace("-", " ").upper().split()
|
|
lines = [" ".join(title[:2])] if title else [item.theme]
|
|
out = images.render_brand_image(
|
|
brand=brand,
|
|
kicker="C A Y C E",
|
|
lines=lines,
|
|
tagline=[(item.link or "")[:70]],
|
|
url=(item.link or "")[:60],
|
|
out_path=f"{cfg.media_dir}/item-{item.id}.jpg",
|
|
)
|
|
item.media_path = out
|
|
return out
|
|
|
|
|
|
# --- publishing ----------------------------------------------------------------------
|
|
|
|
|
|
def _publish_nostr(item: ContentItem, cfg: Config, sess, sender=None) -> str:
|
|
if not cfg.nostr_private_key or not cfg.nostr_relays:
|
|
raise RuntimeError(
|
|
"nostr channel requires CAYCE_OPS_NOSTR_PRIVATE_KEY and CAYCE_OPS_NOSTR_RELAYS"
|
|
)
|
|
secret = nostr.normalize_key(cfg.nostr_private_key)
|
|
event = nostr.build_event(secret, item.caption, tags=[["t", item.theme]])
|
|
if not nostr.verify_event(event): # pragma: no cover — build/verify symmetry
|
|
raise RuntimeError("event failed self-verification before publish")
|
|
results = (sender or nostr.publish)(cfg.nostr_relays, event)
|
|
audit(
|
|
sess,
|
|
actor="daemon:publisher",
|
|
action="nostr_relay_results",
|
|
detail={"event": event["id"], "relays": results},
|
|
)
|
|
return event["id"]
|
|
|
|
|
|
def _publish_due(cfg: Config, sess, nostr_sender=None) -> dict:
|
|
"""Publish approved items that are due. Non-nostr channels are skipped
|
|
unless their adapters are explicitly enabled (default: skipped, audited)."""
|
|
stats = {"published": 0, "skipped": 0, "failed": 0, "tampered": 0}
|
|
due = (
|
|
sess.query(ContentItem)
|
|
.filter(ContentItem.status == ContentStatus.APPROVED)
|
|
.all()
|
|
)
|
|
now = datetime.now(UTC)
|
|
for item in due:
|
|
if item.scheduled_for and item.scheduled_for > now:
|
|
continue # not due yet
|
|
|
|
# run-time signature gate (audit round 2, 1d): valid operator
|
|
# signature required for CURRENT channel/caption/link + deployment.
|
|
# Unsigned rows (raw SQL approval) or edited ones revert to DRAFT.
|
|
from cayceops.signing import verify_signature
|
|
|
|
try:
|
|
deployment_id, pubkey = _signing(cfg)
|
|
except Exception as exc: # noqa: BLE001 — ConfigError
|
|
audit(
|
|
sess,
|
|
actor="daemon:publisher",
|
|
action="publish_refused",
|
|
result="error",
|
|
detail={"item": item.id, "reason": str(exc)},
|
|
)
|
|
stats["failed"] += 1
|
|
continue
|
|
if not verify_signature(
|
|
"content",
|
|
deployment_id,
|
|
item.id,
|
|
_content_binding(item),
|
|
item.operator_signature,
|
|
pubkey,
|
|
):
|
|
item.status = ContentStatus.DRAFT # back to pre-approval
|
|
audit(
|
|
sess,
|
|
actor="daemon:publisher",
|
|
action="publish_refused",
|
|
result="error",
|
|
detail={
|
|
"item": item.id,
|
|
"reason": "no valid operator signature for current content/deployment",
|
|
},
|
|
)
|
|
stats["tampered"] += 1
|
|
continue
|
|
|
|
try:
|
|
if item.channel == "nostr":
|
|
item.post_ref = _publish_nostr(item, cfg, sess, sender=nostr_sender)
|
|
else:
|
|
# disabled channel is TERMINAL (audit finding: this used to
|
|
# write a skip audit row every 5 minutes, forever)
|
|
item.status = ContentStatus.SKIPPED
|
|
item.error = f"{item.channel} adapter disabled by default"
|
|
audit(
|
|
sess,
|
|
actor="daemon:publisher",
|
|
action="publish_skipped_terminal",
|
|
detail={"item": item.id, "channel": item.channel},
|
|
)
|
|
stats["skipped"] += 1
|
|
continue
|
|
except Exception as exc: # noqa: BLE001 — publish boundary: audit and continue
|
|
item.attempts += 1
|
|
item.error = str(exc)[:300]
|
|
if item.attempts >= cfg.max_attempts:
|
|
# attempt budget exhausted -> terminal (no infinite retries)
|
|
item.status = ContentStatus.SKIPPED
|
|
audit(
|
|
sess,
|
|
actor="daemon:publisher",
|
|
action="publish_abandoned",
|
|
result="error",
|
|
detail={"item": item.id, "attempts": item.attempts},
|
|
)
|
|
else:
|
|
audit(
|
|
sess,
|
|
actor="daemon:publisher",
|
|
action="publish_failed",
|
|
result="error",
|
|
detail={"item": item.id, "error": item.error, "attempt": item.attempts},
|
|
)
|
|
stats["failed"] += 1
|
|
continue
|
|
item.status = ContentStatus.PUBLISHED
|
|
item.published_at = datetime.now(UTC)
|
|
audit(
|
|
sess,
|
|
actor="daemon:publisher",
|
|
action="publish_content",
|
|
detail={"item": item.id, "channel": item.channel, "ref": item.post_ref},
|
|
)
|
|
stats["published"] += 1
|
|
return stats
|
|
|
|
|
|
# --- CLI wrappers ---------------------------------------------------------------------
|
|
|
|
|
|
def plan(cfg: Config | None = None) -> int:
|
|
from cayceops.config import load
|
|
from cayceops.db import engine
|
|
|
|
cfg = cfg or load()
|
|
try:
|
|
with session_scope() as sess:
|
|
run_row = DaemonRun(daemon="publisher")
|
|
sess.add(run_row)
|
|
sess.flush()
|
|
stats = _plan(cfg, sess)
|
|
run_row.status = "ok"
|
|
run_row.detail = stats
|
|
run_row.finished_at = datetime.now(UTC)
|
|
return 0
|
|
finally:
|
|
engine().dispose()
|
|
|
|
|
|
def publish_due(cfg: Config | None = None) -> int:
|
|
from cayceops.config import load
|
|
from cayceops.db import engine
|
|
|
|
cfg = cfg or load()
|
|
try:
|
|
with session_scope() as sess:
|
|
run_row = DaemonRun(daemon="publisher")
|
|
sess.add(run_row)
|
|
sess.flush()
|
|
try:
|
|
_signing(cfg) # no degraded mode
|
|
except Exception as exc: # noqa: BLE001 — ConfigError
|
|
run_row.status = "error"
|
|
run_row.detail = {"refused": str(exc)[:200]}
|
|
run_row.finished_at = datetime.now(UTC)
|
|
return 2
|
|
stats = _publish_due(cfg, sess)
|
|
run_row.status = "ok"
|
|
run_row.detail = stats
|
|
run_row.finished_at = datetime.now(UTC)
|
|
return 0
|
|
finally:
|
|
engine().dispose()
|
|
|
|
|
|
def make_image(item_id: int, cfg: Config | None = None) -> int:
|
|
from cayceops.config import load
|
|
from cayceops.db import engine
|
|
|
|
cfg = cfg or load()
|
|
try:
|
|
with session_scope() as sess:
|
|
item = sess.get(ContentItem, item_id)
|
|
if item is None:
|
|
return 1
|
|
print(_render_image(item, cfg))
|
|
return 0
|
|
finally:
|
|
engine().dispose()
|
|
|
|
|
|
def approve(item_id: int, actor: str, signature: str | None = None) -> int:
|
|
from cayceops.db import engine
|
|
|
|
try:
|
|
with session_scope() as sess:
|
|
return _approve(item_id, actor, sess, signature)
|
|
finally:
|
|
engine().dispose()
|