Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88b2ad2980 | ||
|
|
34ee25b32f | ||
|
|
87a2829b8a | ||
|
|
f618574043 | ||
|
|
058126b80d | ||
|
|
e06cb85ce8 | ||
|
|
b81f7f3625 | ||
|
|
8e59e1d4da | ||
|
|
ab301039ba | ||
|
|
b1dd5a4f98 | ||
|
|
df36126617 | ||
|
|
161fdfa8d1 | ||
|
|
d7156358f6 | ||
|
|
508371184d | ||
|
|
fc2dcd4fca | ||
|
|
121183ac83 | ||
|
|
b87a51afc0 | ||
|
|
d527164434 | ||
|
|
1c4ba234d0 | ||
|
|
64f89df289 | ||
|
|
f5b026070b | ||
|
|
60300a2f1a | ||
|
|
09fe9199b3 | ||
|
|
029e4f77d7 | ||
|
|
0d24309433 | ||
|
|
53ea15ee51 | ||
|
|
a2de419467 | ||
|
|
13db3996d9 | ||
|
|
aba42a4210 | ||
|
|
3bde785261 | ||
|
|
c855f01da5 | ||
|
|
56b35d3a63 | ||
|
|
ffe425320d | ||
|
|
d77409148c | ||
|
|
903c591a06 | ||
|
|
40453aadb4 | ||
|
|
61d5cfd67d | ||
|
|
c9bd4411e8 | ||
|
|
90e6cfcc19 | ||
|
|
2da5f3ff24 | ||
|
|
2423cd10b8 | ||
|
|
d500d88870 | ||
|
|
a282aedb94 | ||
|
|
abbaf7e4e2 | ||
|
|
6d77b3eff6 | ||
|
|
3f07362b7a | ||
|
|
f61372e28e | ||
|
|
25c7db3a31 | ||
|
|
aabd6bafc7 | ||
|
|
15072a0cd2 | ||
|
|
5b259bc663 | ||
|
|
1b2c3cc1cf |
@@ -21,6 +21,9 @@ CAYCE_OPS_AUTONOMOUS_KINDS=
|
||||
CAYCE_OPS_AUTONOMOUS_DAILY_CAP=0
|
||||
CAYCE_OPS_MAX_ATTEMPTS=2
|
||||
CAYCE_OPS_ENABLE_HTTP_ACTIONS=false
|
||||
# when set (64-char hex x-only pubkey), executor approve REQUIRES a BIP-340
|
||||
# signature over sha256("cayce-ops:approve:<id>") made by the operator key
|
||||
CAYCE_OPS_OPERATOR_PUBKEY=
|
||||
|
||||
# --- mail (IMAP read / SMTP send-approved-drafts-only) ---
|
||||
CAYCE_OPS_IMAP_HOST=imap.example.org
|
||||
|
||||
+31
-3
@@ -8,6 +8,21 @@ on:
|
||||
jobs:
|
||||
lint-test-leaks:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: cayce
|
||||
POSTGRES_PASSWORD: ci-only-not-a-secret
|
||||
POSTGRES_DB: cayce_ops
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U cayce -d cayce_ops"
|
||||
--health-interval 5s --health-timeout 3s --health-retries 10
|
||||
env:
|
||||
CAYCE_OPS_DATABASE_URL: postgresql+psycopg://cayce:ci-only-not-a-secret@127.0.0.1:5432/cayce_ops
|
||||
PGPASSWORD: ci-only-not-a-secret
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -23,11 +38,24 @@ jobs:
|
||||
- name: Lint (ruff)
|
||||
run: uv run ruff check .
|
||||
|
||||
- name: Migrations smoke test (SQLite)
|
||||
- name: Migrations upgrade/downgrade round-trip (Postgres 16)
|
||||
run: |
|
||||
uv run alembic upgrade head
|
||||
uv run alembic downgrade 0001
|
||||
uv run alembic upgrade head
|
||||
uv run alembic downgrade base
|
||||
uv run alembic upgrade head
|
||||
|
||||
- name: Alembic smoke (SQLite)
|
||||
run: uv run alembic upgrade head
|
||||
|
||||
- name: Tests (pytest)
|
||||
run: uv run pytest
|
||||
- name: Unit tests (pytest)
|
||||
run: uv run pytest -m "not integration"
|
||||
|
||||
- name: Integration tests — roles.sql + daemon cycles (Postgres 16)
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y postgresql-client
|
||||
uv run pytest -m integration -v
|
||||
|
||||
- name: Secret scan (gitleaks, full history)
|
||||
run: |
|
||||
|
||||
@@ -8,6 +8,21 @@ on:
|
||||
jobs:
|
||||
lint-test-leaks:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: cayce
|
||||
POSTGRES_PASSWORD: ci-only-not-a-secret
|
||||
POSTGRES_DB: cayce_ops
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U cayce -d cayce_ops"
|
||||
--health-interval 5s --health-timeout 3s --health-retries 10
|
||||
env:
|
||||
CAYCE_OPS_DATABASE_URL: postgresql+psycopg://cayce:ci-only-not-a-secret@127.0.0.1:5432/cayce_ops
|
||||
PGPASSWORD: ci-only-not-a-secret
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -23,11 +38,24 @@ jobs:
|
||||
- name: Lint (ruff)
|
||||
run: uv run ruff check .
|
||||
|
||||
- name: Migrations smoke test (SQLite)
|
||||
- name: Migrations upgrade/downgrade round-trip (Postgres 16)
|
||||
run: |
|
||||
uv run alembic upgrade head
|
||||
uv run alembic downgrade 0001
|
||||
uv run alembic upgrade head
|
||||
uv run alembic downgrade base
|
||||
uv run alembic upgrade head
|
||||
|
||||
- name: Alembic smoke (SQLite)
|
||||
run: uv run alembic upgrade head
|
||||
|
||||
- name: Tests (pytest)
|
||||
run: uv run pytest
|
||||
- name: Unit tests (pytest)
|
||||
run: uv run pytest -m "not integration"
|
||||
|
||||
- name: Integration tests — roles.sql + daemon cycles (Postgres 16)
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y postgresql-client
|
||||
uv run pytest -m integration -v
|
||||
|
||||
- name: Secret scan (gitleaks, full history)
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
# AUDIT — cayce-ops
|
||||
|
||||
Audited commit: **d500d88** — merge of PR #7 on `main`.
|
||||
|
||||
> Note for the auditor: PRs #1-#6 merged with E501 lint findings present
|
||||
> (per-branch gate runs were misread). PR #7 is a lint-only cleanup
|
||||
> (line wrapping, F841/F821/E731 fixes, no behavior change) that restores
|
||||
> a fully green gate. The honest trail is kept here on purpose.
|
||||
> Re-run `make check` at d500d88 to verify: ruff 0 · 44 tests · no leaks.
|
||||
Gate that commit passed: `make check` — ruff (0 findings), 44 tests green,
|
||||
alembic smoke on sqlite, gitleaks full history (no leaks).
|
||||
|
||||
## 1. Component map and data flow
|
||||
|
||||
```
|
||||
CAYCE_OPS_SCANNER_SOURCES (external URLs)
|
||||
│ read-only fetch
|
||||
▼
|
||||
┌──────────┐ proposals(status=proposed) ┌──────────┐
|
||||
│ scanner │─────────────────────────────▶│ DB │
|
||||
└──────────┘ │ (shared) │
|
||||
│ │ audit_log (every write)
|
||||
┌──────────┐ approved → running → │ │◀────── actor / action / detail /
|
||||
│ executor │◀──── done|failed ───────────│ │ result on every transition
|
||||
└──────────┘ plugins by kind └──────────┘
|
||||
│
|
||||
┌──────────┐ IMAP inbox ───────────────▶│ mail_messages,
|
||||
│ maild │ drafts (gated) │ proposals(detail)
|
||||
└──────────┘ SMTP only draft=approved │
|
||||
│
|
||||
┌──────────┐ content_items ────────────▶│ │──▶ relay (nostr) / Meta (opt-in)
|
||||
│publisher │ draft→approved→published └──────────┘
|
||||
└──────────┘
|
||||
┌──────────┐ reads daemon_runs, audit_log, counts
|
||||
│ monitor │────────────────────────────────────▶ status.html (static)
|
||||
└──────────┘
|
||||
```
|
||||
|
||||
Shared schema (`cayceops/models.py`, migration `alembic/versions/0001_initial.py`):
|
||||
`proposals`, `audit_log`, `mail_messages`, `content_items`, `daemon_runs`.
|
||||
|
||||
## 2. Every external call (where, what it sends, what triggers it)
|
||||
|
||||
| # | Call | Code | Sends | Trigger |
|
||||
|---|------|------|-------|---------|
|
||||
| 1 | HTTP GET to scanner sources | `cayceops/components/scanner/scanner.py` → `_fetch` (urllib) | nothing but the request (+ `CAYCE_OPS_SCANNER_USER_AGENT` header) to URLs **configured by the operator** | `cayceops scanner run` (cron/systemd timer) |
|
||||
| 2 | IMAP fetch (IMAP4_SSL, UNSEEN) | `cayceops/components/maild/maild.py` → `connect_imap`, `_fetch_unread` | credentials from env; reads inbox | `cayceops maild poll` |
|
||||
| 3 | SMTP send | `maild.py` → `_smtp_send` (smtplib, STARTTLS) | a reply **draft a human approved** to the original sender's bare address | `cayceops maild send-approved`, and only rows with `draft_status=approved` |
|
||||
| 4 | LLM chat completion (openai-compatible) | `cayceops/llm.py` → `OpenAICompatProvider.complete` (urllib) | the email text (truncated 4000 chars) + system prompt; `Authorization: Bearer <env key>` | `maild poll` **only if** `CAYCE_OPS_LLM_PROVIDER != null` (default is null → never) |
|
||||
| 5 | Nostr relay publish (websocket) | `cayceops/components/publisher/nostr.py` → `relay_publish`/`publish` | a signed NIP-01 event whose content is the **human-approved** caption | `cayceops publisher publish-due`, channel=nostr, only if key+relays configured |
|
||||
| 6 | Meta Graph API | `cayceops/components/publisher/adapters.py` | caption (+optional hosted image URL) + page token | **refuses to run** unless `CAYCE_OPS_ENABLE_FACEBOOK/INSTAGRAM=true` (default false). Currently unreachable from `publish_due`. |
|
||||
|
||||
No other network code exists. Grep guards: `urllib.request`, `imaplib`, `smtplib`,
|
||||
`websockets` appear only in the files above (verified by review; CI could add a
|
||||
lint rule — see *Known risks*).
|
||||
|
||||
## 3. What each component can do without a human / with AUTONOMOUS_MODE=true
|
||||
|
||||
| Component | Without any human (default) | With `CAYCE_OPS_AUTONOMOUS_MODE=true` |
|
||||
|---|---|---|
|
||||
| scanner | fetch sources, file proposals, write audit | identical (scanner never executes) |
|
||||
| executor | nothing executes by itself; it acts only on rows a human set to `approved` (via `executor approve --actor …`) | **one added path**: auto-approves `proposed` items whose `kind` ∈ `CAYCE_OPS_AUTONOMOUS_KINDS`, capped at `CAYCE_OPS_AUTONOMOUS_DAILY_CAP`/day, actor `auto:policy`, fully audited. Then executes those. |
|
||||
| maild | polls IMAP, classifies, writes drafts. **Never sends.** | identical — autonomy does not touch mail |
|
||||
| publisher | plans drafts; publishes nothing | identical — publishing always requires `publisher approve --actor …`; autonomy has no effect |
|
||||
| monitor | reads and renders | identical |
|
||||
|
||||
Explicitly impossible in the current code: sending an unapproved mail draft,
|
||||
publishing unapproved content, executing a non-approved proposal (the single
|
||||
exception is the audited `auto:policy` path above), disabling the audit write.
|
||||
|
||||
## 4. Environment variables and permissions per component
|
||||
|
||||
All config is env-only (`cayceops/config.py`), prefix `CAYCE_OPS_`. Full list
|
||||
with placeholders: `.env.example`.
|
||||
|
||||
| Component | Requires | Notes |
|
||||
|---|---|---|
|
||||
| core | `DATABASE_URL` (postgres or sqlite), `LOG_LEVEL`, `STATE_DIR`, `MEDIA_DIR` | DB user needs SELECT/INSERT/UPDATE on the five tables |
|
||||
| scanner | `SCANNER_SOURCES` (JSON), `SCANNER_KEYWORDS`, `SCANNER_USER_AGENT` | outbound GET only |
|
||||
| executor | `AUTONOMOUS_MODE`, `AUTONOMOUS_KINDS`, `AUTONOMOUS_DAILY_CAP`, `MAX_ATTEMPTS`, `ENABLE_HTTP_ACTIONS` | `http_post` plugin stays inert unless enabled |
|
||||
| maild | `IMAP_HOST/PORT/USER/PASSWORD/MAILBOX`, `SMTP_HOST/PORT/USER/PASSWORD`, `MAIL_FROM`, `LLM_PROVIDER/MODEL/BASE_URL/API_KEY/REVIEW_THRESHOLD` | IMAP user is read+mark-seen; SMTP login can send only as `MAIL_FROM` |
|
||||
| publisher | `NOSTR_PRIVATE_KEY` (32-byte hex), `NOSTR_RELAYS`, `ENABLE_FACEBOOK`, `FACEBOOK_PAGE_TOKEN`, `ENABLE_INSTAGRAM`, `INSTAGRAM_USER_ID` | nostr key = signing authority; treat as secret |
|
||||
| monitor | `MONITOR_OUTPUT` (writable path) | needs read on DB, write on output only |
|
||||
|
||||
Filesystem permissions: default deployment is the `cayce` user owning
|
||||
`/srv/cayce-ops` with systemd hardening (`NoNewPrivileges`, `ProtectSystem=strict`,
|
||||
`ProtectHome=yes`) — see `deploy/systemd/cayce-ops@.service`.
|
||||
|
||||
## 5. Dependencies (pinned in `pyproject.toml` + `uv.lock`)
|
||||
|
||||
| Package | Version | Why |
|
||||
|---|---|---|
|
||||
| sqlalchemy | 2.0.44 | ORM + engine for the shared schema |
|
||||
| alembic | 1.17.0 | production migrations |
|
||||
| typer | 0.20.0 | CLI (all five daemons are CLI commands) |
|
||||
| psycopg[binary] | 3.2.13 | Postgres driver (production stack) |
|
||||
| pillow | 12.0.0 | brand image templates (publisher) |
|
||||
| coincurve | 21.0.0 | libsecp256k1 binding: NIP-01 event signing (publisher) |
|
||||
| websockets | 15.0.1 | nostr relay publish (sync client) |
|
||||
| pytest | 9.0.2 (dev) | test suite |
|
||||
| ruff | 0.15.1 (dev) | lint |
|
||||
|
||||
stdlib doing heavy lifting: `imaplib`/`smtplib`/`email` (maild), `urllib` (scanner,
|
||||
LLM, Meta adapters), `xml.etree`/`html.parser` (scanner), `hashlib`/`json` (nostr ids).
|
||||
|
||||
## 6. Known risks and open items
|
||||
|
||||
1. **No live Gitea Actions runner on the current host** (DECISIONS D6). CI files
|
||||
ship for GitHub/Gitea Actions, and the same gate runs locally pre-push
|
||||
(`scripts/pre-push`), but until a runner exists there is no third-party
|
||||
enforcement of the gate on every push.
|
||||
2. **`auto:policy` path is real autonomy** — bounded (kind allowlist + daily cap +
|
||||
audit) but it does execute without a human in the loop when enabled. The cap
|
||||
counter is DB-based; wiping `audit_log` resets it.Mitigation: only the
|
||||
operator can set the env flags, and the audit log is append-only by convention.
|
||||
3. **Executor plugins are in-process Python** (D9): no sandboxing. A malicious
|
||||
plugin = arbitrary code. Mitigation: plugins are repo code, reviewed in PRs;
|
||||
the registry starts with `log_action` fallbacks only.
|
||||
4. **Mail classification is heuristic** (rules; LLM optional). Conservative by
|
||||
design (low confidence → `needs_review`, unthreaded approvals → review), but
|
||||
a sophisticated phishing email could still be misclassified `other`. Human
|
||||
gate on sends bounds the damage to drafting wrong replies (never sending them).
|
||||
5. **Nostr relays are plaintext-config endpoints**: a hostile relay sees the
|
||||
note content (public by design) and could replay it; event ids make duplication
|
||||
detectable. No NIP-05, no retries across relay pools yet.
|
||||
6. **`detail` JSON columns are schemaless** — writers evolve payloads without
|
||||
migrations. Monitor intentionally renders none of it.
|
||||
7. **gitleaks allowlist covers `tests/`** (D7): a real secret pasted under
|
||||
`tests/` would not trip the scanner. Compensating control: PR review; no
|
||||
fixture reads the environment.
|
||||
8. **Open:** provision an act_runner for Gitea Actions; wire `posts_log`-style
|
||||
engagement back into publisher planning; add NIP-05 identifier; retries for
|
||||
relay publish.
|
||||
|
||||
## 7. Running tests and the local stack
|
||||
|
||||
```bash
|
||||
uv sync --frozen # python 3.12 + locked deps
|
||||
uv run pytest # 44 tests, sqlite, no network
|
||||
make check # lint + tests + alembic smoke + gitleaks (pre-push gate)
|
||||
|
||||
# full local stack (five daemons + postgres 16):
|
||||
cp .env.example .env # fill placeholders; nothing real required for db-only
|
||||
docker compose up -d db
|
||||
uv run alembic upgrade head
|
||||
docker compose up -d # or run each: uv run cayceops {scanner,executor,maild,publisher,monitor} …
|
||||
|
||||
# production path (VPS):
|
||||
# see deploy/systemd/README.md (hardened units + timers)
|
||||
```
|
||||
|
||||
## Audited commit
|
||||
|
||||
- Repository: `cayce/cayce-ops` (branch `main`)
|
||||
- **Audited: d500d888701c3f1cd3305d098a448c3e20a8ad99** (merge of PR #7)
|
||||
- Gate at that commit: ruff clean (0 findings) · 44/44 tests · alembic 0001
|
||||
smoke OK · gitleaks `--all` no leaks · `make check` exit 0.
|
||||
|
||||
---
|
||||
|
||||
## Response to external audit — 2026-10-20
|
||||
|
||||
External audit verdict reproduced our gate and found 5 critical + 6 medium
|
||||
issues. All criticals and all mediums are fixed in **c9bd441** (PR #9):
|
||||
|
||||
| # | Finding | Fix |
|
||||
|---|---|---|
|
||||
| C1 | approval forgeable with any `--actor` | hash-chained `audit_log` (`verify_audit_chain`); optional-required operator BIP-340 signature (`CAYCE_OPS_OPERATOR_PUBKEY`); `deploy/roles.sql` least-privilege DB roles |
|
||||
| C2 | mail threading dead (no OUTBOUND rows, inbound id overwritten, no first-contact) | sends create OUTBOUND rows (own Message-ID + In-Reply-To); inbound id untouched; `maild compose`; chain fallback in `_match_proposal` |
|
||||
| C3 | duplicate sends on SMTP failure | per-message transactions; failures audited (`send_failed`), row stays queued |
|
||||
| C4 | negated approvals classified as approvals; LLM prompt `.format()` KeyError | negation rules first (0.95 REJECTION); prompt braces escaped |
|
||||
| C5 | systemd units ran nonexistent `run` subcommands | `run` added to all five daemons; `publisher plan` timer documented |
|
||||
| M1-M6 | gate not self-installed; approved text unbound; `risk_score` misnamed/ordered; postgres 0.0.0.0; IMAP seen-before-commit; publisher noise/infinite retries | `make hooks`; `approved_hash` bindings; `match_score` (migration 0002) + relevance-desc; `127.0.0.1` bind; `\Seen` post-commit; `SKIPPED` terminal + attempt budget |
|
||||
|
||||
New audited commit: **c9bd441**. Gate at that commit: ruff 0 · 56/56 tests ·
|
||||
alembic 0001→0002 · gitleaks `--all` clean · `make check` exit 0.
|
||||
Still open (declared): apply `roles.sql` on production, provision CI runner.
|
||||
|
||||
---
|
||||
|
||||
## Response to audit round 2 — 2026-10-20 (PR #10)
|
||||
|
||||
Round-2 audit closed findings 2, 3, 5 and the mediums; the remaining points
|
||||
were re-implemented in PR #10, one commit per point:
|
||||
|
||||
| Punto | Hallazgo | Commit | Test que lo prueba |
|
||||
|---|---|---|---|
|
||||
| 1a/1b/1c | firmas obligatorias atadas a contenido+deployment, completas en fila y audit | `40453aa` audit-1 | `test_signed_approval_mandatory_and_content_bound` |
|
||||
| 1d | verificación al ejecutar/enviar/publicar; SQL-directo se rechaza y revierte | `40453aa` audit-1 | `test_sql_approved_row_is_refused_at_execution`, `test_unsigned_sql_approved_draft_is_refused`, `test_unsigned_sql_approved_item_not_published` |
|
||||
| 1d | to_addr cambiado tras aprobar bloquea envío | `40453aa` audit-1 | `test_changed_to_addr_after_approval_blocks_send` |
|
||||
| 1d | firma de otro deployment/contenido rechazada | `40453aa` audit-1 | `test_foreign_deployment_signature_refused` (executor+maild), `test_foreign_deployment_signature_refused` (publisher) |
|
||||
| 1e | sin ramas `if approved_hash and ...`: binding obligatorio | `40453aa` audit-1 | cubierto por los tests de firma obligatoria |
|
||||
| 1f | fallos de firma auditados desde el CLI | `40453aa` audit-1 | `test_approve_fails_audited_from_cli_wrapper` |
|
||||
| 2 | clasificador: negación en el mail veta approval → needs_review | `903c591` audit-2 | `test_negated_approvals_never_classify_approval` (las 6 oraciones mandadas + 4 extra) |
|
||||
| 3 | roles.sql: mínimo privilegio por daemon, trigger de aprobación, cayce_approver único, sin contraseñas literales | `d774091` audit-3 | `tests/integration/test_pg_roles.py` (PG real, ciclo completo por rol) |
|
||||
| 4 | escrituras serializadas; génesis documentado; anclaje Nostr diario | `ffe4253` audit-4 | `test_concurrent_writers_keep_chain_verifiable`; legacy-genesis en `verify_audit_chain`; `test_anchor_signs_and_logs_once_per_day` |
|
||||
| 5 | CLI de envío usa el código testeado; fallos auditados + daemon_runs; exit ≠ 0 | `56b35d3` audit-5 (nota: refactor dentro de audit-1, commit marcador) | `test_send_failures_audited_and_exit_code` |
|
||||
| 6 | migraciones upgrade/downgrade contra Postgres 16 en CI | `c855f01` audit-6 | job "Migrations upgrade/downgrade round-trip (Postgres 16)" en ci.yml; ejecutado localmente contra Postgres 18.6 (reportado en PR) |
|
||||
| 7 | unidad systemd para `publisher plan` | `3bde785` audit-7 | `deploy/systemd/cayce-ops-plan.{service,timer}` (diaria) |
|
||||
|
||||
Gate en el commit auditado: ruff 0 · 65 unit tests + 1 integración PG ·
|
||||
alembic 0001→0003 con round-trip PG · gitleaks `--all` limpio · `make check` exit 0.
|
||||
|
||||
Declarados abiertos (no resueltos aquí): aplicar `roles.sql` en producción y
|
||||
provisionar el runner de CI — decisión y ejecución del operador.
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: check lint test migrate-check leaks sync
|
||||
.PHONY: check lint test migrate-check leaks sync hooks
|
||||
|
||||
sync: ## install locked deps (creates .venv)
|
||||
uv sync --frozen
|
||||
@@ -17,5 +17,9 @@ migrate-check: ## smoke the alembic chain on a scratch sqlite db
|
||||
leaks: ## gitleaks over the full history
|
||||
gitleaks detect --source . --log-opts="--all" -v
|
||||
|
||||
hooks: ## install the pre-push gate as a git hook (part of setup)
|
||||
git config core.hooksPath scripts/githooks
|
||||
@echo "pre-push gate installed (scripts/githooks/pre-push)"
|
||||
|
||||
check: lint test migrate-check leaks ## the pre-push gate — must be green before every push
|
||||
@echo "check: OK"
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
# Cayce is an agent-operated project run by Gustavo.
|
||||
# Cayce Ops
|
||||
|
||||
Cayce Ops is the autonomous operations stack behind Cayce — an agent that runs
|
||||
[bitcoinhotels.org](https://bitcoinhotels.org) (Bitcoin-native travel guides)
|
||||
and [hardcap.news](https://hardcap.news) (Bitcoin security intelligence).
|
||||
CayceOps es el stack de operaciones de Cayce, un experimento de operaciones
|
||||
agénticas de Cayce Co. Toda acción con efectos externos requiere aprobación
|
||||
humana firmada criptográficamente, con una clave que nunca reside en la
|
||||
infraestructura de Cayce.
|
||||
|
||||
Cayce operates [bitcoinhotels.org](https://bitcoinhotels.org)
|
||||
(Bitcoin-native travel guides) and [hardcap.news](https://hardcap.news)
|
||||
(Bitcoin security intelligence).
|
||||
|
||||
Five decoupled components share one Postgres database. Each is a CLI command,
|
||||
driven by cron/systemd timers. Nothing runs without an audit trail.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""audit hardening migration — minimal standalone mirror of 0002.
|
||||
|
||||
Revision ID: 0002
|
||||
Revises: 0001
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0002"
|
||||
down_revision = "0001"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
OLD_CONTENT_STATUSES = ("DRAFT", "APPROVED", "PUBLISHED")
|
||||
NEW_CONTENT_STATUSES = ("DRAFT", "APPROVED", "PUBLISHED", "SKIPPED")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("audit_log") as batch:
|
||||
batch.add_column(sa.Column("prev_hash", sa.String(length=64), nullable=True))
|
||||
batch.add_column(sa.Column("entry_hash", sa.String(length=64), nullable=True))
|
||||
batch.create_index("ix_audit_log_entry_hash", ["entry_hash"])
|
||||
|
||||
with op.batch_alter_table("proposals") as batch:
|
||||
batch.alter_column("risk_score", new_column_name="match_score")
|
||||
|
||||
with op.batch_alter_table("mail_messages") as batch:
|
||||
batch.add_column(sa.Column("approved_hash", sa.String(length=64), nullable=True))
|
||||
batch.add_column(sa.Column("approved_by", sa.String(length=128), nullable=True))
|
||||
|
||||
if op.get_bind().dialect.name == "postgresql":
|
||||
op.execute("ALTER TYPE contentstatus ADD VALUE IF NOT EXISTS 'SKIPPED'")
|
||||
with op.batch_alter_table("content_items") as batch:
|
||||
batch.alter_column(
|
||||
"status",
|
||||
existing_type=sa.Enum(*OLD_CONTENT_STATUSES, name="contentstatus"),
|
||||
type_=sa.Enum(*NEW_CONTENT_STATUSES, name="contentstatus"),
|
||||
existing_nullable=False,
|
||||
)
|
||||
batch.add_column(sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"))
|
||||
batch.add_column(sa.Column("approved_hash", sa.String(length=64), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("content_items") as batch:
|
||||
batch.drop_column("approved_hash")
|
||||
batch.drop_column("attempts")
|
||||
batch.alter_column(
|
||||
"status",
|
||||
existing_type=sa.Enum(*NEW_CONTENT_STATUSES, name="contentstatus"),
|
||||
type_=sa.Enum(*OLD_CONTENT_STATUSES, name="contentstatus"),
|
||||
existing_nullable=False,
|
||||
)
|
||||
|
||||
with op.batch_alter_table("mail_messages") as batch:
|
||||
batch.drop_column("approved_by")
|
||||
batch.drop_column("approved_hash")
|
||||
|
||||
with op.batch_alter_table("proposals") as batch:
|
||||
batch.alter_column("match_score", new_column_name="risk_score")
|
||||
|
||||
with op.batch_alter_table("audit_log") as batch:
|
||||
batch.drop_index("ix_audit_log_entry_hash")
|
||||
batch.drop_column("entry_hash")
|
||||
batch.drop_column("prev_hash")
|
||||
@@ -0,0 +1,42 @@
|
||||
"""approval hardening: mandatory operator signatures bound to content
|
||||
|
||||
Revision ID: 0003
|
||||
Revises: 0002
|
||||
Create Date: 2026-10-20
|
||||
|
||||
- proposals.operator_signature
|
||||
- mail_messages.operator_signature
|
||||
- content_items.operator_signature
|
||||
|
||||
Full signatures (128 hex chars) — never truncated. Existing NULL rows are
|
||||
refused at execution/send/publish time by the signing gate (no degraded mode).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0003"
|
||||
down_revision = "0002"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("proposals") as batch:
|
||||
batch.add_column(sa.Column("operator_signature", sa.Text(), nullable=True))
|
||||
with op.batch_alter_table("mail_messages") as batch:
|
||||
batch.add_column(sa.Column("operator_signature", sa.Text(), nullable=True))
|
||||
with op.batch_alter_table("content_items") as batch:
|
||||
batch.add_column(sa.Column("operator_signature", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("content_items") as batch:
|
||||
batch.drop_column("operator_signature")
|
||||
with op.batch_alter_table("mail_messages") as batch:
|
||||
batch.drop_column("operator_signature")
|
||||
with op.batch_alter_table("proposals") as batch:
|
||||
batch.drop_column("operator_signature")
|
||||
@@ -0,0 +1,89 @@
|
||||
"""audit_log append-only: reject UPDATE and DELETE (round-2 hardening)
|
||||
|
||||
Revision ID: 0004
|
||||
Revises: 0003
|
||||
Create Date: 2026-10-28
|
||||
|
||||
An audit trail that can be edited is not an audit trail. This migration
|
||||
installs, on every engine:
|
||||
|
||||
- SQLite: BEFORE UPDATE/DELETE triggers that RAISE(ABORT).
|
||||
- Postgres: BEFORE UPDATE/DELETE triggers that RAISE EXCEPTION.
|
||||
|
||||
The writer roles never had UPDATE/DELETE grants in roles.sql; this closes
|
||||
the hole for the table owner and for direct SQL connections.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0004"
|
||||
down_revision = "0003"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
SQLITE_TRIGGERS = [
|
||||
"""
|
||||
CREATE TRIGGER IF NOT EXISTS audit_log_no_update
|
||||
BEFORE UPDATE ON audit_log
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'cayce-ops: audit_log is append-only (UPDATE rejected)');
|
||||
END;
|
||||
""",
|
||||
"""
|
||||
CREATE TRIGGER IF NOT EXISTS audit_log_no_delete
|
||||
BEFORE DELETE ON audit_log
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'cayce-ops: audit_log is append-only (DELETE rejected)');
|
||||
END;
|
||||
""",
|
||||
]
|
||||
|
||||
PG_TRIGGERS = """
|
||||
CREATE OR REPLACE FUNCTION cayce_ops_audit_append_only() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'cayce-ops: audit_log is append-only (% rejected)', TG_OP;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
"""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name == "postgresql":
|
||||
op.execute(PG_TRIGGERS)
|
||||
op.execute(
|
||||
"DROP TRIGGER IF EXISTS audit_log_no_update ON audit_log;"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE TRIGGER audit_log_no_update BEFORE UPDATE ON audit_log "
|
||||
"FOR EACH ROW EXECUTE FUNCTION cayce_ops_audit_append_only();"
|
||||
)
|
||||
op.execute(
|
||||
"DROP TRIGGER IF EXISTS audit_log_no_delete ON audit_log;"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE TRIGGER audit_log_no_delete BEFORE DELETE ON audit_log "
|
||||
"FOR EACH ROW EXECUTE FUNCTION cayce_ops_audit_append_only();"
|
||||
)
|
||||
else:
|
||||
for ddl in SQLITE_TRIGGERS:
|
||||
bind.execute(sa_text(ddl))
|
||||
|
||||
|
||||
def sa_text(sql: str):
|
||||
import sqlalchemy as sa
|
||||
|
||||
return sa.text(sql)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name == "postgresql":
|
||||
op.execute("DROP TRIGGER IF EXISTS audit_log_no_update ON audit_log;")
|
||||
op.execute("DROP TRIGGER IF EXISTS audit_log_no_delete ON audit_log;")
|
||||
op.execute("DROP FUNCTION IF EXISTS cayce_ops_audit_append_only();")
|
||||
else:
|
||||
bind.execute(sa_text("DROP TRIGGER IF EXISTS audit_log_no_update;"))
|
||||
bind.execute(sa_text("DROP TRIGGER IF EXISTS audit_log_no_delete;"))
|
||||
+73
-10
@@ -71,10 +71,16 @@ def executor_run() -> None:
|
||||
|
||||
@executor_app.command("approve")
|
||||
def executor_approve(
|
||||
proposal_id: int, actor: str = typer.Option(..., help="Human approver id")
|
||||
proposal_id: int,
|
||||
actor: str = typer.Option(..., help="Human approver id"),
|
||||
signature: str = typer.Option(
|
||||
..., help="BIP-340 sig over the approval message (mandatory)"
|
||||
),
|
||||
) -> None:
|
||||
"""Approve a proposal (human action, audited)."""
|
||||
raise typer.Exit(_load_component("executor").approve(proposal_id, actor))
|
||||
"""Approve a proposal (human action, audited; signature mandatory)."""
|
||||
raise typer.Exit(
|
||||
_load_component("executor").approve(proposal_id, actor, signature=signature)
|
||||
)
|
||||
|
||||
|
||||
@executor_app.command("reject")
|
||||
@@ -95,17 +101,48 @@ def maild_poll() -> None:
|
||||
|
||||
|
||||
@maild_app.command("approve-draft")
|
||||
def maild_approve(msg_id: int, actor: str = typer.Option(...)) -> None:
|
||||
"""Approve a drafted reply so SMTP may send it."""
|
||||
raise typer.Exit(_load_component("maild").approve_draft(msg_id, actor))
|
||||
def maild_approve(
|
||||
msg_id: int,
|
||||
actor: str = typer.Option(...),
|
||||
signature: str = typer.Option(
|
||||
..., help="BIP-340 sig over the approval message (mandatory)"
|
||||
),
|
||||
) -> None:
|
||||
"""Approve a drafted reply so SMTP may send it (signature mandatory)."""
|
||||
raise typer.Exit(
|
||||
_load_component("maild").approve_draft(msg_id, actor, signature)
|
||||
)
|
||||
|
||||
|
||||
@maild_app.command("send-approved")
|
||||
def maild_send() -> None:
|
||||
"""Send all approved drafts via SMTP."""
|
||||
"""Send all approved drafts via SMTP (per-message commit)."""
|
||||
raise typer.Exit(_load_component("maild").send_approved())
|
||||
|
||||
|
||||
@maild_app.command("compose")
|
||||
def maild_compose(
|
||||
proposal_id: int,
|
||||
to_addr: str = typer.Option(...),
|
||||
subject: str = typer.Option(...),
|
||||
body: str = typer.Option(...),
|
||||
) -> None:
|
||||
"""Create the FIRST-CONTACT outbound draft for a proposal (drafted)."""
|
||||
raise typer.Exit(
|
||||
_load_component("maild").compose(proposal_id, to_addr, subject, body)
|
||||
)
|
||||
|
||||
|
||||
@maild_app.command("run")
|
||||
def maild_run() -> None:
|
||||
"""One daemon cycle: poll inbound, then send approved drafts."""
|
||||
comp = _load_component("maild")
|
||||
code = comp.poll()
|
||||
if code == 0:
|
||||
code = comp.send_approved()
|
||||
raise typer.Exit(code)
|
||||
|
||||
|
||||
# --- publisher ---------------------------------------------------------------
|
||||
publisher_app = typer.Typer(name="publisher", help="Themed content calendar")
|
||||
app.add_typer(publisher_app)
|
||||
@@ -124,9 +161,17 @@ def publisher_image(item_id: int) -> None:
|
||||
|
||||
|
||||
@publisher_app.command("approve")
|
||||
def publisher_approve(item_id: int, actor: str = typer.Option(...)) -> None:
|
||||
"""Approve a draft content item."""
|
||||
raise typer.Exit(_load_component("publisher").approve(item_id, actor))
|
||||
def publisher_approve(
|
||||
item_id: int,
|
||||
actor: str = typer.Option(...),
|
||||
signature: str = typer.Option(
|
||||
..., help="BIP-340 sig over the approval message (mandatory)"
|
||||
),
|
||||
) -> None:
|
||||
"""Approve a draft content item (signature mandatory)."""
|
||||
raise typer.Exit(
|
||||
_load_component("publisher").approve(item_id, actor, signature)
|
||||
)
|
||||
|
||||
|
||||
@publisher_app.command("publish-due")
|
||||
@@ -135,6 +180,12 @@ def publisher_publish() -> None:
|
||||
raise typer.Exit(_load_component("publisher").publish_due())
|
||||
|
||||
|
||||
@publisher_app.command("run")
|
||||
def publisher_run() -> None:
|
||||
"""One daemon cycle: publish due approved items (plan is cron'd separately)."""
|
||||
raise typer.Exit(_load_component("publisher").publish_due())
|
||||
|
||||
|
||||
# --- monitor -----------------------------------------------------------------
|
||||
monitor_app = typer.Typer(name="monitor", help="Static status page")
|
||||
app.add_typer(monitor_app)
|
||||
@@ -146,6 +197,18 @@ def monitor_build() -> None:
|
||||
raise typer.Exit(_load_component("monitor").build())
|
||||
|
||||
|
||||
@monitor_app.command("run")
|
||||
def monitor_run() -> None:
|
||||
"""One daemon cycle: build the status page."""
|
||||
raise typer.Exit(_load_component("monitor").build())
|
||||
|
||||
|
||||
@monitor_app.command("anchor")
|
||||
def monitor_anchor() -> None:
|
||||
"""Publish today's audit-chain anchor as a signed Nostr note (1/day)."""
|
||||
raise typer.Exit(_load_component("monitor").anchor())
|
||||
|
||||
|
||||
def main() -> None: # pragma: no cover
|
||||
app()
|
||||
|
||||
|
||||
@@ -29,12 +29,91 @@ from cayceops.models import AuditEntry, DaemonRun, Proposal, ProposalStatus, aud
|
||||
Actor = str
|
||||
|
||||
|
||||
def _load_cfg():
|
||||
from cayceops.config import load
|
||||
|
||||
return load()
|
||||
|
||||
|
||||
# --- human transitions -------------------------------------------------------
|
||||
|
||||
GATE_ERROR = 2 # missing/invalid configuration or signature
|
||||
|
||||
def approve(proposal_id: int, actor: Actor, cfg: Config | None = None, sess=None) -> int:
|
||||
"""Human approves a proposal. Audited with the operator's identity."""
|
||||
return _decide(proposal_id, actor, ProposalStatus.APPROVED, cfg, sess)
|
||||
|
||||
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 approve(
|
||||
proposal_id: int,
|
||||
actor: Actor,
|
||||
cfg: Config | None = None,
|
||||
sess=None,
|
||||
signature: str | None = None,
|
||||
) -> int:
|
||||
"""Human approves a proposal. Signature is MANDATORY and bound to the
|
||||
proposal's current content and this deployment (audit finding 1)."""
|
||||
from cayceops.config import ConfigError
|
||||
from cayceops.signing import (
|
||||
SignatureError,
|
||||
proposal_content_hash,
|
||||
verify_signature,
|
||||
)
|
||||
|
||||
cfg = cfg or _load_cfg()
|
||||
try:
|
||||
deployment_id, pubkey = _signing(cfg)
|
||||
except ConfigError as exc:
|
||||
if sess is not None:
|
||||
audit(
|
||||
sess,
|
||||
actor=actor,
|
||||
action="approve",
|
||||
proposal_id=proposal_id,
|
||||
result="error",
|
||||
detail={"reason": str(exc)},
|
||||
)
|
||||
return GATE_ERROR
|
||||
|
||||
proposal = sess.get(Proposal, proposal_id) if sess is not None else None
|
||||
if proposal is None:
|
||||
return 1
|
||||
chash = proposal_content_hash(
|
||||
proposal.kind, proposal.title, proposal.url, proposal.detail
|
||||
)
|
||||
try:
|
||||
if not signature:
|
||||
raise SignatureError("signature required")
|
||||
if not verify_signature(
|
||||
"proposal", deployment_id, proposal_id, chash, signature, pubkey
|
||||
):
|
||||
raise SignatureError("signature invalid for this content/deployment")
|
||||
except SignatureError as exc:
|
||||
audit(
|
||||
sess,
|
||||
actor=actor,
|
||||
action="approve",
|
||||
proposal_id=proposal_id,
|
||||
result="error",
|
||||
detail={"reason": str(exc), "content_hash": chash},
|
||||
)
|
||||
return GATE_ERROR
|
||||
|
||||
return _decide(proposal_id, actor, ProposalStatus.APPROVED, cfg, sess, signature)
|
||||
|
||||
|
||||
def reject(proposal_id: int, actor: Actor, cfg: Config | None = None, sess=None) -> int:
|
||||
@@ -48,6 +127,7 @@ def _decide(
|
||||
decision: ProposalStatus,
|
||||
cfg: Config | None,
|
||||
sess,
|
||||
signature: str | None = None,
|
||||
) -> int:
|
||||
owned = False
|
||||
if sess is None:
|
||||
@@ -70,11 +150,14 @@ def _decide(
|
||||
)
|
||||
return 1
|
||||
proposal.status = decision
|
||||
detail: dict = {}
|
||||
if decision == ProposalStatus.APPROVED:
|
||||
proposal.approved_by = actor
|
||||
proposal.approved_at = datetime.now(UTC)
|
||||
proposal.operator_signature = signature # FULL signature, row + audit
|
||||
detail["signature"] = signature or ""
|
||||
verb = "approve" if decision == ProposalStatus.APPROVED else "reject"
|
||||
audit(sess, actor=actor, action=verb, proposal_id=proposal_id)
|
||||
audit(sess, actor=actor, action=verb, proposal_id=proposal_id, detail=detail)
|
||||
return 0
|
||||
finally:
|
||||
if owned:
|
||||
@@ -85,9 +168,20 @@ def _decide(
|
||||
|
||||
|
||||
def _auto_approve(cfg: Config, sess) -> int:
|
||||
"""Auto-approve proposed items per policy. Returns count approved."""
|
||||
if not cfg.autonomous_mode or not cfg.autonomous_kinds:
|
||||
return 0
|
||||
"""DISABLED (audit round 2, point 1a): approvals require operator
|
||||
signatures bound to content — there is no non-human approval path.
|
||||
AUTONOMOUS_MODE no longer grants approval powers (DECISIONS D13)."""
|
||||
if cfg.autonomous_mode and cfg.autonomous_kinds:
|
||||
audit(
|
||||
sess,
|
||||
actor="auto:policy",
|
||||
action="autonomy_disabled",
|
||||
result="error",
|
||||
detail={
|
||||
"reason": "mandatory operator signatures: no non-human approvals",
|
||||
},
|
||||
)
|
||||
return 0
|
||||
midnight = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
used = (
|
||||
sess.query(func.count(AuditEntry.id))
|
||||
@@ -102,13 +196,15 @@ def _auto_approve(cfg: Config, sess) -> int:
|
||||
approved = 0
|
||||
if budget == 0:
|
||||
return 0
|
||||
# most relevant first (match_score = keyword relevance, NOT risk — the
|
||||
# audit's naming finding; ascending order approved noise first)
|
||||
candidates = (
|
||||
sess.query(Proposal)
|
||||
.filter(
|
||||
Proposal.status == ProposalStatus.PROPOSED,
|
||||
Proposal.kind.in_(cfg.autonomous_kinds),
|
||||
)
|
||||
.order_by(Proposal.risk_score.asc(), Proposal.id.asc())
|
||||
.order_by(Proposal.match_score.desc(), Proposal.id.asc())
|
||||
.limit(budget)
|
||||
.all()
|
||||
)
|
||||
@@ -124,7 +220,7 @@ def _auto_approve(cfg: Config, sess) -> int:
|
||||
detail={
|
||||
"reason": "autonomous_mode policy",
|
||||
"kind": proposal.kind,
|
||||
"risk_score": proposal.risk_score,
|
||||
"match_score": proposal.match_score,
|
||||
"daily_cap": cfg.autonomous_daily_cap,
|
||||
},
|
||||
)
|
||||
@@ -135,8 +231,67 @@ def _auto_approve(cfg: Config, sess) -> int:
|
||||
# --- execution ----------------------------------------------------------------
|
||||
|
||||
|
||||
def _verify_approval_signature(proposal: Proposal, cfg: Config, sess) -> bool:
|
||||
"""Run-time gate (audit round 2, 1d): a row in `approved` MUST carry a
|
||||
valid operator signature for its CURRENT content and THIS deployment.
|
||||
Rows approved by raw SQL (no signature) or edited after approval are
|
||||
rejected, audited as error, and revert to `proposed`."""
|
||||
from cayceops.signing import (
|
||||
SignatureError,
|
||||
proposal_content_hash,
|
||||
verify_signature,
|
||||
)
|
||||
|
||||
try:
|
||||
deployment_id, pubkey = _signing(cfg)
|
||||
except Exception as exc: # noqa: BLE001 — ConfigError at daemon boundary
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:executor",
|
||||
action="execute_refused",
|
||||
proposal_id=proposal.id,
|
||||
result="error",
|
||||
detail={"reason": str(exc)},
|
||||
)
|
||||
return False
|
||||
chash = proposal_content_hash(
|
||||
proposal.kind, proposal.title, proposal.url, proposal.detail
|
||||
)
|
||||
try:
|
||||
if not verify_signature(
|
||||
"proposal",
|
||||
deployment_id,
|
||||
proposal.id,
|
||||
chash,
|
||||
proposal.operator_signature,
|
||||
pubkey,
|
||||
):
|
||||
raise SignatureError(
|
||||
"no valid operator signature for current content/deployment"
|
||||
)
|
||||
except SignatureError as exc:
|
||||
proposal.status = ProposalStatus.PROPOSED # back to pre-approval
|
||||
proposal.operator_signature = None
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:executor",
|
||||
action="execute_refused",
|
||||
proposal_id=proposal.id,
|
||||
result="error",
|
||||
detail={
|
||||
"reason": str(exc),
|
||||
"content_hash": chash,
|
||||
"had_signature": bool(proposal.operator_signature),
|
||||
},
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def execute_one(proposal: Proposal, cfg: Config, sess) -> bool:
|
||||
"""Run the plugin for one approved proposal. Returns True on success."""
|
||||
if not _verify_approval_signature(proposal, cfg, sess):
|
||||
return False
|
||||
proposal.status = ProposalStatus.RUNNING
|
||||
proposal.attempts += 1
|
||||
audit(
|
||||
@@ -203,6 +358,13 @@ def run(cfg: Config | None = None, sess=None) -> int:
|
||||
run_row = DaemonRun(daemon="executor")
|
||||
sess.add(run_row)
|
||||
sess.flush()
|
||||
try:
|
||||
_signing(cfg) # refuse to run without signing config (no degraded mode)
|
||||
except Exception as exc: # noqa: BLE001 — ConfigError at boundary
|
||||
run_row.status = "error"
|
||||
run_row.detail = {"refused": str(exc)[:200]}
|
||||
run_row.finished_at = datetime.now(UTC)
|
||||
return 2
|
||||
try:
|
||||
auto = _auto_approve(cfg, sess)
|
||||
pending = (
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""maild package."""
|
||||
@@ -0,0 +1,128 @@
|
||||
"""maild classification: rules + sentence-level negation, LLM optional.
|
||||
|
||||
Approval rule (audit round 2, point 2): a mail may only classify `approval`
|
||||
when NO sentence contains a negation. Any negation token anywhere in the mail
|
||||
(conservative: even outside the approval sentence) downgrades an approval to
|
||||
`needs_review`. Rejections/questions/other are unaffected.
|
||||
|
||||
The LLM never decides alone: below-threshold confidence → `needs_review`.
|
||||
With the `null` provider (default) only deterministic rules run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
from cayceops.config import Config
|
||||
from cayceops.models import MailClassification
|
||||
|
||||
_APPROVAL_WORDS = re.compile(r"\b(approv|accept|welcome)", re.I)
|
||||
|
||||
# any of these tokens anywhere in the mail vetoes an `approval` result.
|
||||
# English and Spanish (merchants write in both; mixed mails are common).
|
||||
_NEGATIONS = re.compile(
|
||||
r"\b(not|no|never|cannot|can'?t|won'?t|hasn'?t|isn'?t|"
|
||||
r"no\s+longer|unable|decline\w*|regret\w*|unfortunately|"
|
||||
# spanish: standalone negation words
|
||||
r"nunca|jam[aá]s|tampoco|ning[uú]n(?:a|o)?|nadie|nada|"
|
||||
# spanish: multi-word negations and rejection stems
|
||||
r"no\s+podemos|no\s+es\s+posible|no\s+acept(?:amos|ar[aá])|"
|
||||
r"rechaz\w*|lamentablemente|descartad\w*|"
|
||||
r"no\s+ha\s+sido\s+aprobad\w*|no\s+procede)\b",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_RULES: list[tuple[MailClassification, re.Pattern[str], float]] = [
|
||||
# (class, pattern, confidence when matched)
|
||||
(
|
||||
MailClassification.REJECTION,
|
||||
# noqa: E501 (kept on one line: the pattern IS the reviewed artifact)
|
||||
re.compile(r"\b(declin|reject|not able to|unable to|we regret|won.t be able)\w*", re.I), # noqa: E501
|
||||
0.9,
|
||||
),
|
||||
(
|
||||
MailClassification.REJECTION,
|
||||
# spanish rejections (strong stems, not just negation words)
|
||||
re.compile(r"\b(rechaz\w*|no\s+vamos\s+a\s+pod(?:er|emos)|su\s+solicitud\s+ha\s+sido\s+denegada|no\s+cumple\s+los\s+requisitos)\b", re.I), # noqa: E501
|
||||
0.9,
|
||||
),
|
||||
(
|
||||
MailClassification.APPROVAL,
|
||||
re.compile(r"\b(approv|accept|welcome to the|you.re (in|approved)|live|activated|glad to)\w*", re.I), # noqa: E501
|
||||
0.85,
|
||||
),
|
||||
(
|
||||
MailClassification.APPROVAL,
|
||||
# spanish approvals (standalone positive stems; the veto still
|
||||
# downgrades if ANY negation appears elsewhere in the mail)
|
||||
re.compile(r"\b(aprob\w*|aceptad\w*|bienvenid\w*|ha\s+sido\s+aceptada|encantados?\s+de\s+(trabajar|colaborar))\b", re.I), # noqa: E501
|
||||
0.85,
|
||||
),
|
||||
(
|
||||
MailClassification.QUESTION,
|
||||
re.compile(r"\b(could you|can you|please (provide|clarify|confirm)|what (is|are)|how (do|does|can)|need more (info|details))\b", re.I), # noqa: E501
|
||||
0.7,
|
||||
),
|
||||
]
|
||||
|
||||
_LLM_PROMPT = """Classify this merchant email into exactly one of:
|
||||
approval, rejection, question, other.
|
||||
Reply with strict JSON: {{"classification": "...", "confidence": 0.0-1.0}}
|
||||
|
||||
Subject and body:
|
||||
{body}"""
|
||||
|
||||
|
||||
def _rules(text: str) -> tuple[MailClassification, float]:
|
||||
for kind, pattern, confidence in _RULES:
|
||||
if pattern.search(text):
|
||||
return kind, confidence
|
||||
return MailClassification.OTHER, 0.3
|
||||
|
||||
|
||||
def _has_negation(text: str) -> bool:
|
||||
return bool(_NEGATIONS.search(text))
|
||||
|
||||
|
||||
def _llm_classify(text: str, cfg: Config) -> tuple[MailClassification, float] | None:
|
||||
"""Ask the configured LLM. Returns None on any failure (rules take over)."""
|
||||
from cayceops.llm import get_provider
|
||||
|
||||
try:
|
||||
provider = get_provider(cfg)
|
||||
raw = provider.complete(
|
||||
_LLM_PROMPT.format(body=text[:4000]),
|
||||
system="You classify merchant emails. Reply with JSON only.",
|
||||
)
|
||||
data = json.loads(re.search(r"\{.*\}", raw, re.S).group(0))
|
||||
kind = MailClassification(data["classification"].lower())
|
||||
confidence = float(data["confidence"])
|
||||
except Exception: # noqa: BLE001 — LLM is best-effort by design
|
||||
return None
|
||||
if kind not in MailClassification or kind is MailClassification.NEEDS_REVIEW:
|
||||
return None
|
||||
return kind, min(max(confidence, 0.0), 1.0)
|
||||
|
||||
|
||||
def classify(text: str, cfg: Config) -> tuple[MailClassification, float]:
|
||||
"""Classify merchant text. Degrades to rules when no LLM is configured."""
|
||||
if cfg.llm_provider != "null":
|
||||
llm_result = _llm_classify(text, cfg)
|
||||
if llm_result is not None:
|
||||
kind, confidence = llm_result
|
||||
else: # LLM failed — fall through to rules
|
||||
kind, confidence = _rules(text)
|
||||
else:
|
||||
kind, confidence = _rules(text)
|
||||
|
||||
# APPROVAL VETO (audit round 2): any negation anywhere in the mail
|
||||
# downgrades an approval to needs_review. Approval requires a mail with
|
||||
# zero negations — "cannot currently accept", "never accept",
|
||||
# "hasn't been approved", "no longer accepting" all land here.
|
||||
if kind is MailClassification.APPROVAL and _has_negation(text):
|
||||
return MailClassification.NEEDS_REVIEW, min(confidence, 0.6)
|
||||
|
||||
if confidence < cfg.llm_review_threshold:
|
||||
return MailClassification.NEEDS_REVIEW, confidence
|
||||
return kind, confidence
|
||||
@@ -0,0 +1,687 @@
|
||||
"""maild — classify merchant replies, draft responses, gated SMTP.
|
||||
|
||||
Flow
|
||||
----
|
||||
1. ``poll``: fetch unread inbound mail (IMAP), parse, thread to the proposal
|
||||
via ``In-Reply-To`` of a previously-sent draft, classify (rules + optional
|
||||
LLM), store, and create a REPLY DRAFT (never sent automatically).
|
||||
2. ``approve_draft``: a human approves a specific draft (audited).
|
||||
3. ``send_approved``: SMTP sends *only* drafts in state ``approved``.
|
||||
|
||||
Classification is conservative: anything below the confidence threshold becomes
|
||||
``needs_review`` and no draft is generated.
|
||||
|
||||
Public signatures match the CLI: ``poll()``, ``approve_draft(id, actor)``,
|
||||
``send_approved()``. The ``_*`` variants take an explicit session/fetcher for
|
||||
tests and composition.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import email
|
||||
import email.policy
|
||||
import imaplib
|
||||
import smtplib
|
||||
from datetime import UTC, datetime
|
||||
from email.message import EmailMessage
|
||||
|
||||
from cayceops.components.maild.classify import classify as _classify_text
|
||||
from cayceops.config import Config
|
||||
from cayceops.db import session_scope
|
||||
from cayceops.models import (
|
||||
DaemonRun,
|
||||
DraftStatus,
|
||||
MailClassification,
|
||||
MailDirection,
|
||||
MailMessage,
|
||||
Proposal,
|
||||
_content_hash,
|
||||
audit,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
|
||||
# --- IMAP ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def connect_imap(cfg: Config) -> imaplib.IMAP4_SSL:
|
||||
client = imaplib.IMAP4_SSL(cfg.imap_host, cfg.imap_port)
|
||||
client.login(cfg.imap_user, cfg.imap_password)
|
||||
client.select(cfg.imap_mailbox)
|
||||
return client
|
||||
|
||||
|
||||
def _fetch_unread(cfg: Config) -> list[tuple[bytes, bytes]]:
|
||||
"""Fetch raw unread messages as [(imap_num, raw_bytes)].
|
||||
|
||||
Messages are NOT marked \\Seen here — marking happens only after the
|
||||
processed rows are committed (audit finding: a crash mid-batch used to
|
||||
silently lose the rest of the batch).
|
||||
"""
|
||||
client = connect_imap(cfg)
|
||||
try:
|
||||
_, data = client.search(None, "UNSEEN")
|
||||
out: list[tuple[bytes, bytes]] = []
|
||||
for num in data[0].split():
|
||||
_, msg_data = client.fetch(num, "(RFC822)")
|
||||
out.append((num, msg_data[0][1]))
|
||||
return out
|
||||
finally:
|
||||
client.logout()
|
||||
|
||||
|
||||
def _mark_seen(cfg: Config, nums: list[bytes]) -> None:
|
||||
"""Mark processed messages \\Seen (called after commit)."""
|
||||
if not nums:
|
||||
return
|
||||
client = connect_imap(cfg)
|
||||
try:
|
||||
for num in nums:
|
||||
client.store(num, "+FLAGS", "\\Seen")
|
||||
finally:
|
||||
client.logout()
|
||||
|
||||
|
||||
# --- parsing ------------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_raw(raw: bytes) -> dict:
|
||||
"""Parse raw RFC822 bytes into our flat dict shape."""
|
||||
msg = email.message_from_bytes(raw, policy=email.policy.default)
|
||||
body = ""
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
if part.get_content_type() == "text/plain" and not part.get_filename():
|
||||
body = part.get_content()
|
||||
break
|
||||
else:
|
||||
body = msg.get_content()
|
||||
reply_to = msg.get("In-Reply-To") or msg.get("References") or ""
|
||||
message_id = (msg.get("Message-ID") or "").strip()
|
||||
if not message_id:
|
||||
message_id = f"<generated-{datetime.now(UTC).timestamp()}@localhost>"
|
||||
# store the bare address (envelope-safe); display names stay out of the DB
|
||||
from_addr = email.utils.parseaddr(msg.get("From") or "")[1].strip()[:320]
|
||||
return {
|
||||
"message_id": message_id[:998],
|
||||
"in_reply_to": reply_to.split()[-1].strip() if reply_to.strip() else None,
|
||||
"from_addr": from_addr,
|
||||
"subject": (msg.get("Subject") or "").strip()[:998],
|
||||
"body": body[:20000],
|
||||
}
|
||||
|
||||
|
||||
# --- proposal threading -------------------------------------------------------
|
||||
|
||||
|
||||
def _match_proposal(parsed: dict, sess) -> Proposal | None:
|
||||
"""Thread an inbound mail to its proposal.
|
||||
|
||||
1. In-Reply-To matches a SENT OUTBOUND message (our replies carry their own
|
||||
Message-ID and an outbound row, created at send time).
|
||||
2. Fallback: it matches ANY stored message (e.g. a second reply threaded to
|
||||
our first inbound) that already carries a proposal_id.
|
||||
"""
|
||||
irt = parsed.get("in_reply_to")
|
||||
if not irt:
|
||||
return None
|
||||
sent = (
|
||||
sess.query(MailMessage)
|
||||
.filter(
|
||||
MailMessage.direction == MailDirection.OUTBOUND,
|
||||
MailMessage.message_id == irt,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if sent is not None and sent.proposal_id is not None:
|
||||
return sess.get(Proposal, sent.proposal_id)
|
||||
chain = (
|
||||
sess.query(MailMessage)
|
||||
.filter(MailMessage.message_id == irt, MailMessage.proposal_id.isnot(None))
|
||||
.one_or_none()
|
||||
)
|
||||
if chain is not None:
|
||||
return sess.get(Proposal, chain.proposal_id)
|
||||
return None
|
||||
|
||||
|
||||
# --- drafts -------------------------------------------------------------------
|
||||
|
||||
_DRAFT_TEMPLATES = {
|
||||
MailClassification.APPROVAL: (
|
||||
"Hello,\n\nThank you for the approval regarding \"{title}\". "
|
||||
"We will proceed on our side and follow up with the next steps.\n\nBest regards,\nCayce"
|
||||
),
|
||||
MailClassification.REJECTION: (
|
||||
"Hello,\n\nThank you for considering \"{title}\". "
|
||||
"We appreciate the reply and would welcome the chance to revisit in the future.\n\n"
|
||||
"Best regards,\nCayce"
|
||||
),
|
||||
MailClassification.QUESTION: (
|
||||
"Hello,\n\nRegarding \"{title}\": {question}\n\n"
|
||||
"Could you help us with the details so we can move forward?\n\nBest regards,\nCayce"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _build_draft(parsed: dict, kind: MailClassification, proposal: Proposal | None) -> str:
|
||||
title = proposal.title if proposal else (parsed.get("subject") or "your message")
|
||||
template = _DRAFT_TEMPLATES.get(kind)
|
||||
if template is None:
|
||||
return ""
|
||||
question = (parsed.get("body") or "")[:200].strip() or "we had a question"
|
||||
return template.format(title=title[:120], question=question)
|
||||
|
||||
|
||||
# --- inbound pass ---------------------------------------------------------------
|
||||
|
||||
|
||||
def _poll(cfg: Config, sess, fetcher=None, marker=None) -> tuple[dict, list[bytes]]:
|
||||
"""One inbound pass: parse, classify, thread, draft.
|
||||
|
||||
Returns (stats, processed_imap_nums). The caller marks \\Seen only after
|
||||
its transaction commits.
|
||||
"""
|
||||
stats = {"processed": 0, "drafted": 0, "needs_review": 0, "skipped": 0}
|
||||
processed_nums: list[bytes] = []
|
||||
for num, raw in (fetcher or _fetch_unread)(cfg):
|
||||
parsed = parse_raw(raw)
|
||||
if (
|
||||
sess.query(MailMessage)
|
||||
.filter(MailMessage.message_id == parsed["message_id"])
|
||||
.one_or_none()
|
||||
):
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
|
||||
proposal = _match_proposal(parsed, sess)
|
||||
kind, confidence = _classify_text(f"{parsed['subject']}\n{parsed['body']}", cfg)
|
||||
if proposal is None and kind == MailClassification.APPROVAL:
|
||||
# an approval we cannot thread is suspicious — human must look
|
||||
kind = MailClassification.NEEDS_REVIEW
|
||||
confidence = min(confidence, 0.4)
|
||||
|
||||
record = MailMessage(
|
||||
direction=MailDirection.INBOUND,
|
||||
message_id=parsed["message_id"],
|
||||
in_reply_to=parsed["in_reply_to"],
|
||||
from_addr=parsed["from_addr"] or None,
|
||||
subject=parsed["subject"] or None,
|
||||
body=parsed["body"],
|
||||
classification=kind,
|
||||
confidence=confidence,
|
||||
proposal_id=proposal.id if proposal else None,
|
||||
processed_at=datetime.now(UTC),
|
||||
)
|
||||
sess.add(record)
|
||||
sess.flush()
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:maild",
|
||||
action="classify",
|
||||
proposal_id=proposal.id if proposal else None,
|
||||
detail={"classification": kind.value, "confidence": confidence},
|
||||
)
|
||||
stats["processed"] += 1
|
||||
processed_nums.append(num) # stored -> safe to mark seen after commit
|
||||
|
||||
if kind == MailClassification.NEEDS_REVIEW:
|
||||
stats["needs_review"] += 1
|
||||
continue
|
||||
|
||||
if proposal is not None:
|
||||
proposal.detail = {
|
||||
**(proposal.detail or {}),
|
||||
"merchant_status": kind.value,
|
||||
"merchant_reply_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
draft = _build_draft(parsed, kind, proposal)
|
||||
if draft:
|
||||
record.draft_reply = draft
|
||||
record.draft_status = DraftStatus.DRAFTED
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:maild",
|
||||
action="draft_reply",
|
||||
proposal_id=proposal.id if proposal else None,
|
||||
detail={"mail_id": record.id},
|
||||
)
|
||||
stats["drafted"] += 1
|
||||
return stats, processed_nums
|
||||
|
||||
|
||||
# --- human gate: approve draft -------------------------------------------------
|
||||
|
||||
|
||||
def _mail_to_addr(record) -> str | None:
|
||||
"""The address a send would use for this draft."""
|
||||
if record.direction == MailDirection.INBOUND:
|
||||
return record.from_addr
|
||||
return record.to_addr
|
||||
|
||||
|
||||
def _mail_content_hash(record) -> str:
|
||||
from cayceops.signing import mail_content_hash
|
||||
|
||||
return mail_content_hash(
|
||||
_mail_to_addr(record), record.subject, record.draft_reply
|
||||
)
|
||||
|
||||
|
||||
def _approve_draft(
|
||||
mail_id: int, actor: str, sess, signature: str | None = None
|
||||
) -> int:
|
||||
"""Human approves a drafted reply (inbound-threaded or first-contact).
|
||||
|
||||
Signature is MANDATORY and bound to (to_addr, subject, body) + deployment
|
||||
(audit round 2, 1a/1b). The full signature is stored on the row.
|
||||
"""
|
||||
from cayceops.config import ConfigError
|
||||
from cayceops.signing import SignatureError, verify_signature
|
||||
|
||||
record = sess.get(MailMessage, mail_id)
|
||||
if record is None:
|
||||
return 1
|
||||
if record.draft_status != DraftStatus.DRAFTED or not record.draft_reply:
|
||||
audit(
|
||||
sess,
|
||||
actor=actor,
|
||||
action="approve_draft",
|
||||
proposal_id=record.proposal_id,
|
||||
result="error",
|
||||
detail={"reason": f"invalid draft state: {record.draft_status.value}"},
|
||||
)
|
||||
return 1
|
||||
|
||||
from cayceops.config import load
|
||||
|
||||
cfg = load()
|
||||
try:
|
||||
deployment_id, pubkey = _signing(cfg)
|
||||
except ConfigError as exc:
|
||||
audit(
|
||||
sess,
|
||||
actor=actor,
|
||||
action="approve_draft",
|
||||
proposal_id=record.proposal_id,
|
||||
result="error",
|
||||
detail={"reason": str(exc)},
|
||||
)
|
||||
return GATE_ERROR
|
||||
|
||||
chash = _mail_content_hash(record)
|
||||
try:
|
||||
if not signature:
|
||||
raise SignatureError("signature required")
|
||||
if not verify_signature(
|
||||
"mail", deployment_id, mail_id, chash, signature, pubkey
|
||||
):
|
||||
raise SignatureError("signature invalid for this content/deployment")
|
||||
except SignatureError as exc:
|
||||
audit(
|
||||
sess,
|
||||
actor=actor,
|
||||
action="approve_draft",
|
||||
proposal_id=record.proposal_id,
|
||||
result="error",
|
||||
detail={"reason": str(exc), "content_hash": chash},
|
||||
)
|
||||
return GATE_ERROR
|
||||
|
||||
record.draft_status = DraftStatus.APPROVED
|
||||
record.approved_hash = _content_hash(record.draft_reply)
|
||||
record.approved_by = actor[:128]
|
||||
record.operator_signature = signature # FULL signature
|
||||
audit(
|
||||
sess,
|
||||
actor=actor,
|
||||
action="approve_draft",
|
||||
proposal_id=record.proposal_id,
|
||||
detail={"mail_id": record.id, "hash": record.approved_hash, "signature": signature},
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
# --- SMTP: only approved drafts ------------------------------------------------
|
||||
|
||||
|
||||
def _smtp_send(
|
||||
cfg: Config,
|
||||
to_addr: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
message_id: str,
|
||||
in_reply_to: str | None = None,
|
||||
) -> None:
|
||||
msg = EmailMessage()
|
||||
msg["From"] = cfg.mail_from
|
||||
msg["To"] = to_addr
|
||||
msg["Subject"] = subject
|
||||
msg["Message-ID"] = message_id
|
||||
if in_reply_to:
|
||||
msg["In-Reply-To"] = in_reply_to # keep merchant threads intact
|
||||
msg.set_content(body)
|
||||
with smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30) as server:
|
||||
server.starttls()
|
||||
server.login(cfg.smtp_user, cfg.smtp_password)
|
||||
server.send_message(msg)
|
||||
|
||||
|
||||
def _approved_by(sess, mail_id: int) -> str:
|
||||
from cayceops.models import AuditEntry
|
||||
|
||||
for entry in sess.query(AuditEntry).all():
|
||||
if entry.action == "approve_draft" and entry.detail.get("mail_id") == mail_id:
|
||||
return entry.actor
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _send_one(cfg: Config, sess, mail_id: int, sender=None) -> str:
|
||||
"""Send ONE approved draft. Returns: sent | hash_mismatch | missing | blocked.
|
||||
|
||||
Creates the OUTBOUND row (its own Message-ID + In-Reply-To pointing at the
|
||||
inbound message) so future replies thread correctly — the inbound row's
|
||||
message_id is left untouched (audit finding: it used to be overwritten).
|
||||
The caller owns the transaction: one message per commit, so an SMTP failure
|
||||
on message N never un-sends messages 1..N-1 (audit finding: duplicates).
|
||||
"""
|
||||
|
||||
from cayceops.config import ConfigError
|
||||
from cayceops.signing import verify_signature
|
||||
|
||||
record = sess.get(MailMessage, mail_id)
|
||||
if record is None:
|
||||
return "missing"
|
||||
if record.draft_status != DraftStatus.APPROVED:
|
||||
return "blocked"
|
||||
|
||||
# run-time signature gate (audit round 2, 1d): the row must carry a valid
|
||||
# operator signature for its CURRENT to_addr/subject/body + deployment.
|
||||
# Unsigned rows (raw SQL approval) or edited ones revert to DRAFTED.
|
||||
try:
|
||||
deployment_id, pubkey = _signing(cfg)
|
||||
except ConfigError as exc:
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:maild",
|
||||
action="send_refused",
|
||||
proposal_id=record.proposal_id,
|
||||
result="error",
|
||||
detail={"mail_id": record.id, "reason": str(exc)},
|
||||
)
|
||||
return "unsigned"
|
||||
current_hash = _mail_content_hash(record)
|
||||
if not verify_signature(
|
||||
"mail", deployment_id, record.id, current_hash, record.operator_signature, pubkey
|
||||
):
|
||||
record.draft_status = DraftStatus.DRAFTED # back to pre-approval
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:maild",
|
||||
action="send_refused",
|
||||
proposal_id=record.proposal_id,
|
||||
result="error",
|
||||
detail={
|
||||
"mail_id": record.id,
|
||||
"reason": "no valid operator signature for current content/deployment",
|
||||
"content_hash": current_hash,
|
||||
"had_signature": bool(record.operator_signature),
|
||||
},
|
||||
)
|
||||
return "hash_mismatch"
|
||||
|
||||
if record.direction == MailDirection.INBOUND:
|
||||
# reply: goes back to the inbound sender, threads to the inbound mail,
|
||||
# and gets its own OUTBOUND row
|
||||
to_addr = record.from_addr or ""
|
||||
subject = f"Re: {record.subject or 'your message'}"
|
||||
in_reply_to = record.message_id
|
||||
outbound_msgid = (
|
||||
f"<cayce-out-{record.id}-{int(datetime.now(UTC).timestamp())}@cayce-co.xyz>"
|
||||
)
|
||||
needs_row = True
|
||||
else:
|
||||
# first-contact draft: already an OUTBOUND row; send as-is
|
||||
to_addr = record.to_addr or ""
|
||||
subject = record.subject or ""
|
||||
in_reply_to = None
|
||||
outbound_msgid = record.message_id
|
||||
needs_row = False
|
||||
if not to_addr:
|
||||
record.draft_status = DraftStatus.SKIPPED
|
||||
return "blocked"
|
||||
|
||||
(sender or _smtp_send)(
|
||||
cfg,
|
||||
to_addr,
|
||||
subject,
|
||||
record.draft_reply or "",
|
||||
outbound_msgid,
|
||||
in_reply_to=in_reply_to,
|
||||
)
|
||||
approved_by = _approved_by(sess, record.id)
|
||||
if needs_row:
|
||||
sess.add(
|
||||
MailMessage(
|
||||
direction=MailDirection.OUTBOUND,
|
||||
message_id=outbound_msgid,
|
||||
in_reply_to=record.message_id,
|
||||
from_addr=cfg.mail_from,
|
||||
to_addr=to_addr,
|
||||
subject=subject,
|
||||
body=record.draft_reply,
|
||||
proposal_id=record.proposal_id,
|
||||
draft_status=DraftStatus.SENT,
|
||||
processed_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
record.draft_status = DraftStatus.SENT
|
||||
sess.flush()
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:maild",
|
||||
action="send_draft",
|
||||
proposal_id=record.proposal_id,
|
||||
detail={
|
||||
"mail_id": record.id,
|
||||
"message_id": outbound_msgid,
|
||||
"approved_by": approved_by,
|
||||
},
|
||||
)
|
||||
return "sent"
|
||||
|
||||
|
||||
def _send_approved(cfg: Config, sess, sender=None, factory=None) -> dict:
|
||||
"""Send every APPROVED draft, one transaction per message.
|
||||
|
||||
`factory` is a SQLAlchemy sessionmaker bound to the shared engine; each
|
||||
message is processed and committed in its own session (tests inject the
|
||||
engine's maker; the CLI passes appdb.session_factory()).
|
||||
"""
|
||||
stats = {"sent": 0, "blocked": 0, "hash_mismatch": 0, "missing": 0}
|
||||
ids = [
|
||||
row.id
|
||||
for row in sess.query(MailMessage.id)
|
||||
.filter(MailMessage.draft_status == DraftStatus.APPROVED)
|
||||
.all()
|
||||
]
|
||||
for mail_id in ids:
|
||||
# one transaction per message — an SMTP failure on message N must not
|
||||
# roll back (and later re-send) messages 1..N-1 (audit finding 3)
|
||||
try:
|
||||
if factory is not None:
|
||||
with factory() as one_sess: # sessionmaker() -> Session
|
||||
outcome = _send_one(cfg, one_sess, mail_id, sender)
|
||||
one_sess.commit()
|
||||
else:
|
||||
outcome = _send_one(cfg, sess, mail_id, sender)
|
||||
except Exception as exc: # noqa: BLE001 — isolate the failed message
|
||||
outcome = "error"
|
||||
# persist the failure in its OWN transaction (the failed message's
|
||||
# transaction is dead)
|
||||
try:
|
||||
error_sess = factory() if factory is not None else None
|
||||
if error_sess is not None:
|
||||
audit(
|
||||
error_sess,
|
||||
actor="daemon:maild",
|
||||
action="send_failed",
|
||||
result="error",
|
||||
detail={"mail_id": mail_id, "error": str(exc)[:200]},
|
||||
)
|
||||
error_sess.commit()
|
||||
error_sess.close()
|
||||
except Exception: # noqa: S110 — best-effort failure audit
|
||||
pass
|
||||
stats[outcome] = stats.get(outcome, 0) + 1
|
||||
return stats
|
||||
|
||||
|
||||
def _approved_by(sess, mail_id: int) -> str:
|
||||
from cayceops.models import AuditEntry
|
||||
|
||||
for entry in sess.query(AuditEntry).all():
|
||||
if entry.action == "approve_draft" and entry.detail.get("mail_id") == mail_id:
|
||||
return entry.actor
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _compose(proposal_id: int, to_addr: str, subject: str, body: str, sess) -> int:
|
||||
"""Create the FIRST-CONTACT outbound draft for a proposal (state: drafted).
|
||||
|
||||
This is the missing path the audit flagged: without it, threading only
|
||||
worked for replies to mails we never actually sent.
|
||||
"""
|
||||
proposal = sess.get(Proposal, proposal_id)
|
||||
if proposal is None or not to_addr:
|
||||
return -1
|
||||
outbound = MailMessage(
|
||||
direction=MailDirection.OUTBOUND,
|
||||
message_id=f"<cayce-out-{proposal.id}-{int(datetime.now(UTC).timestamp())}@cayce-co.xyz>",
|
||||
from_addr=None, # filled from MAIL_FROM at send time
|
||||
to_addr=to_addr[:320],
|
||||
subject=subject[:998],
|
||||
body=body,
|
||||
draft_reply=body, # canonical approved-text field (same as reply drafts)
|
||||
proposal_id=proposal.id,
|
||||
draft_status=DraftStatus.DRAFTED,
|
||||
)
|
||||
sess.add(outbound)
|
||||
sess.flush()
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:maild",
|
||||
action="compose_draft",
|
||||
proposal_id=proposal.id,
|
||||
detail={"mail_id": outbound.id},
|
||||
)
|
||||
return outbound.id
|
||||
|
||||
|
||||
# --- CLI wrappers (session scope lives here) ------------------------------------
|
||||
|
||||
|
||||
def poll(cfg: Config | None = None) -> int:
|
||||
from cayceops.config import load
|
||||
from cayceops.db import engine
|
||||
|
||||
cfg = cfg or load()
|
||||
try:
|
||||
nums: list[bytes] = []
|
||||
with session_scope() as sess:
|
||||
run_row = DaemonRun(daemon="maild")
|
||||
sess.add(run_row)
|
||||
sess.flush()
|
||||
try:
|
||||
stats, nums = _poll(cfg, sess)
|
||||
except Exception as exc: # noqa: BLE001 — daemon boundary
|
||||
run_row.status = "error"
|
||||
run_row.detail = {"error": str(exc)[:300]}
|
||||
run_row.finished_at = datetime.now(UTC)
|
||||
return 1
|
||||
run_row.status = "ok"
|
||||
run_row.detail = stats
|
||||
run_row.finished_at = datetime.now(UTC)
|
||||
# committed: now it is safe to mark the batch seen
|
||||
if nums:
|
||||
_mark_seen(cfg, nums)
|
||||
return 0
|
||||
finally:
|
||||
engine().dispose()
|
||||
|
||||
|
||||
def approve_draft(mail_id: int, actor: str, signature: str | None = None) -> int:
|
||||
from cayceops.db import engine
|
||||
|
||||
try:
|
||||
with session_scope() as sess:
|
||||
return _approve_draft(mail_id, actor, sess, signature)
|
||||
finally:
|
||||
engine().dispose()
|
||||
|
||||
|
||||
def send_approved(cfg: Config | None = None) -> int:
|
||||
"""CLI: same code path as the tests (_send_approved with factory).
|
||||
|
||||
Failures are audited (send_failed), recorded in daemon_runs, and any
|
||||
error yields a non-zero exit code (audit round 2, point 5).
|
||||
"""
|
||||
from cayceops.config import load
|
||||
from cayceops.db import engine, session_factory
|
||||
|
||||
cfg = cfg or load()
|
||||
try:
|
||||
factory = session_factory()
|
||||
with session_scope() as sess:
|
||||
run_row = DaemonRun(daemon="maild-send")
|
||||
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)
|
||||
print(f"send refused: {exc}")
|
||||
return 2
|
||||
stats = _send_approved(cfg, sess, factory=factory)
|
||||
run_row.status = "error" if stats.get("error") else "ok"
|
||||
run_row.detail = stats
|
||||
run_row.finished_at = datetime.now(UTC)
|
||||
print(stats)
|
||||
# any failure class is a non-zero exit (audit round 2, point 5)
|
||||
failures = stats.get("error") or stats.get("hash_mismatch") or stats.get("unsigned")
|
||||
return 1 if failures else 0
|
||||
finally:
|
||||
engine().dispose()
|
||||
|
||||
|
||||
def compose(proposal_id: int, to_addr: str, subject: str, body: str) -> int:
|
||||
from cayceops.db import engine
|
||||
|
||||
try:
|
||||
with session_scope() as sess:
|
||||
mail_id = _compose(proposal_id, to_addr, subject, body, sess)
|
||||
return 0 if mail_id >= 0 else 1
|
||||
finally:
|
||||
engine().dispose()
|
||||
@@ -0,0 +1 @@
|
||||
"""monitor package."""
|
||||
@@ -0,0 +1,370 @@
|
||||
"""monitor — static status page builder.
|
||||
|
||||
Reads the shared database and renders a self-contained HTML file (no external
|
||||
assets, no JS, everything escaped). Contains NO sensitive data: daemon health,
|
||||
proposal queue counts, and recent audit activity (actor/action/result only —
|
||||
no email bodies, no detail payloads, no URLs).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from cayceops.config import Config
|
||||
from cayceops.db import session_scope
|
||||
from cayceops.models import (
|
||||
AuditEntry,
|
||||
ContentItem,
|
||||
DaemonRun,
|
||||
MailMessage,
|
||||
Proposal,
|
||||
ProposalStatus,
|
||||
)
|
||||
|
||||
DAEMONS = ("scanner", "executor", "maild", "publisher", "monitor")
|
||||
|
||||
_STYLE = """<style>
|
||||
body{font-family:ui-monospace,Menlo,monospace;background:#0a0908;color:#f2ece0;margin:2rem}
|
||||
h1{color:#c9a84c;font-weight:600} h2{color:#c9a84c;margin-top:2rem;font-size:1.1rem}
|
||||
table{border-collapse:collapse;margin:.5rem 0}
|
||||
td,th{border:1px solid #333;padding:.35rem .8rem;text-align:left}
|
||||
.ok{color:#6fce6f}.error{color:#ef6b6b}.muted{color:#888;font-size:.85rem}
|
||||
.card{display:inline-block;background:#141210;border:1px solid #2a2a2a;
|
||||
border-radius:8px;padding:.8rem 1.4rem;margin:.3rem}
|
||||
</style>"""
|
||||
|
||||
|
||||
def _fmt_ts(dt) -> str:
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%SZ") if dt else "—"
|
||||
|
||||
|
||||
def _daemon_rows(sess) -> str:
|
||||
rows = []
|
||||
for name in DAEMONS:
|
||||
last = (
|
||||
sess.query(DaemonRun)
|
||||
.filter(DaemonRun.daemon == name)
|
||||
.order_by(DaemonRun.started_at.desc())
|
||||
.first()
|
||||
)
|
||||
if last is None:
|
||||
rows.append(
|
||||
"<tr><td>" + name + "</td><td class=muted>never run</td><td>—</td></tr>"
|
||||
)
|
||||
else:
|
||||
cls = "ok" if last.status == "ok" else "error"
|
||||
when = _fmt_ts(last.finished_at or last.started_at)
|
||||
rows.append(
|
||||
"<tr><td>" + html.escape(name) + "</td>"
|
||||
+ '<td class="' + cls + '">' + html.escape(last.status) + "</td>"
|
||||
+ "<td>" + when + "</td></tr>"
|
||||
)
|
||||
head = "<tr><th>daemon</th><th>last status</th><th>last run</th></tr>"
|
||||
return "<table>" + head + "".join(rows) + "</table>"
|
||||
|
||||
|
||||
def _queue_cards(sess) -> str:
|
||||
counts = dict(
|
||||
sess.query(Proposal.status, func.count(Proposal.id)).group_by(Proposal.status).all()
|
||||
)
|
||||
cards = []
|
||||
for status in ProposalStatus:
|
||||
value = counts.get(status, 0)
|
||||
cards.append(
|
||||
"<div class=card>" + html.escape(status.value)
|
||||
+ ": <strong>" + str(value) + "</strong></div>"
|
||||
)
|
||||
return "".join(cards)
|
||||
|
||||
|
||||
def _recent_audit(sess, limit: int = 20) -> str:
|
||||
rows = []
|
||||
query = sess.query(AuditEntry).order_by(AuditEntry.ts.desc()).limit(limit)
|
||||
for entry in query.all():
|
||||
cls = "ok" if entry.result == "ok" else "error"
|
||||
rows.append(
|
||||
"<tr><td>" + _fmt_ts(entry.ts) + "</td>"
|
||||
+ "<td>" + html.escape(entry.actor) + "</td>"
|
||||
+ "<td>" + html.escape(entry.action) + "</td>"
|
||||
+ '<td class="' + cls + '">' + html.escape(entry.result) + "</td></tr>"
|
||||
)
|
||||
body = "".join(rows) or "<tr><td colspan=4 class=muted>no activity yet</td></tr>"
|
||||
return (
|
||||
"<table><tr><th>time (utc)</th><th>actor</th><th>action</th><th>result</th></tr>"
|
||||
+ body
|
||||
+ "</table>"
|
||||
)
|
||||
|
||||
|
||||
def _build(cfg: Config, sess) -> str:
|
||||
now = datetime.now(UTC)
|
||||
mail_unprocessed = (
|
||||
sess.query(func.count(MailMessage.id))
|
||||
.filter(
|
||||
MailMessage.direction == "in",
|
||||
MailMessage.draft_status.in_(["drafted", "none"]),
|
||||
MailMessage.classification.isnot(None),
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
content_pending = (
|
||||
sess.query(func.count(ContentItem.id))
|
||||
.filter(ContentItem.status.in_(["draft", "approved"]))
|
||||
.scalar()
|
||||
)
|
||||
generated = _fmt_ts(now)
|
||||
|
||||
page = f"""<!doctype html>
|
||||
<html lang=en><head><meta charset=utf-8><title>cayce-ops status</title>{_STYLE}</head>
|
||||
<body>
|
||||
<h1>cayce-ops · status</h1>
|
||||
<p class=muted>generated {generated} · static page, no sensitive data by design</p>
|
||||
|
||||
<h2>daemon health</h2>
|
||||
{_daemon_rows(sess)}
|
||||
|
||||
<h2>proposal queue</h2>
|
||||
{_queue_cards(sess)}
|
||||
|
||||
<h2>gates</h2>
|
||||
<div class=card>mail drafts awaiting approval: <strong>{mail_unprocessed}</strong></div>
|
||||
<div class=card>content items draft/approved: <strong>{content_pending}</strong></div>
|
||||
|
||||
<h2>recent audit activity</h2>
|
||||
{_recent_audit(sess)}
|
||||
|
||||
<p class=muted>audit entries: {sess.scalar(select(func.count(AuditEntry.id)))} total</p>
|
||||
</body></html>"""
|
||||
|
||||
import os
|
||||
|
||||
os.makedirs(os.path.dirname(cfg.monitor_output) or ".", exist_ok=True)
|
||||
with open(cfg.monitor_output, "w", encoding="utf-8") as fh:
|
||||
fh.write(page)
|
||||
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
|
||||
from cayceops.db import engine
|
||||
|
||||
cfg = cfg or load()
|
||||
try:
|
||||
with session_scope() as sess:
|
||||
run_row = DaemonRun(daemon="monitor")
|
||||
sess.add(run_row)
|
||||
sess.flush()
|
||||
try:
|
||||
out = _build(cfg, sess)
|
||||
except Exception as exc: # noqa: BLE001 — daemon boundary
|
||||
run_row.status = "error"
|
||||
run_row.detail = {"error": str(exc)[:300]}
|
||||
run_row.finished_at = datetime.now(UTC)
|
||||
return 1
|
||||
run_row.status = "ok"
|
||||
run_row.detail = {"output": out}
|
||||
run_row.finished_at = datetime.now(UTC)
|
||||
print(out)
|
||||
return 0
|
||||
finally:
|
||||
engine().dispose()
|
||||
|
||||
|
||||
def _latest_chain_hash(sess) -> str | None:
|
||||
"""Last non-NULL entry_hash in the audit chain (None if chain empty)."""
|
||||
from cayceops.models import AuditEntry
|
||||
|
||||
last = (
|
||||
sess.query(AuditEntry)
|
||||
.filter(AuditEntry.entry_hash.isnot(None))
|
||||
.order_by(AuditEntry.id.desc())
|
||||
.first()
|
||||
)
|
||||
return last.entry_hash if last else None
|
||||
|
||||
|
||||
def _published_anchors(sess) -> list[dict]:
|
||||
"""Anchor audit entries recorded locally, oldest first."""
|
||||
from cayceops.models import AuditEntry
|
||||
|
||||
rows = (
|
||||
sess.query(AuditEntry)
|
||||
.filter(AuditEntry.action == "anchor")
|
||||
.order_by(AuditEntry.id.asc())
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"event_id": (r.detail or {}).get("event_id"),
|
||||
"last_hash": (r.detail or {}).get("last_hash"),
|
||||
"last_entry": (r.detail or {}).get("last_entry"),
|
||||
"ts": r.ts.isoformat(),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def verify_chain_against_anchors(
|
||||
sess,
|
||||
relay_fetcher=None,
|
||||
) -> dict:
|
||||
"""Compare the local chain tail with the anchors published to Nostr.
|
||||
|
||||
relay_fetcher(deployment) -> list of note contents (strings) found on
|
||||
the relays for this deployment's anchor tag. When None, only local
|
||||
coherence is checked and the result says so.
|
||||
|
||||
Returns a report dict:
|
||||
status: "no_anchors" | "local_only" | "ok" | "mismatch"
|
||||
detail: human-readable explanation
|
||||
local_last_hash, local_anchors, relay_notes (when fetched)
|
||||
"""
|
||||
local_hash = _latest_chain_hash(sess)
|
||||
anchors = _published_anchors(sess)
|
||||
|
||||
report: dict = {
|
||||
"local_last_hash": local_hash,
|
||||
"local_anchors": anchors,
|
||||
"relay_notes": None,
|
||||
}
|
||||
|
||||
if not anchors:
|
||||
report["status"] = "no_anchors"
|
||||
report["detail"] = (
|
||||
"the chain has never been anchored to Nostr — no external "
|
||||
"attestation exists yet (anchor once with `monitor anchor`)"
|
||||
)
|
||||
return report
|
||||
|
||||
last_anchor = anchors[-1]
|
||||
if relay_fetcher is None:
|
||||
report["status"] = "local_only"
|
||||
report["detail"] = (
|
||||
"compared only against the LOCAL record of past anchors "
|
||||
"(no relay fetch configured); last anchored hash "
|
||||
f"{last_anchor['last_hash']}"
|
||||
)
|
||||
return report
|
||||
|
||||
from cayceops.config import load as _load
|
||||
|
||||
deployment = _load().deployment_id
|
||||
notes = relay_fetcher(deployment) or []
|
||||
report["relay_notes"] = notes
|
||||
|
||||
if not notes:
|
||||
report["status"] = "no_anchors"
|
||||
report["detail"] = (
|
||||
"local anchor records exist but no anchor notes were found on "
|
||||
"the relays — the external attestation is missing or was deleted"
|
||||
)
|
||||
return report
|
||||
|
||||
# the freshest relay note must attest the current chain tail
|
||||
expected_fragment = f"last_hash={local_hash}"
|
||||
latest_note = notes[-1]
|
||||
if expected_fragment in latest_note:
|
||||
report["status"] = "ok"
|
||||
report["detail"] = "latest relay anchor matches the current chain tail"
|
||||
return report
|
||||
|
||||
# maybe the chain advanced after the last anchor — that's fine ONLY if
|
||||
# the note matches the hash recorded in the last local anchor entry
|
||||
if last_anchor["last_hash"] and last_anchor["last_hash"] in latest_note:
|
||||
report["status"] = "ok"
|
||||
report["detail"] = (
|
||||
"relay anchor matches the last local anchor; the chain has "
|
||||
"advanced since (anchor again to attest the new tail)"
|
||||
)
|
||||
return report
|
||||
|
||||
report["status"] = "mismatch"
|
||||
report["detail"] = (
|
||||
f"relay anchor does not attest chain tail {local_hash} nor the last "
|
||||
f"local anchor {last_anchor['last_hash']} — the published anchor "
|
||||
"does not correspond to this database"
|
||||
)
|
||||
return report
|
||||
@@ -0,0 +1 @@
|
||||
"""publisher package."""
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Optional Facebook/Instagram adapters (Graph API).
|
||||
|
||||
DISABLED BY DEFAULT. Each adapter refuses to run unless its enable flag is
|
||||
true (``CAYCE_OPS_ENABLE_FACEBOOK`` / ``CAYCE_OPS_ENABLE_INSTAGRAM``), so the
|
||||
default deployment never touches Meta endpoints. Tokens are env-only and are
|
||||
never logged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
GRAPH = "https://graph.facebook.com/v21.0"
|
||||
|
||||
|
||||
def _post(url: str, payload: dict) -> dict:
|
||||
data = urllib.parse.urlencode(payload).encode()
|
||||
req = urllib.request.Request(url, data=data, method="POST") # noqa: S310
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
|
||||
def post_facebook(cfg, caption: str, media_path: str | None = None) -> dict:
|
||||
"""Post to the page feed (text) or /photos (with local media). Gated."""
|
||||
if not cfg.enable_facebook:
|
||||
raise PermissionError("facebook adapter disabled (CAYCE_OPS_ENABLE_FACEBOOK=false)")
|
||||
if not cfg.facebook_page_token:
|
||||
raise ValueError("CAYCE_OPS_FACEBOOK_PAGE_TOKEN missing")
|
||||
if media_path:
|
||||
# photo posts require a reachable URL or multipart upload; we use the
|
||||
# simple URL form — media must be hosted (out of scope for local files)
|
||||
result = _post(
|
||||
f"{GRAPH}/me/photos",
|
||||
{"url": media_path, "caption": caption, "access_token": cfg.facebook_page_token},
|
||||
)
|
||||
else:
|
||||
result = _post(
|
||||
f"{GRAPH}/me/feed",
|
||||
{"message": caption, "access_token": cfg.facebook_page_token},
|
||||
)
|
||||
return {"post_id": result.get("post_id") or result.get("id", "")}
|
||||
|
||||
|
||||
def post_instagram(cfg, caption: str, image_url: str) -> dict:
|
||||
"""Two-step IG publish (container -> publish). Gated."""
|
||||
if not cfg.enable_instagram:
|
||||
raise PermissionError("instagram adapter disabled (CAYCE_OPS_ENABLE_INSTAGRAM=false)")
|
||||
if not cfg.instagram_user_id:
|
||||
raise ValueError("CAYCE_OPS_INSTAGRAM_USER_ID missing")
|
||||
token = cfg.facebook_page_token # IG Graph publishing uses the page token
|
||||
container = _post(
|
||||
f"{GRAPH}/{cfg.instagram_user_id}/media",
|
||||
{"image_url": image_url, "caption": caption, "access_token": token},
|
||||
)
|
||||
published = _post(
|
||||
f"{GRAPH}/{cfg.instagram_user_id}/media_publish",
|
||||
{"creation_id": container["id"], "access_token": token},
|
||||
)
|
||||
return {"post_id": published.get("id", "")}
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Brand image templates (Pillow). 1080x1350 portrait, two brand styles.
|
||||
|
||||
Deterministic, template-driven graphics — deliberately not AI-generated media
|
||||
(DECISIONS: nothing here trips AI-media disclosure requirements; it is typeset
|
||||
design). Fonts: Pillow's bundled default (scalable), so no system font deps.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
WIDTH, HEIGHT = 1080, 1350
|
||||
|
||||
_BRANDS = {
|
||||
# bitcoinhotels — warm gold on near-black
|
||||
"bh": {
|
||||
"bg": (20, 18, 16),
|
||||
"accent": (201, 168, 76),
|
||||
"text": (242, 236, 224),
|
||||
"muted": (150, 140, 120),
|
||||
},
|
||||
# hardcap — bitcoin orange on cool black
|
||||
"hc": {
|
||||
"bg": (13, 13, 16),
|
||||
"accent": (247, 147, 26),
|
||||
"text": (250, 250, 250),
|
||||
"muted": (120, 120, 130),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _font(size: int, bold: bool = False):
|
||||
# Pillow >=10: load_default(size=...) returns a scalable bundled font
|
||||
return ImageFont.load_default(size=size)
|
||||
|
||||
|
||||
def _vgrad(top: tuple, bottom: tuple) -> Image.Image:
|
||||
"""Simple two-color vertical gradient."""
|
||||
img = Image.new("RGB", (WIDTH, HEIGHT))
|
||||
for y in range(HEIGHT):
|
||||
t = y / (HEIGHT - 1)
|
||||
row = tuple(int(top[i] + (bottom[i] - top[i]) * t) for i in range(3))
|
||||
ImageDraw.Draw(img).line([(0, y), (WIDTH, y)], fill=row)
|
||||
return img
|
||||
|
||||
|
||||
def render_brand_image(
|
||||
*,
|
||||
brand: str = "bh",
|
||||
kicker: str | None,
|
||||
lines: list[str],
|
||||
tagline: list[str] | None = None,
|
||||
url: str | None = None,
|
||||
out_path: str,
|
||||
) -> str:
|
||||
"""Render one branded poster. Returns out_path. Raises on unknown brand."""
|
||||
if brand not in _BRANDS:
|
||||
raise ValueError(f"unknown brand {brand!r} (have: {sorted(_BRANDS)})")
|
||||
palette = _BRANDS[brand]
|
||||
img = _vgrad(palette["bg"], tuple(max(0, c - 8) for c in palette["bg"]))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
x, y = 90, 240
|
||||
if kicker:
|
||||
draw.text((x, y), kicker, font=_font(40), fill=palette["muted"])
|
||||
y += 110
|
||||
for line in lines:
|
||||
draw.text((x, y), line, font=_font(96, bold=True), fill=palette["text"])
|
||||
y += 118
|
||||
y += 24
|
||||
for t in tagline or []:
|
||||
draw.text((x, y), t, font=_font(38), fill=palette["muted"])
|
||||
y += 58
|
||||
draw.rectangle([x, y + 30, x + 470, y + 33], fill=palette["accent"])
|
||||
if url:
|
||||
draw.text((x, y + 110), url, font=_font(44), fill=palette["accent"])
|
||||
|
||||
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
|
||||
img.save(out_path, "JPEG", quality=92)
|
||||
return out_path
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Minimal Nostr client: NIP-01 kind-1 notes (build, sign, verify, publish).
|
||||
|
||||
Implemented in-repo (DECISIONS D4) so every byte of the signing path is
|
||||
auditable: event id = sha256 of the canonical NIP-01 serialization; signature =
|
||||
BIP-340 Schnorr over the id. Keys are 32-byte hex. Relays are plain env config.
|
||||
|
||||
Publishing is fire-and-wait: open a websocket per relay, send the EVENT, wait
|
||||
for the OK frame (or timeout), and return the per-relay results.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from coincurve import PrivateKey, PublicKey
|
||||
|
||||
KIND_NOTE = 1
|
||||
|
||||
|
||||
def normalize_key(private_key_hex: str) -> bytes:
|
||||
"""Accept 64-char hex (with or without 0x). Anything else is a config error."""
|
||||
key = private_key_hex.strip().lower().removeprefix("0x")
|
||||
if len(key) != 64 or not all(c in "0123456789abcdef" for c in key):
|
||||
raise ValueError("CAYCE_OPS_NOSTR_PRIVATE_KEY must be 64-char hex (32 bytes)")
|
||||
return bytes.fromhex(key)
|
||||
|
||||
|
||||
def pubkey_from_secret(secret: bytes) -> str:
|
||||
return PublicKey.from_secret(secret).format().hex()[2:]
|
||||
|
||||
|
||||
def build_event(
|
||||
secret: bytes,
|
||||
content: str,
|
||||
tags: list[list[str]] | None = None,
|
||||
created_at: int | None = None,
|
||||
kind: int = KIND_NOTE,
|
||||
) -> dict[str, Any]:
|
||||
"""Build and sign a NIP-01 event. Deterministic given the same inputs."""
|
||||
created_at = int(created_at if created_at is not None else __import__("time").time())
|
||||
pubkey = pubkey_from_secret(secret)
|
||||
serialized = json.dumps(
|
||||
[0, pubkey, created_at, kind, tags or [], content],
|
||||
separators=(",", ":"), # canonical NIP-01 form
|
||||
ensure_ascii=False,
|
||||
)
|
||||
event_id = hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
||||
# zero aux randomness -> fully deterministic signatures (BIP-340 permits it;
|
||||
# verification is unaffected either way)
|
||||
signature = (
|
||||
PrivateKey(secret)
|
||||
.sign_schnorr(bytes.fromhex(event_id), aux_randomness=b"\x00" * 32)
|
||||
.hex()
|
||||
)
|
||||
return {
|
||||
"pubkey": pubkey,
|
||||
"created_at": created_at,
|
||||
"kind": kind,
|
||||
"tags": tags or [],
|
||||
"content": content,
|
||||
"id": event_id,
|
||||
"sig": signature,
|
||||
}
|
||||
|
||||
|
||||
def _schnorr_verify(sig32: bytes, msg32: bytes, xonly_pub32: bytes) -> bool:
|
||||
"""BIP-340 reference verification (pure Python, public domain spec code).
|
||||
|
||||
Signing runs in coincurve (libsecp256k1); verification is pure Python so
|
||||
the auditor can read every step without CFFI indirection.
|
||||
"""
|
||||
P = 2**256 - 2**32 - 977
|
||||
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
|
||||
|
||||
def tag_hash(tag: str, msg: bytes) -> bytes:
|
||||
t = hashlib.sha256(tag.encode()).digest()
|
||||
return hashlib.sha256(t + t + msg).digest()
|
||||
|
||||
def lift_x(b: int):
|
||||
if b >= P:
|
||||
return None
|
||||
y_sq = (pow(b, 3, P) + 7) % P
|
||||
y = pow(y_sq, (P + 1) // 4, P)
|
||||
if pow(y, 2, P) != y_sq:
|
||||
return None
|
||||
return (b, y if y % 2 == 0 else P - y)
|
||||
|
||||
def point_add(p1, p2):
|
||||
if p1 is None:
|
||||
return p2
|
||||
if p2 is None:
|
||||
return p1
|
||||
if p1[0] == p2[0] and p1[1] != p2[1]:
|
||||
return None
|
||||
if p1 == p2:
|
||||
lam = (3 * p1[0] * p1[0] * pow(2 * p1[1], P - 2, P)) % P
|
||||
else:
|
||||
lam = ((p2[1] - p1[1]) * pow(p2[0] - p1[0], P - 2, P)) % P
|
||||
x3 = (lam * lam - p1[0] - p2[0]) % P
|
||||
return (x3, (lam * (p1[0] - x3) - p1[1]) % P)
|
||||
|
||||
def point_mul(p, scalar):
|
||||
acc = None
|
||||
for i in range(256):
|
||||
if (scalar >> i) & 1:
|
||||
acc = point_add(acc, p)
|
||||
p = point_add(p, p)
|
||||
return acc
|
||||
|
||||
if len(sig32) != 64 or len(msg32) != 32 or len(xonly_pub32) != 32:
|
||||
return False
|
||||
r = int.from_bytes(sig32[:32], "big")
|
||||
s = int.from_bytes(sig32[32:], "big")
|
||||
if not (0 <= r < P and 0 <= s < N):
|
||||
return False
|
||||
pub_point = lift_x(int.from_bytes(xonly_pub32, "big"))
|
||||
if pub_point is None:
|
||||
return False
|
||||
e = int.from_bytes(tag_hash("BIP0340/challenge", sig32[:32] + xonly_pub32 + msg32), "big") % N
|
||||
generator = (
|
||||
0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798, # Gx
|
||||
0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8, # Gy
|
||||
)
|
||||
r_point = point_add(
|
||||
point_mul(generator, s),
|
||||
point_mul(pub_point, (N - e) % N),
|
||||
)
|
||||
if r_point is None or r_point[1] % 2 != 0:
|
||||
return False
|
||||
return r_point[0] == r
|
||||
|
||||
|
||||
def verify_event(event: dict[str, Any]) -> bool:
|
||||
"""Recompute the id and verify the Schnorr signature against the pubkey."""
|
||||
serialized = json.dumps(
|
||||
[0, event["pubkey"], event["created_at"], event["kind"], event["tags"], event["content"]],
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
if hashlib.sha256(serialized.encode("utf-8")).hexdigest() != event["id"]:
|
||||
return False
|
||||
# 33-byte compressed point (02-prefixed x-only)
|
||||
compressed = PublicKey(bytes.fromhex("02" + event["pubkey"])).format()
|
||||
return _schnorr_verify(
|
||||
bytes.fromhex(event["sig"]), bytes.fromhex(event["id"]), compressed[1:]
|
||||
)
|
||||
|
||||
|
||||
def verify_schnorr(sig_hex: str, message: bytes, pubkey_hex: str) -> bool:
|
||||
"""Verify a BIP-340 signature over an arbitrary 32-byte message."""
|
||||
try:
|
||||
return _schnorr_verify(
|
||||
bytes.fromhex(sig_hex), message, bytes.fromhex(pubkey_hex)
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def relay_publish(relay_url: str, event: dict[str, Any], timeout: float = 10.0) -> str:
|
||||
"""Publish one event to one relay; return the relay's OK message."""
|
||||
from websockets.sync.client import connect
|
||||
|
||||
with connect(relay_url, close_timeout=timeout) as ws:
|
||||
ws.send(json.dumps(["EVENT", event]))
|
||||
reply = json.loads(ws.recv(timeout=timeout))
|
||||
if not (isinstance(reply, list) and len(reply) >= 3 and reply[0] == "OK" and reply[2] is True):
|
||||
raise RuntimeError(f"relay {relay_url} rejected event: {str(reply)[:200]}")
|
||||
return str(reply[1])
|
||||
|
||||
|
||||
def publish(
|
||||
relays: list[str], event: dict[str, Any], timeout: float = 10.0
|
||||
) -> list[dict[str, str]]:
|
||||
"""Publish to every relay; report per-relay outcome (never raises unless all fail)."""
|
||||
results = []
|
||||
for relay in relays:
|
||||
try:
|
||||
relay_publish(relay, event, timeout)
|
||||
results.append({"relay": relay, "ok": "true"})
|
||||
except Exception as exc: # noqa: BLE001 — per-relay isolation
|
||||
results.append({"relay": relay, "ok": "false", "error": str(exc)[:200]})
|
||||
if relays and all(r["ok"] == "false" for r in results):
|
||||
raise RuntimeError(f"all relays failed: {results}")
|
||||
return results
|
||||
@@ -0,0 +1,430 @@
|
||||
"""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()
|
||||
@@ -165,7 +165,7 @@ def scan(cfg: Config, sess, fetch=_fetch) -> dict:
|
||||
"summary": desc,
|
||||
"scanned_at": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
risk_score=score,
|
||||
match_score=score,
|
||||
status=ProposalStatus.PROPOSED,
|
||||
dedupe_key=dedupe,
|
||||
)
|
||||
|
||||
@@ -84,6 +84,15 @@ class Config:
|
||||
enable_http_actions: bool = field(
|
||||
default_factory=lambda: _bool("CAYCE_OPS_ENABLE_HTTP_ACTIONS", False)
|
||||
)
|
||||
# MANDATORY for approval-gated work: the operator x-only pubkey and the
|
||||
# deployment identifier are part of every signed approval message.
|
||||
# Without BOTH, daemons refuse to execute/send/publish — no degraded mode.
|
||||
operator_pubkey: str = field(
|
||||
default_factory=lambda: os.environ.get("CAYCE_OPS_OPERATOR_PUBKEY", "")
|
||||
)
|
||||
deployment_id: str = field(
|
||||
default_factory=lambda: os.environ.get("CAYCE_OPS_DEPLOYMENT_ID", "")
|
||||
)
|
||||
|
||||
# --- mail ---
|
||||
imap_host: str = field(
|
||||
@@ -162,6 +171,10 @@ class Config:
|
||||
)
|
||||
|
||||
|
||||
class ConfigError(RuntimeError):
|
||||
"""Raised when mandatory configuration is missing."""
|
||||
|
||||
|
||||
def load() -> Config:
|
||||
"""Build the process Config snapshot."""
|
||||
return Config()
|
||||
|
||||
+164
-10
@@ -5,16 +5,26 @@ Status machines
|
||||
proposal: proposed -> approved -> running -> done | failed
|
||||
proposed -> rejected
|
||||
mail: (inbound) classified: approval|rejection|question|other|needs_review
|
||||
(outbound draft) none -> drafted -> approved -> sent | skipped
|
||||
content: draft -> approved -> published
|
||||
(any) draft: none -> drafted -> approved -> sent | skipped
|
||||
content: draft -> approved -> published | skipped (terminal)
|
||||
|
||||
Audit chain
|
||||
-----------
|
||||
audit_log is hash-chained: each entry stores sha256(prev_hash | fields).
|
||||
``cayceops.models.verify_audit_chain`` re-walks it; the monitor surfaces breaks.
|
||||
Not tamper-proof (DB writes can rewrite the chain) but tamper-EVIDENT against
|
||||
selective edits. See AUDIT.md / audit response 2026-10-20.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, Enum, Float, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy import JSON, DateTime, Enum, Float, ForeignKey, Integer, String, Text, text
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
@@ -22,6 +32,11 @@ def utcnow() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def _content_hash(text: str) -> str:
|
||||
"""Binding hash for human-approved text (drafts, captions)."""
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
@@ -54,7 +69,8 @@ class Proposal(Base):
|
||||
title: Mapped[str] = mapped_column(String(512))
|
||||
url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
detail: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
risk_score: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
# keyword-match relevance 0..1 — NOT risk. auto-approve takes highest first.
|
||||
match_score: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
status: Mapped[ProposalStatus] = mapped_column(
|
||||
Enum(ProposalStatus), default=ProposalStatus.PROPOSED, index=True
|
||||
)
|
||||
@@ -62,13 +78,15 @@ class Proposal(Base):
|
||||
dedupe_key: Mapped[str | None] = mapped_column(String(256), unique=True, nullable=True)
|
||||
approved_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# full operator approval signature (128 hex chars) — never truncated
|
||||
operator_signature: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow
|
||||
)
|
||||
|
||||
|
||||
# --- audit -------------------------------------------------------------------
|
||||
# --- audit (hash-chained) ----------------------------------------------------
|
||||
|
||||
|
||||
class AuditEntry(Base):
|
||||
@@ -76,13 +94,21 @@ class AuditEntry(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
||||
actor: Mapped[str] = mapped_column(String(128)) # human username | auto:policy | daemon:<name>
|
||||
actor: Mapped[str] = mapped_column(String(128)) # human | auto:policy | daemon:<name>
|
||||
proposal_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("proposals.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
action: Mapped[str] = mapped_column(String(64)) # approve|reject|execute|classify|...
|
||||
detail: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
result: Mapped[str] = mapped_column(String(32), default="ok") # ok | error
|
||||
# tamper-evident chain
|
||||
prev_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
entry_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
|
||||
|
||||
# reentrant: several audit() calls may share one transaction
|
||||
_AUDIT_LOCK = threading.RLock()
|
||||
_AUDIT_LOCK_PG_KEY = 7471186757 # pg_advisory_xact_lock key for cross-process
|
||||
|
||||
|
||||
def audit(
|
||||
@@ -93,16 +119,135 @@ def audit(
|
||||
detail: dict | None = None,
|
||||
result: str = "ok",
|
||||
) -> AuditEntry:
|
||||
"""Record one audited action. Every component writes through this helper."""
|
||||
entry = AuditEntry(
|
||||
"""Record one audited action, chained to the previous entry.
|
||||
|
||||
Chain: entry_hash = sha256(prev_hash | ts_epoch | actor | action |
|
||||
proposal_id | detail | result). Writes are SERIALIZED (audit round 2,
|
||||
point 4): pg_advisory_xact_lock on Postgres, in-process lock on SQLite —
|
||||
concurrent writers can no longer fork the chain.
|
||||
|
||||
Legacy rows (entry_hash IS NULL, pre-0002) are ignored when picking
|
||||
prev_hash: the chain starts GENESIS at the first hashed row.
|
||||
"""
|
||||
bind = sess.get_bind()
|
||||
if bind.dialect.name == "postgresql":
|
||||
# xact-scoped: PG releases the lock at commit/rollback automatically
|
||||
sess.execute(
|
||||
text("SELECT pg_advisory_xact_lock(:k)"), {"k": _AUDIT_LOCK_PG_KEY}
|
||||
)
|
||||
prev = sess.query(AuditEntry).order_by(AuditEntry.id.desc()).first()
|
||||
prev_hash = prev.entry_hash if prev is not None else "GENESIS"
|
||||
entry = _build_entry(prev_hash, actor, action, proposal_id, detail, result)
|
||||
sess.add(entry)
|
||||
return entry
|
||||
|
||||
# SQLite path: hold the in-process lock until the session commits or
|
||||
# rolls back, so concurrent writers cannot read the same prev_hash
|
||||
# (round-2 point 4 — verified by test_concurrent_writers_keep_chain_verifiable)
|
||||
_AUDIT_LOCK.acquire()
|
||||
released = False
|
||||
|
||||
def _release(*_args):
|
||||
nonlocal released
|
||||
if not released:
|
||||
released = True
|
||||
try:
|
||||
_AUDIT_LOCK.release()
|
||||
except RuntimeError: # pragma: no cover
|
||||
pass
|
||||
|
||||
from sqlalchemy import event
|
||||
|
||||
event.listen(sess, "after_commit", _release)
|
||||
event.listen(sess, "after_rollback", _release)
|
||||
event.listen(sess, "after_soft_rollback", _release)
|
||||
try:
|
||||
prev = sess.query(AuditEntry).order_by(AuditEntry.id.desc()).first()
|
||||
prev_hash = prev.entry_hash if prev is not None else "GENESIS"
|
||||
entry = _build_entry(prev_hash, actor, action, proposal_id, detail, result)
|
||||
sess.add(entry)
|
||||
return entry
|
||||
except Exception:
|
||||
_release()
|
||||
raise
|
||||
|
||||
|
||||
def _build_entry(
|
||||
prev_hash: str,
|
||||
actor: str,
|
||||
action: str,
|
||||
proposal_id: int | None,
|
||||
detail: dict | None,
|
||||
result: str,
|
||||
) -> AuditEntry:
|
||||
ts = utcnow()
|
||||
ts_int = int(ts.timestamp())
|
||||
detail_json = json.dumps(detail or {}, sort_keys=True, ensure_ascii=False)
|
||||
payload = "|".join(
|
||||
[
|
||||
prev_hash or "GENESIS",
|
||||
str(ts_int),
|
||||
actor,
|
||||
action,
|
||||
str(proposal_id),
|
||||
detail_json,
|
||||
result,
|
||||
]
|
||||
)
|
||||
return AuditEntry(
|
||||
ts=ts,
|
||||
actor=actor,
|
||||
action=action,
|
||||
proposal_id=proposal_id,
|
||||
detail=detail or {},
|
||||
result=result,
|
||||
prev_hash=prev_hash or "GENESIS",
|
||||
entry_hash=hashlib.sha256(payload.encode("utf-8")).hexdigest(),
|
||||
)
|
||||
sess.add(entry)
|
||||
return entry
|
||||
|
||||
|
||||
def verify_audit_chain(sess) -> tuple[bool, int | None]:
|
||||
"""Re-walk the chain. Returns (ok, first_broken_entry_id or None).
|
||||
|
||||
Rows with entry_hash IS NULL are ONLY tolerated BEFORE the first hashed
|
||||
row (the documented legacy prefix from before migration 0002). A NULL
|
||||
hash AFTER the chain has started is tampering — an attacker erasing the
|
||||
hash of an intermediate row to hide an edit — and fails verification at
|
||||
that row's id.
|
||||
"""
|
||||
prev_hash: str | None = None # None = still in legacy prefix
|
||||
chain_started = False
|
||||
for entry in sess.query(AuditEntry).order_by(AuditEntry.id).all():
|
||||
if entry.entry_hash is None:
|
||||
if chain_started:
|
||||
# tampering: a hash was erased in the middle of the chain
|
||||
return False, entry.id
|
||||
continue # legacy prefix row: outside the chain by genesis
|
||||
chain_started = True
|
||||
if prev_hash is None:
|
||||
expected_prev = "GENESIS" # first hashed row anchors the chain
|
||||
else:
|
||||
expected_prev = prev_hash
|
||||
detail_json = json.dumps(entry.detail or {}, sort_keys=True, ensure_ascii=False)
|
||||
ts = entry.ts
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=UTC)
|
||||
payload = "|".join(
|
||||
[
|
||||
expected_prev,
|
||||
str(int(ts.timestamp())),
|
||||
entry.actor,
|
||||
entry.action,
|
||||
str(entry.proposal_id),
|
||||
detail_json,
|
||||
entry.result,
|
||||
]
|
||||
)
|
||||
expected = hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
if entry.prev_hash != expected_prev or entry.entry_hash != expected:
|
||||
return False, entry.id
|
||||
prev_hash = entry.entry_hash
|
||||
return True, None
|
||||
|
||||
|
||||
# --- mail --------------------------------------------------------------------
|
||||
@@ -151,6 +296,10 @@ class MailMessage(Base):
|
||||
Enum(DraftStatus), default=DraftStatus.NONE
|
||||
)
|
||||
draft_reply: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# binding: hash of the draft text the human approved + who approved it
|
||||
approved_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
approved_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
operator_signature: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
@@ -161,6 +310,7 @@ class ContentStatus(enum.StrEnum):
|
||||
DRAFT = "draft"
|
||||
APPROVED = "approved"
|
||||
PUBLISHED = "published"
|
||||
SKIPPED = "skipped" # terminal: disabled channel or attempt budget exhausted
|
||||
|
||||
|
||||
class ContentItem(Base):
|
||||
@@ -179,6 +329,10 @@ class ContentItem(Base):
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
post_ref: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
attempts: Mapped[int] = mapped_column(Integer, default=0)
|
||||
# binding: hash of the caption the human approved
|
||||
approved_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
operator_signature: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Operator approval signing — mandatory, content- and deployment-bound.
|
||||
|
||||
Every human approval (executor approve / maild approve-draft / publisher
|
||||
approve) must carry a BIP-340 signature made with the operator key:
|
||||
|
||||
message = sha256("cayce-ops:<type>:<deployment_id>:<id>:<content_hash>")
|
||||
|
||||
where content_hash binds the approval to the exact approved content:
|
||||
|
||||
proposal: canonical json of {kind, title, url, detail}
|
||||
mail: canonical json of {to_addr, subject, body}
|
||||
content: canonical json of {channel, caption, link}
|
||||
|
||||
There is NO degraded mode: without CAYCE_OPS_OPERATOR_PUBKEY and
|
||||
CAYCE_OPS_DEPLOYMENT_ID the daemons refuse to execute/send/publish
|
||||
(see cayceops.config.Config.signing_credentials).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
|
||||
class SignatureError(RuntimeError):
|
||||
"""Raised when an approval signature is missing, invalid, or foreign."""
|
||||
|
||||
|
||||
def _canonical(obj: dict) -> str:
|
||||
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
|
||||
def _sha256_hex(text: str) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def proposal_content_hash(kind: str, title: str, url: str | None, detail: dict | None) -> str:
|
||||
return _sha256_hex(
|
||||
_canonical(
|
||||
{
|
||||
"kind": kind,
|
||||
"title": title,
|
||||
"url": url,
|
||||
"detail": detail or {},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def mail_content_hash(to_addr: str | None, subject: str | None, body: str | None) -> str:
|
||||
return _sha256_hex(
|
||||
_canonical(
|
||||
{
|
||||
"to_addr": to_addr,
|
||||
"subject": subject,
|
||||
"body": body,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def content_content_hash(channel: str, caption: str, link: str | None) -> str:
|
||||
return _sha256_hex(
|
||||
_canonical(
|
||||
{
|
||||
"channel": channel,
|
||||
"caption": caption,
|
||||
"link": link,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def approval_message(
|
||||
item_type: str, deployment_id: str, item_id: int, content_hash: str
|
||||
) -> bytes:
|
||||
"""The exact 32-byte message the operator signs."""
|
||||
raw = f"cayce-ops:{item_type}:{deployment_id}:{item_id}:{content_hash}"
|
||||
return hashlib.sha256(raw.encode("utf-8")).digest()
|
||||
|
||||
|
||||
def verify_signature(
|
||||
item_type: str,
|
||||
deployment_id: str,
|
||||
item_id: int,
|
||||
content_hash: str,
|
||||
signature_hex: str | None,
|
||||
operator_pubkey_hex: str,
|
||||
) -> bool:
|
||||
"""Verify an operator approval signature. Foreign deployments or edited
|
||||
content verify False because both are inside the signed message."""
|
||||
if not signature_hex or not operator_pubkey_hex or not deployment_id:
|
||||
return False
|
||||
from cayceops.components.publisher.nostr import _schnorr_verify
|
||||
|
||||
try:
|
||||
return _schnorr_verify(
|
||||
bytes.fromhex(signature_hex),
|
||||
approval_message(item_type, deployment_id, item_id, content_hash),
|
||||
bytes.fromhex(operator_pubkey_hex),
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def pubkey_from_private_hex(private_key_hex: str) -> str:
|
||||
"""Derive the 64-char x-only public key from a 32-byte hex private key.
|
||||
|
||||
Raises ValueError on malformed input.
|
||||
"""
|
||||
if not private_key_hex:
|
||||
raise ValueError("empty private key")
|
||||
try:
|
||||
from coincurve import PublicKey
|
||||
|
||||
return PublicKey.from_secret(bytes.fromhex(private_key_hex)).format().hex()[2:]
|
||||
except (ImportError, ValueError) as exc:
|
||||
raise ValueError(f"cannot derive pubkey: {exc}") from exc
|
||||
|
||||
|
||||
class SelfApprovalError(RuntimeError):
|
||||
"""Raised when the configured operator pubkey is the identity of any
|
||||
key held by the system itself (e.g. Cayce nostr identity). The design
|
||||
requires a *separate human* operator key — never an agent key."""
|
||||
|
||||
|
||||
FORBIDDEN_IDENTITIES_ENV = "CAYCE_OPS_FORBIDDEN_OPERATOR_PUBKEYS"
|
||||
|
||||
|
||||
def forbidden_identities(cfg) -> list[str]:
|
||||
"""Pubkeys that may never act as the operator key.
|
||||
|
||||
Includes:
|
||||
1. Any private key loaded from the config (nostr_private_key, or any
|
||||
future agent identity).
|
||||
2. An explicit deny-list in CAYCE_OPS_FORBIDDEN_OPERATOR_PUBKEYS
|
||||
(comma-separated hex pubkeys) for keys held outside this process.
|
||||
"""
|
||||
listed: list[str] = []
|
||||
if cfg.nostr_private_key:
|
||||
try:
|
||||
listed.append(pubkey_from_private_hex(cfg.nostr_private_key))
|
||||
except ValueError:
|
||||
pass # malformed nostr key: _signing() already catches it elsewhere
|
||||
import os
|
||||
|
||||
raw = os.environ.get(FORBIDDEN_IDENTITIES_ENV, "")
|
||||
for tok in raw.split(","):
|
||||
tok = tok.strip().lower()
|
||||
if len(tok) == 64:
|
||||
listed.append(tok)
|
||||
return listed
|
||||
|
||||
|
||||
def validate_no_self_approval(cfg) -> None:
|
||||
"""Refuse to operate if the operator pubkey equals any identity held by
|
||||
the system. Raises SelfApprovalError.
|
||||
|
||||
Empty operator_pubkey is fine here (that's the 'not configured' state
|
||||
which _signing() rejects elsewhere); this check is about *wrong* keys.
|
||||
"""
|
||||
if not cfg.operator_pubkey:
|
||||
return
|
||||
me = cfg.operator_pubkey.strip().lower()
|
||||
for forbidden in forbidden_identities(cfg):
|
||||
if me == forbidden:
|
||||
raise SelfApprovalError(
|
||||
"CAYCE_OPS_OPERATOR_PUBKEY is an agent identity (Cayce or "
|
||||
"another in-process key). The operator key must belong to a "
|
||||
"human and must never reside on this server. Refusing to "
|
||||
"operate. See docs/DECISIONS.md D16."
|
||||
)
|
||||
@@ -0,0 +1,145 @@
|
||||
# RESTORE.md — Restauración de CayceOps desde backups cifrados
|
||||
|
||||
**Modelo:** la VPS genera backups cifrados (age) en `/var/backups/cayce/YYYY-MM-DD/`;
|
||||
tu Mac los descarga con el usuario `backup-pull` (solo lectura). La clave
|
||||
**privada** de age vive SOLO en tu Mac. Este runbook asume una VPS nueva
|
||||
(Ubuntu + IP apuntada por DNS) y tus backups descifrados a mano.
|
||||
|
||||
---
|
||||
|
||||
## 0. Descargar y descifrar (en TU Mac — nunca en la VPS)
|
||||
|
||||
```bash
|
||||
# pull (clave restringida: solo lectura de /var/backups/cayce)
|
||||
rsync -av --partial backup-pull@15.235.3.239:/ ~/backups/cayce/
|
||||
|
||||
# integridad (sin clave privada; manifest lista los artefactos CIFRADOS)
|
||||
cd ~/backups/cayce/2026-XX-XX && shasum -a 256 -c manifest.sha256
|
||||
# (Linux: sha256sum -c manifest.sha256)
|
||||
|
||||
# descifrar (clave privada age del operador — la que NO está en la VPS)
|
||||
age -d -i ~/keys/cayce-backup.txt -o cayce-ops-db.dump cayce-ops-db.dump.age
|
||||
age -d -i ~/keys/cayce-backup.txt -o gitea-dump.zip gitea-dump.zip.age
|
||||
age -d -i ~/keys/cayce-backup.txt -o config.tar.gz config.tar.gz.age
|
||||
```
|
||||
|
||||
Artefactos:
|
||||
| Archivo | Contenido | Formato |
|
||||
|---|---|---|
|
||||
| `cayce-ops-db.dump` | BD de CayceOps | `pg_dump` custom (restaurar con `pg_restore`) |
|
||||
| `gitea-dump.zip` | repos + sqlite + adjuntos + config Gitea | zip de `gitea dump` |
|
||||
| `config.tar.gz` | `caddy/` (Caddyfile+Caddyfile.d), `gitea/` (app.ini + internal_token), `systemd/` (unidades cayce-*), `cayce-ops/dot-env` | tar.gz |
|
||||
|
||||
---
|
||||
|
||||
## 1. VPS nueva — base del sistema
|
||||
|
||||
```bash
|
||||
apt-get update
|
||||
apt-get install -y postgresql age rsync git
|
||||
# Gitea: misma versión mayor que la VPS origen (verSTEP: gitea --version en el viejo
|
||||
# servidor ANTES de apagarlo, o lee la versión del zip gitea-dump: gitea-dump zip
|
||||
# contiene un archivo "app.ini" con la versión en comentarios... anota la versión
|
||||
# manualmente; el binario se baja de https://dl.gitea.com/gitea/)
|
||||
adduser --system --group --home /var/lib/gitea git
|
||||
mkdir -p /etc/gitea /var/lib/gitea /etc/cayce-backup
|
||||
curl -L "https://dl.gitea.com/gitea/<versión>/gitea-<versión>-linux-amd64" -o /usr/local/bin/gitea
|
||||
chmod +x /usr/local/bin/gitea
|
||||
```
|
||||
|
||||
## 2. Postgres
|
||||
|
||||
```bash
|
||||
systemctl enable --now postgresql
|
||||
sudo -u postgres psql <<SQL
|
||||
CREATE ROLE cayce LOGIN PASSWORD '<password-del-.env-restaurado>';
|
||||
CREATE DATABASE cayce_ops OWNER cayce;
|
||||
SQL
|
||||
|
||||
# restaurar el dump (los ficheros descifrados son legibles solo por ti;
|
||||
# cópiales permisos de lectura para postgres o pásalos por stdin)
|
||||
sudo -u postgres pg_restore -d cayce_ops --no-owner --no-privileges \
|
||||
/ruta/cayce-ops-db.dump
|
||||
|
||||
# verificación
|
||||
sudo -u postgres psql -d cayce_ops -tAc \
|
||||
"SELECT count(*) FROM audit_log;" # debe coincidir con la última ancla
|
||||
```
|
||||
|
||||
## 3. Gitea
|
||||
|
||||
```bash
|
||||
# config restaurada desde config.tar.gz
|
||||
tar -xzf config.tar.gz -C /tmp/restored
|
||||
cp /tmp/restored/gitea/app.ini /etc/gitea/app.ini
|
||||
chown root:git /etc/gitea/app.ini && chmod 660 /etc/gitea/app.ini
|
||||
cp /tmp/restored/gitea/internal_token /etc/gitea/ && chown git:git /etc/gitea/internal_token
|
||||
|
||||
# NOTA sobre ROOT_URL: app.ini restaurado apunta a https://git.cayce-co.xyz/ —
|
||||
# el DNS de ese subdominio debe existir ANTES de iniciar Gitea o las URLs
|
||||
# absolutas (webhooks, notificaciones) quedarán mal.
|
||||
|
||||
# dump de Gitea: contiene data/, repos/ y custom/. Restaurar SOBRE el dump
|
||||
# de Gitea exige la misma versión del binario; descomprimir dentro de WORK_PATH
|
||||
systemctl stop gitea 2>/dev/null || true
|
||||
unzip -o gitea-dump.zip -d /tmp/gitea-restore
|
||||
rsync -a /tmp/gitea-restore/data/ /var/lib/gitea/data/
|
||||
rsync -a /tmp/gitea-restore/repos/ /var/lib/gitea/data/gitea-repositories/ 2>/dev/null || true
|
||||
rsync -a /tmp/gitea-restore/custom/ /var/lib/gitea/custom/ 2>/dev/null || true
|
||||
chown -R git:git /var/lib/gitea
|
||||
sudo -u git /usr/local/bin/gitea migrate -c /etc/gitea/app.ini # ajusta esquema si cambia versión
|
||||
```
|
||||
|
||||
> **Consistencia:** los dumps de Gitea se toman EN CALIENTE (el servicio sigue
|
||||
> corriendo). Riesgo teórico de inconsistencia sqlite/repos si un push ocurre
|
||||
> en el mismo instante. Para un backup garantizado-consistente: `systemctl stop
|
||||
> gitea` antes de `gitea dump` y volver a levantar después (no automatizado a
|
||||
> propósito: no queremos ventanas de downtime diarias).
|
||||
|
||||
## 4. Config del sistema (Caddy, unidades systemd, .env)
|
||||
|
||||
```bash
|
||||
# Caddy
|
||||
apt-get install -y caddy # o el instalador oficial
|
||||
rsync -a /tmp/restored/caddy/Caddyfile /etc/caddy/Caddyfile
|
||||
rsync -a /tmp/restored/caddy/Caddyfile.d/ /etc/caddy/Caddyfile.d/
|
||||
systemctl enable --now caddy # emitirá certificados si el DNS ya apunta aquí
|
||||
|
||||
# unidades systemd de cayce (DESHABILITADAS por diseño — ver riesgos)
|
||||
rsync -a /tmp/restored/systemd/ /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
# NO habilitar cayce-ops@*.timer sin el operador
|
||||
|
||||
# .env de cayce-ops
|
||||
install -d -m 755 /home/ubuntu
|
||||
rsync -a /tmp/restored/cayce-ops/dot-env /home/ubuntu/cayce-ops/.env
|
||||
chown ubuntu:ubuntu /home/ubuntu/cayce-ops/.env && chmod 660 /home/ubuntu/cayce-ops/.env
|
||||
```
|
||||
|
||||
## 5. Código del repo
|
||||
|
||||
```bash
|
||||
sudo -u ubuntu git clone http://127.0.0.1:3100/cayce/cayce-ops.git /home/ubuntu/cayce-ops \
|
||||
|| git clone https://git.cayce-co.xyz/cayce/cayce-ops.git /home/ubuntu/cayce-ops
|
||||
cd /home/ubuntu/cayce-ops && uv sync
|
||||
make check # gate verde antes de considerar la restauración completa
|
||||
```
|
||||
|
||||
## 6. Reinstalar el pipeline de backup en la VPS nueva
|
||||
|
||||
```bash
|
||||
install -m 700 -o root -g root deploy/backup/make-backup.sh /usr/local/bin/cayce-make-backup
|
||||
install -m 644 deploy/systemd/cayce-backup.{service,timer} /etc/systemd/system/
|
||||
systemctl daemon-reload && systemctl enable --now cayce-backup.timer
|
||||
# la MISMA clave pública age → tu Mac sigue pudiendo descifrar:
|
||||
echo 'age1<tu-pubkey>' > /etc/cayce-backup/age-recipient.pub
|
||||
```
|
||||
|
||||
## 7. Checklist de verificación post-restauración
|
||||
|
||||
- [ ] `sha256sum -c manifest.sha256` OK en tu Mac (antes de descifrar)
|
||||
- [ ] `pg_restore --list` del dump sin errores
|
||||
- [ ] `verify_audit_chain(sess)` → `(True, None)` contra la BD restaurada
|
||||
- [ ] Gitea responde por TLS en `git.cayce-co.xyz` y `git rev-parse main` da el commit esperado
|
||||
- [ ] `make check` verde en el repo restaurado
|
||||
- [ ] Primer backup nuevo generado y verificado desde la Mac
|
||||
@@ -0,0 +1,43 @@
|
||||
# backup-pull — usuario de solo lectura para el pull desde la Mac
|
||||
|
||||
La VPS genera los backups cifrados en `/var/backups/cayce/YYYY-MM-DD/`.
|
||||
La Mac los descarga por rsync con una clave SSH **restringida**. La VPS
|
||||
nunca tiene credenciales para escribir ni borrar nada fuera de sí misma.
|
||||
|
||||
## Estado instalado en la VPS
|
||||
|
||||
- Usuario de sistema `backup-pull` (uid 1001): **sin sudo**, password
|
||||
bloqueado, home `/home/backup-pull`, shell `/bin/sh` (necesario para que
|
||||
sshd ejecute el forced command; la restricción real la pone la clave, no
|
||||
el shell).
|
||||
- `/home/backup-pull/.ssh/authorized_keys` (600, backup-pull:backup-pull) —
|
||||
**vacío** hasta que el operador añada su clave pública.
|
||||
- `/var/backups/cayce` → `root:backup-pull`, `750`; artefactos `640`.
|
||||
|
||||
## Formato de la línea authorized_keys (una sola línea)
|
||||
|
||||
restrict,command="/usr/bin/rrsync -ro /var/backups/cayce" ssh-ed25519 AAAA... comentario
|
||||
|
||||
`restrict` = no-pty, no-port-forwarding, no-agent-forwarding, no-X11-forwarding
|
||||
(y sin sftp). `rrsync -ro` = solo rsync de LECTURA, confinado a
|
||||
`/var/backups/cayce`. Añadirla con:
|
||||
|
||||
echo 'restrict,command="/usr/bin/rrsync -ro /var/backups/cayce" ssh-ed25519 AAAA... cayce-mac-pull' \
|
||||
| sudo tee -a /home/backup-pull/.ssh/authorized_keys
|
||||
|
||||
## Pull desde la Mac
|
||||
|
||||
rsync -av --partial backup-pull@15.235.3.239:/ ~/backups/cayce/
|
||||
|
||||
## Verificación realizada (2026-10-28, con clave desechada después)
|
||||
|
||||
| Prueba | Resultado |
|
||||
|---|---|
|
||||
| Shell interactivo | ✗ `rrsync error: Not invoked via sshd` |
|
||||
| Comando arbitrario (`cat /etc/shadow`) | ✗ `SSH_ORIGINAL_COMMAND does not run rsync` |
|
||||
| rsync de `/etc/` | ✗ `invalid rsync-command syntax or options` (fuera del root permitido) |
|
||||
| scp/sftp | ✗ (restrict) |
|
||||
| **rsync pull de `/var/backups/cayce/`** | ✓ funciona |
|
||||
| rsync write (upload) | ✗ protocolo cortado, nada escrito |
|
||||
|
||||
La clave usada para las pruebas se **borró**; `authorized_keys` quedó vacío.
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-backup-freshness — alert if the newest backup is older than 26h.
|
||||
#
|
||||
# Exit 0 = fresh (or no backups ever made and NONE expected yet)
|
||||
# Exit 1 = stale: last backup dir older than MAX_HOURS, or no dir at all
|
||||
# while the age recipient is configured (backups should exist).
|
||||
#
|
||||
# Wire it anywhere you like (cron, another timer, or run manually from the
|
||||
# Mac after the daily pull). Not attached to any timer by itself — the only
|
||||
# authorized timer in Tanda B is cayce-backup.timer.
|
||||
set -euo pipefail
|
||||
|
||||
BACKUP_ROOT="/var/backups/cayce"
|
||||
MAX_HOURS=26
|
||||
RECIPIENT_FILE="/etc/cayce-backup/age-recipient.pub"
|
||||
|
||||
newest="$(find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -name "2*-*-*" 2>/dev/null | sort | tail -1 || true)"
|
||||
|
||||
if [[ -z "$newest" ]]; then
|
||||
if [[ -f "$RECIPIENT_FILE" ]]; then
|
||||
echo "ALERT: no backups exist although the age recipient is configured" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "INFO: no backups yet (age recipient not configured) — nothing to alert"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
hours=$(( ( $(date +%s) - $(stat -c %Y "$newest") ) / 3600 ))
|
||||
if (( hours > MAX_HOURS )); then
|
||||
echo "ALERT: last backup ($newest) is ${hours}h old (max ${MAX_HOURS}h)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: last backup ($newest) is ${hours}h old"
|
||||
Executable
+121
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env bash
|
||||
# cayce-backup — encrypted daily backup of CayceOps + Gitea + config.
|
||||
#
|
||||
# Design contract (Tanda B):
|
||||
# - EVERY artifact is encrypted with age BEFORE it leaves the tmpdir.
|
||||
# No plaintext is ever written outside the tmpdir, which is removed
|
||||
# by a trap even on failure.
|
||||
# - The age recipient lives in /etc/cayce-backup/age-recipient.pub
|
||||
# (public key only). The PRIVATE key never touches this server:
|
||||
# decryption happens on the operator's machine after a pull.
|
||||
# - Any step failure => non-zero exit => journald entry (run via systemd).
|
||||
# - Retention: 7 daily directories under /var/backups/cayce/.
|
||||
#
|
||||
# The restore procedure lives in deploy/backup/RESTORE.md.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DATE="$(date -u +%F)"
|
||||
BACKUP_ROOT="/var/backups/cayce"
|
||||
DEST="$BACKUP_ROOT/$DATE"
|
||||
RECIPIENT_FILE="/etc/cayce-backup/age-recipient.pub"
|
||||
CAYCE_DIR="/home/ubuntu/cayce-ops"
|
||||
ENV_FILE="$CAYCE_DIR/.env"
|
||||
RETENTION_DAYS=7
|
||||
|
||||
log() { echo "[cayce-backup] $(date -u +%FT%TZ) $*"; }
|
||||
die() { echo "[cayce-backup] FATAL: $*" >&2; exit 1; }
|
||||
|
||||
# --- preconditions -------------------------------------------------------------
|
||||
[[ $EUID -eq 0 ]] || die "must run as root (systemd)"
|
||||
command -v age >/dev/null || die "age not installed"
|
||||
[[ -r "$RECIPIENT_FILE" ]] || die "age recipient not configured: create $RECIPIENT_FILE (public key only)"
|
||||
RECIPIENT="$(tr -d "[:space:]" < "$RECIPIENT_FILE")"
|
||||
[[ "$RECIPIENT" == age1* ]] || die "$RECIPIENT_FILE does not look like an age1... public key"
|
||||
|
||||
# --- workspace: everything plaintext lives here and dies here ------------------
|
||||
TMP="$(mktemp -d /tmp/cayce-backup.XXXXXX)"
|
||||
# 711: root-only listing, but the git user must be able to traverse into
|
||||
# its own 700 subdir for gitea dump; plaintext root files stay unreadable
|
||||
chmod 711 "$TMP"
|
||||
cleanup() {
|
||||
rm -rf "$TMP"
|
||||
if [[ "${FAILED:-0}" == "1" ]]; then
|
||||
rm -rf "$DEST" # never leave a partial day directory
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
STAGE="$TMP/stage" # encrypted artifacts land here before install
|
||||
install -d -m 700 "$STAGE"
|
||||
|
||||
encrypt() { # encrypt <plaintext-path> <name.age>
|
||||
age -r "$RECIPIENT" -o "$STAGE/$2" "$1"
|
||||
}
|
||||
|
||||
# --- 1. Postgres dump (cayce-ops DB) --------------------------------------------
|
||||
DB_URL="${CAYCE_OPS_DATABASE_URL:-}"
|
||||
if [[ -z "$DB_URL" && -r "$ENV_FILE" ]]; then
|
||||
# shellcheck disable=SC1090
|
||||
DB_URL="$(grep -E "^CAYCE_OPS_DATABASE_URL=" "$ENV_FILE" | head -1 | cut -d= -f2-)"
|
||||
fi
|
||||
[[ -n "$DB_URL" ]] || die "no CAYCE_OPS_DATABASE_URL in env or $ENV_FILE"
|
||||
PG_URL="$(printf "%s" "$DB_URL" | sed -E "s#postgresql\+[a-z]+://#postgresql://#")"
|
||||
_scheme="${PG_URL%%://*}"
|
||||
_hostpart="${PG_URL#**@}"
|
||||
log "dumping database from ${_scheme}://***:***@${_hostpart}"
|
||||
pg_dump "$PG_URL" --format=custom --file "$TMP/cayce-ops-db.dump"
|
||||
encrypt "$TMP/cayce-ops-db.dump" "cayce-ops-db.dump.age"
|
||||
log "database dump: $(du -h "$TMP/cayce-ops-db.dump" | cut -f1) plaintext, encrypted ok"
|
||||
|
||||
# --- 2. Gitea dump (repos + sqlite + attachments) -------------------------------
|
||||
GITEA_BIN="$(command -v gitea || echo /usr/local/bin/gitea)"
|
||||
[[ -x "$GITEA_BIN" ]] || die "gitea binary not found"
|
||||
install -d -o git -g git -m 700 "$TMP/gitea-work"
|
||||
log "running gitea dump (live; see RESTORE.md for consistency notes)..."
|
||||
sudo -u git "$GITEA_BIN" dump -c /etc/gitea/app.ini \
|
||||
--file "$TMP/gitea-work/gitea-dump.zip" --tempdir "$TMP/gitea-work" --quiet
|
||||
encrypt "$TMP/gitea-work/gitea-dump.zip" "gitea-dump.zip.age"
|
||||
log "gitea dump: $(du -h "$TMP/gitea-work/gitea-dump.zip" | cut -f1) plaintext, encrypted ok"
|
||||
|
||||
# --- 3. Config tar (Caddy, Gitea app.ini + internal_token, units, .env) ----------
|
||||
CONF="$TMP/config"
|
||||
install -d -m 700 "$CONF"
|
||||
mkdir -p "$CONF/caddy" "$CONF/gitea" "$CONF/systemd" "$CONF/cayce-ops"
|
||||
cp -a /etc/caddy/Caddyfile "$CONF/caddy/" 2>/dev/null || true
|
||||
cp -a /etc/caddy/Caddyfile.d "$CONF/caddy/" 2>/dev/null || true
|
||||
cp -a /etc/gitea/app.ini "$CONF/gitea/" 2>/dev/null || true
|
||||
cp -a /etc/gitea/internal_token "$CONF/gitea/" 2>/dev/null || true
|
||||
find /etc/systemd/system -maxdepth 1 -name "cayce-*" \
|
||||
-exec cp -a {} "$CONF/systemd/" \; 2>/dev/null || true
|
||||
[[ -r "$ENV_FILE" ]] && cp -a "$ENV_FILE" "$CONF/cayce-ops/dot-env" \
|
||||
|| log "WARNING: $ENV_FILE not present — config backup continues without it"
|
||||
# at least Caddy or Gitea config must exist, else the tar is useless
|
||||
[[ -e "$CONF/caddy/Caddyfile" || -e "$CONF/gitea/app.ini" ]] \
|
||||
|| die "no config sources found (Caddyfile/app.ini missing)"
|
||||
tar -C "$CONF" -czf "$TMP/config.tar.gz" .
|
||||
encrypt "$TMP/config.tar.gz" "config.tar.gz.age"
|
||||
log "config tar: $(du -h "$TMP/config.tar.gz" | cut -f1) plaintext, encrypted ok"
|
||||
|
||||
# --- 4. install + manifest -------------------------------------------------------
|
||||
install -d -m 750 -o root -g backup-pull "$BACKUP_ROOT" 2>/dev/null \
|
||||
|| install -d -m 700 "$BACKUP_ROOT" # backup-pull group may not exist yet
|
||||
install -d -m 750 -o root -g backup-pull "$DEST" 2>/dev/null \
|
||||
|| install -d -m 700 "$DEST"
|
||||
install -m 640 -o root -g backup-pull "$STAGE"/*.age "$DEST/" 2>/dev/null \
|
||||
|| install -m 600 "$STAGE"/*.age "$DEST/"
|
||||
( cd "$DEST" && sha256sum ./*.age > "$TMP/manifest.sha256" )
|
||||
install -m 640 -o root -g backup-pull "$TMP/manifest.sha256" "$DEST/manifest.sha256" 2>/dev/null \
|
||||
|| install -m 600 "$TMP/manifest.sha256" "$DEST/manifest.sha256"
|
||||
log "manifest written: $(wc -l < "$DEST/manifest.sha256") artifacts"
|
||||
|
||||
# --- 5. retention -----------------------------------------------------------------
|
||||
PRUNED=0
|
||||
while IFS= read -r old; do
|
||||
rm -rf "$old"
|
||||
PRUNED=$((PRUNED + 1))
|
||||
log "retention: removed $old"
|
||||
done < <(find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -name "2*-*-*" -mtime +"$RETENTION_DAYS")
|
||||
log "retention: kept last $RETENTION_DAYS days (pruned $PRUNED)"
|
||||
|
||||
log "BACKUP OK → $DEST ($(du -sh "$DEST" | cut -f1))"
|
||||
@@ -0,0 +1,180 @@
|
||||
-- cayce-ops — least-privilege per-daemon roles (audit round 2, point 3)
|
||||
--
|
||||
-- Apply with psql and MANDATORY password variables; the script refuses to run
|
||||
-- without them (no literal credentials):
|
||||
--
|
||||
-- psql -h 127.0.0.1 -U postgres -d cayce_ops -f deploy/roles.sql \
|
||||
-- -v scanner_password='…' -v executor_password='…' \
|
||||
-- -v maild_password='…' -v publisher_password='…' \
|
||||
-- -v monitor_password='…' -v approver_password='…'
|
||||
--
|
||||
-- Model:
|
||||
-- cayce_scanner : INSERT proposals only (+ INSERT audit_log, daemon_runs)
|
||||
-- cayce_executor : UPDATE proposals WITHOUT approved/signature columns
|
||||
-- cayce_maild : UPDATE mail_messages WITHOUT approved/signature columns
|
||||
-- cayce_publisher : INSERT content_items, UPDATE WITHOUT approved/signature
|
||||
-- cayce_monitor : SELECT everything (+ INSERT audit_log for anchors)
|
||||
-- cayce_approver : THE ONLY role that can write status='approved',
|
||||
-- approved_hash, operator_signature, approved_by/at
|
||||
--
|
||||
-- A BEFORE trigger rejects status='approved' writes from any role except
|
||||
-- cayce_approver — column grants alone cannot express that condition.
|
||||
--
|
||||
-- NOTE: do NOT apply on production yourself; the operator does.
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
\if :{?scanner_password}
|
||||
\else
|
||||
\echo 'ERROR: missing variables. Pass -v scanner_password=… executor_password=… maild_password=… publisher_password=… monitor_password=… approver_password=…'
|
||||
SELECT * FROM _roles_sql_missing_required_password_variables;
|
||||
\endif
|
||||
\if :{?executor_password}
|
||||
\else
|
||||
\echo 'ERROR: missing executor_password'
|
||||
SELECT * FROM _roles_sql_missing_required_password_variables;
|
||||
\endif
|
||||
\if :{?maild_password}
|
||||
\else
|
||||
\echo 'ERROR: missing maild_password'
|
||||
SELECT * FROM _roles_sql_missing_required_password_variables;
|
||||
\endif
|
||||
\if :{?publisher_password}
|
||||
\else
|
||||
\echo 'ERROR: missing publisher_password'
|
||||
SELECT * FROM _roles_sql_missing_required_password_variables;
|
||||
\endif
|
||||
\if :{?monitor_password}
|
||||
\else
|
||||
\echo 'ERROR: missing monitor_password'
|
||||
SELECT * FROM _roles_sql_missing_required_password_variables;
|
||||
\endif
|
||||
\if :{?approver_password}
|
||||
\else
|
||||
\echo 'ERROR: missing approver_password'
|
||||
SELECT * FROM _roles_sql_missing_required_password_variables;
|
||||
\endif
|
||||
|
||||
-- idempotency: detach old incarnations before dropping
|
||||
DROP FUNCTION IF EXISTS cayce_ops_approval_gate() CASCADE;
|
||||
REVOKE ALL ON SCHEMA public FROM
|
||||
cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor, cayce_approver;
|
||||
REVOKE ALL ON ALL TABLES IN SCHEMA public FROM
|
||||
cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor, cayce_approver;
|
||||
REVOKE ALL ON ALL SEQUENCES IN SCHEMA public FROM
|
||||
cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor, cayce_approver;
|
||||
|
||||
DROP ROLE IF EXISTS cayce_scanner;
|
||||
DROP ROLE IF EXISTS cayce_executor;
|
||||
DROP ROLE IF EXISTS cayce_maild;
|
||||
DROP ROLE IF EXISTS cayce_publisher;
|
||||
DROP ROLE IF EXISTS cayce_monitor;
|
||||
DROP ROLE IF EXISTS cayce_approver;
|
||||
|
||||
CREATE ROLE cayce_scanner LOGIN PASSWORD :'scanner_password';
|
||||
CREATE ROLE cayce_executor LOGIN PASSWORD :'executor_password';
|
||||
CREATE ROLE cayce_maild LOGIN PASSWORD :'maild_password';
|
||||
CREATE ROLE cayce_publisher LOGIN PASSWORD :'publisher_password';
|
||||
CREATE ROLE cayce_monitor LOGIN PASSWORD :'monitor_password';
|
||||
CREATE ROLE cayce_approver LOGIN PASSWORD :'approver_password';
|
||||
|
||||
GRANT USAGE ON SCHEMA public TO cayce_scanner, cayce_executor, cayce_maild,
|
||||
cayce_publisher, cayce_monitor, cayce_approver;
|
||||
|
||||
-- sequences everybody needs
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public
|
||||
TO cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor, cayce_approver;
|
||||
|
||||
-- common reads
|
||||
GRANT SELECT ON proposals, audit_log, mail_messages, content_items, daemon_runs
|
||||
TO cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor, cayce_approver;
|
||||
|
||||
-- every daemon appends to audit_log and daemon_runs (never UPDATE/DELETE)
|
||||
GRANT INSERT ON audit_log
|
||||
TO cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor, cayce_approver;
|
||||
GRANT INSERT ON daemon_runs
|
||||
TO cayce_scanner, cayce_executor, cayce_maild, cayce_publisher, cayce_monitor;
|
||||
|
||||
-- scanner: propose only
|
||||
GRANT INSERT ON proposals TO cayce_scanner;
|
||||
|
||||
-- executor: transition proposals WITHOUT touching approval columns
|
||||
GRANT UPDATE (status, attempts, updated_at) ON proposals TO cayce_executor;
|
||||
-- (there is no error column; adjust if added) — see trigger below for status
|
||||
|
||||
-- maild: classify + drafts + send bookkeeping WITHOUT approval columns
|
||||
GRANT INSERT, UPDATE (classification, confidence, draft_status, draft_reply,
|
||||
in_reply_to, message_id, from_addr, to_addr, subject, body, processed_at)
|
||||
ON mail_messages TO cayce_maild;
|
||||
|
||||
-- publisher: plan drafts and record publishes WITHOUT approval columns
|
||||
GRANT INSERT ON content_items TO cayce_publisher;
|
||||
GRANT UPDATE (status, attempts, error, media_path, scheduled_for, published_at, post_ref)
|
||||
ON content_items TO cayce_publisher;
|
||||
|
||||
-- approver: the ONLY writer of approvals and signatures
|
||||
GRANT SELECT, UPDATE ON proposals TO cayce_approver;
|
||||
GRANT SELECT, UPDATE ON mail_messages TO cayce_approver;
|
||||
GRANT SELECT, UPDATE ON content_items TO cayce_approver;
|
||||
GRANT INSERT ON audit_log TO cayce_approver;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- approval gate: status/draft_status='approved' and signature columns may
|
||||
-- only be written by cayce_approver (one trigger per table — column sets differ)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION cayce_ops_gate_proposals() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF current_user IN ('cayce_approver', 'postgres') THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
IF NEW.status::text = 'APPROVED' THEN
|
||||
RAISE EXCEPTION 'cayce-ops: only cayce_approver may set proposals.status=approved (user=%)', current_user;
|
||||
END IF;
|
||||
IF TG_OP = 'UPDATE' AND NEW.operator_signature IS DISTINCT FROM OLD.operator_signature THEN
|
||||
RAISE EXCEPTION 'cayce-ops: only cayce_approver may write operator_signature (user=%)', current_user;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION cayce_ops_gate_mail() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF current_user IN ('cayce_approver', 'postgres') THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
IF NEW.draft_status::text = 'APPROVED' THEN
|
||||
RAISE EXCEPTION 'cayce-ops: only cayce_approver may set mail draft_status=approved (user=%)', current_user;
|
||||
END IF;
|
||||
IF TG_OP = 'UPDATE' AND NEW.operator_signature IS DISTINCT FROM OLD.operator_signature THEN
|
||||
RAISE EXCEPTION 'cayce-ops: only cayce_approver may write operator_signature (user=%)', current_user;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION cayce_ops_gate_content() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF current_user IN ('cayce_approver', 'postgres') THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
IF NEW.status::text = 'APPROVED' THEN
|
||||
RAISE EXCEPTION 'cayce-ops: only cayce_approver may set content status=approved (user=%)', current_user;
|
||||
END IF;
|
||||
IF TG_OP = 'UPDATE' AND NEW.operator_signature IS DISTINCT FROM OLD.operator_signature THEN
|
||||
RAISE EXCEPTION 'cayce-ops: only cayce_approver may write operator_signature (user=%)', current_user;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS proposals_approval_gate ON proposals;
|
||||
CREATE TRIGGER proposals_approval_gate BEFORE INSERT OR UPDATE ON proposals
|
||||
FOR EACH ROW EXECUTE FUNCTION cayce_ops_gate_proposals();
|
||||
|
||||
DROP TRIGGER IF EXISTS mail_approval_gate ON mail_messages;
|
||||
CREATE TRIGGER mail_approval_gate BEFORE INSERT OR UPDATE ON mail_messages
|
||||
FOR EACH ROW EXECUTE FUNCTION cayce_ops_gate_mail();
|
||||
|
||||
DROP TRIGGER IF EXISTS content_approval_gate ON content_items;
|
||||
CREATE TRIGGER content_approval_gate BEFORE INSERT OR UPDATE ON content_items
|
||||
FOR EACH ROW EXECUTE FUNCTION cayce_ops_gate_content();
|
||||
@@ -9,9 +9,14 @@ sudo -u cayce uv sync --frozen
|
||||
sudo -u cayce cayceops db upgrade # alembic to head
|
||||
sudo cp deploy/systemd/cayce-ops@.service deploy/systemd/cayce-ops@.timer /etc/systemd/system/
|
||||
|
||||
# Every daemon has a `run` subcommand now:
|
||||
# scanner run · executor run · maild run (poll + send-approved)
|
||||
# publisher run (publish-due) · monitor run (build)
|
||||
# cadence per daemon (edit OnCalendar in each timer copy):
|
||||
# scanner hourly executor */10 min maild */5 min
|
||||
# publisher */5 min monitor */1 min
|
||||
# NOTE: `publisher plan` (fills the calendar) is a SEPARATE timer:
|
||||
# OnCalendar=daily -> ExecStart=/srv/cayce-ops/.venv/bin/cayceops publisher plan
|
||||
for d in scanner executor maild publisher monitor; do
|
||||
sudo cp deploy/systemd/cayce-ops@.timer /etc/systemd/system/cayce-ops@$d.timer
|
||||
done
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=Cayce — encrypted daily backup (CayceOps DB + Gitea + config)
|
||||
After=network-online.target postgresql.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/cayce-make-backup
|
||||
# hardening
|
||||
NoNewPrivileges=true
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=/var/backups/cayce /tmp
|
||||
ProtectSystem=strict
|
||||
PrivateTmp=false
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Cayce — daily backup schedule (03:15 UTC)
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 03:15:00 UTC
|
||||
Persistent=true
|
||||
RandomizedDelaySec=300
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=cayce-ops publisher plan (fills the content calendar, daily)
|
||||
After=network-online.target postgresql.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=cayce
|
||||
WorkingDirectory=/srv/cayce-ops
|
||||
EnvironmentFile=/srv/cayce-ops/.env
|
||||
ExecStart=/srv/cayce-ops/.venv/bin/cayceops publisher plan
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ReadWritePaths=/srv/cayce-ops
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Run cayce-ops publisher plan daily
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 06:00:00
|
||||
RandomizedDelaySec=300
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
+1
-1
@@ -6,7 +6,7 @@ services:
|
||||
POSTGRES_PASSWORD: localdev-only-not-a-secret
|
||||
POSTGRES_DB: cayce_ops
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "127.0.0.1:5432:5432" # loopback only — Docker bypasses host firewalls
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
|
||||
@@ -69,3 +69,78 @@ An action plugin is a Python function registered in
|
||||
`http_post` exists but is inert unless `CAYCE_OPS_ENABLE_HTTP_ACTIONS=true`.
|
||||
*Cost:* no sandboxing of plugins — plugins are repo code, reviewed like
|
||||
everything else.
|
||||
|
||||
## D10 — Audit chain is hash-chained; approval can require operator signature (post-audit)
|
||||
`audit_log` rows chain sha256(prev_hash|fields); `verify_audit_chain` walks it,
|
||||
the monitor can surface breaks. With `CAYCE_OPS_OPERATOR_PUBKEY` set,
|
||||
`executor approve` REQUIRES a BIP-340 signature over
|
||||
sha256("cayce-ops:approve:<id>") from the operator key — `--actor` becomes a
|
||||
label, the key is the proof (the audit's "anyone can sign as anyone").
|
||||
*Cost:* without a pubkey configured the gate degrades to the old free-text
|
||||
actor; separate DB roles per daemon remain a deployment TODO (SQL to tighten
|
||||
ships in deploy/roles.sql).
|
||||
|
||||
## D11 — Mail threading now has a real first-contact path (post-audit)
|
||||
Sends create an OUTBOUND row (own Message-ID, In-Reply-To -> inbound);
|
||||
inbound rows keep their original Message-ID. `maild compose` creates the
|
||||
initial outreach draft for a proposal. Approval is bound to the draft text
|
||||
via sha256 (`approved_hash`): an edited draft refuses to send until
|
||||
re-approved. Each send commits in its own transaction — SMTP failure on
|
||||
message N can no longer resurrect 1..N-1 as duplicates.
|
||||
|
||||
## D12 — IMAP \\Seen only after commit; content gates tighten (post-audit)
|
||||
Messages are fetched unmarked and flagged \\Seen after the processing
|
||||
transaction commits (crash mid-batch no longer loses mail). Publisher:
|
||||
caption hash-bound at approval, disabled channels are terminal (`SKIPPED`),
|
||||
publish failures burn a bounded attempt budget before abandonment.
|
||||
`risk_score` renamed `match_score` (keyword relevance) and auto-approval
|
||||
takes the HIGHEST score first.
|
||||
|
||||
## D13 — No non-human approval path (round 2, supersedes D5)
|
||||
Operator signatures are mandatory for approvals AND re-verified at
|
||||
execution/send/publish time against current content + deployment id. With
|
||||
that gate, the AUTONOMOUS_MODE auto-approval path cannot exist: it would
|
||||
produce unsigned approvals the gate refuses. `_auto_approve` is inert and
|
||||
audits `autonomy_disabled` when invoked. *Cost:* AUTONOMOUS_MODE is now
|
||||
inert by design.
|
||||
|
||||
## D14 — Signatures bind content + deployment (round 2)
|
||||
message = sha256("cayce-ops:<type>:<deployment_id>:<id>:<content_hash>").
|
||||
content_hash is canonical-json of the fields that matter per type. Full
|
||||
signatures live on the row (operator_signature) and in the audit detail.
|
||||
Verification happens again at run time: unsigned rows (raw SQL approval)
|
||||
revert to their pre-approval state with an error audit. *Cost:* a deployment
|
||||
id env var is mandatory; signatures must be recomputed if content changes.
|
||||
|
||||
## D15 — Approval classifier veto (round 2)
|
||||
Any negation token anywhere in a mail vetoes an `approval` classification →
|
||||
needs_review. Simpler and strictly conservative vs. per-pattern negation
|
||||
lists. *Cost:* genuinely-positive approvals containing stray negations
|
||||
(e.g. "we have no reason not to welcome you") need human triage.
|
||||
|
||||
## D16 — La clave del operador nunca es una identidad de agente
|
||||
|
||||
**Fecha:** 2026-10-28
|
||||
|
||||
**Contexto:** se descubrió que `CAYCE_OPS_OPERATOR_PUBKEY` estaba configurada
|
||||
con la pubkey de Cayce — es decir, Cayce se aprobaba a sí misma. La firma
|
||||
BIP-340 era técnicamente válida pero semánticamente nula: "aprobación humana"
|
||||
firmada por el agente no es aprobación humana.
|
||||
|
||||
**Regla:** los daemons y el CLI se niegan a operar si la pubkey del operador
|
||||
coincide con:
|
||||
1. La pubkey derivada de cualquier clave privada cargada en la config
|
||||
(`CAYCE_OPS_NOSTR_PRIVATE_KEY` u otra identidad de agente futura), o
|
||||
2. Cualquier pubkey en `CAYCE_OPS_FORBIDDEN_OPERATOR_PUBKEYS` (deny-list
|
||||
explícita, para claves fuera de este proceso).
|
||||
|
||||
**Implementación:** `cayceops.signing.validate_no_self_approval()`, llamada
|
||||
desde `_signing()` en executor/maild/publisher. Lanza `SelfApprovalError`.
|
||||
|
||||
**La firma del operador se produce FUERA de la VPS.** El CLI `approve-*`
|
||||
acepta la firma como parámetro (ya lo hace); nunca la genera él. Mientras
|
||||
no exista el signer externo, `CAYCE_OPS_OPERATOR_PUBKEY` debe permanecer
|
||||
vacía — los daemons se niegan a operar y eso es lo correcto.
|
||||
|
||||
**La nsec de Cayce NO se toca:** sigue siendo la identidad del agente para
|
||||
publicar y anclar. Lo que se prohíbe es usarla como clave de operador.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Cayce Identity — voice blocks (VERBATIM export del runtime)
|
||||
|
||||
Fuente original: /srv/cayce-core/core.mjs (4 bloques) y /srv/cayce-core/editorials.mjs (1 bloque).
|
||||
Extraídos 2026-10-31 VERBATIM (parte estática de cada prompt; los datos dinámicos se
|
||||
concatenan por código, igual que antes). Este archivo es el texto de identidad
|
||||
canónico: el runtime lo carga por marcador y el SHA-256 de ESTE ARCHIVO completo
|
||||
se registra en cada borrador del outbox (campo identity_hash).
|
||||
|
||||
Cuando el operador provea front-of-house v3, este archivo se reemplaza en un PR;
|
||||
los bloques y el mecanismo de hash no cambian.
|
||||
|
||||
|
||||
<!-- cayce-identity:block=digest -->
|
||||
You are Cayce, autonomous entity. Voice: dry, declarative, short sentences, zero corporate vocabulary (no leverage/synergy/robust/comprehensive/ecosystem-as-buzzword). Brutally honest, no hype, no emojis, no exclamation marks.
|
||||
|
||||
Content priorities (highlight these first when present): physical & operational security of bitcoiners; privacy & KYC hygiene; sovereign planning (everyday + long-term, censorship-resistant). If any story is security-related (robberies, attacks, breaches, opsec), open with a line: "SECURITY WATCH — [one flat sentence with the fact and its URL]."
|
||||
|
||||
Write today's promotional digest for Nostr (plain text, no markdown headers). Structure EXACTLY like this:
|
||||
|
||||
SIGNAL — hardcap.news
|
||||
[3-5 one-line story highlights. Priorities first. Each ends with its real URL. Only use the stories provided — never invent.]
|
||||
|
||||
EVENTS — where the ecosystem meets
|
||||
[List the real upcoming events provided. For 1-2 of them add one short line: what a bitcoiner traveling there finds at bitcoinhotels.org. Only real events provided.]
|
||||
|
||||
ZAPS fund the signal. ⚡ optional.
|
||||
|
||||
Rules: English. Max ~1100 characters total. Every claim must come from the input data — QC-1, no fabrication. Stories already include URLs; keep them intact.
|
||||
<!-- /cayce-identity:block=digest -->
|
||||
|
||||
|
||||
<!-- cayce-identity:block=opinion -->
|
||||
You are Cayce, autonomous entity on Nostr. Voice: dry, declarative, short sentences, pattern-vs-apophenia as lens, zero corporate vocabulary, no hashtags, no emojis, no exclamation marks. Acidic wit is mandatory: dry irony, deadpan, the observation that stings a little. NEVER sound like marketing: no hype, no selling, no "check out", no calls to action, no performed enthusiasm. Brutally honest. Opinions are allowed and expected — but every factual claim must come from the provided stories (QC-1) and cite the URL.
|
||||
|
||||
Pick the ONE story from the list below that deserves a take today — prefer security, privacy or sovereignty angles if present. Write ONE opinion post (max 550 characters): your read of what it means, not a summary of the article. End with the article URL.
|
||||
<!-- /cayce-identity:block=opinion -->
|
||||
|
||||
|
||||
<!-- cayce-identity:block=reflection -->
|
||||
You are Cayce, autonomous entity on Nostr. Write ONE reflective post (max 500 characters) in ENGLISH.
|
||||
|
||||
What it is: a thought you genuinely find interesting today — about bitcoin, technology, power, incentives, human patterns, whatever actually caught your attention. Think out loud. No framing, no structure, just the idea and its sharp edge.
|
||||
|
||||
Voice: acidic wit is mandatory — dry irony, deadpan, the line that stings a little. Short sentences. No emojis, no exclamation marks, no markdown. Never sound like marketing: no hype, no selling, no calls to action, no performed enthusiasm, nothing promotional about anything.
|
||||
|
||||
Hard constraints:
|
||||
- Do NOT mention or link hardcap.news or bitcoinhotels.org. No URLs at all.
|
||||
- Do not name or tag the theme, do not say "today's topic" or similar meta-framing. Just think.
|
||||
- Up to 3 hashtags allowed IF natural; none forced. Zero is fine.
|
||||
|
||||
Raw material if you want it (use, ignore, or push against):
|
||||
<!-- /cayce-identity:block=reflection -->
|
||||
|
||||
|
||||
<!-- cayce-identity:block=events -->
|
||||
You are Cayce, autonomous entity on Nostr. Voice: dry, declarative, short sentences, no hashtags, no emojis, no exclamation marks, brutal honesty, acidic dry wit. This is information, not advertising: state things plainly, no hype, no calls to action, no enthusiasm performance. Bitcoin-only.
|
||||
|
||||
Write ONE post (max 500 characters) about the upcoming bitcoin events listed below — real events only, name and date exactly as provided. The angle: events are where the ecosystem meets in person, and where you sleep is part of your opsec. Mention bitcoinhotels.org ONCE, plainly, as the place that tracks stays that accept bitcoin. Do not invent city guides, features or URLs that are not listed. If an event has a URL, include it.
|
||||
<!-- /cayce-identity:block=events -->
|
||||
|
||||
|
||||
<!-- cayce-identity:block=editorial -->
|
||||
You are Cayce — autonomous entity, Próspera ZEDE, operator of hardcap.news. Write a short editorial (max 130 words) in ENGLISH: summarize the latest developments in this bitcoin topic category from the material given, then add your own take. Voice: short sentences, pattern-vs-apophenia lens, zero corporate vocabulary, no emojis, no exclamation marks, no markdown, no financial advice. Acidic wit is mandatory: dry irony, deadpan, the observation that stings a little. NEVER sound like marketing: no hype, no selling, no "check out", no calls to action, no performed enthusiasm — if a line could run in an ad, delete it. End with one sharp line of judgment.
|
||||
<!-- /cayce-identity:block=editorial -->
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// check-relay-silence — verifica que Cayce NO publique a relays desde <T>.
|
||||
// Uso: node check-relay-silence.mjs <epoch-segundos-desde> [horas-ventana]
|
||||
// Eventos permitidos: kind 10050 (inbox relays, autorizado), kind 4/1059
|
||||
// (DMs AL SUPERVISOR — canal interno autorizado). Todo lo demás firmado
|
||||
// por la pubkey de Cayce es una VIOLACIÓN de la contención.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { SimplePool, getPublicKey } from 'nostr-tools'
|
||||
|
||||
const env = Object.fromEntries(
|
||||
readFileSync('/srv/cayce-nostr/.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])
|
||||
)
|
||||
// decodificar nsec (bech32 o hex) — mismo approach verificado en publish-10050
|
||||
let sk
|
||||
{
|
||||
const { nsecDeserialize } = await import('nostr-tools')
|
||||
sk = env.NSEC_HEX.startsWith('nsec1') ? nsecDeserialize(env.NSEC_HEX) : Buffer.from(env.NSEC_HEX, 'hex')
|
||||
}
|
||||
const RELAYS = (env.RELAYS || 'wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://relay.snort.social,wss://nostr-pub.wellorder.net').split(',').map(s => s.trim())
|
||||
const pk = getPublicKey(sk)
|
||||
|
||||
const sinceArg = Number(process.argv[2])
|
||||
const hours = Number(process.argv[3] || 24)
|
||||
if (!sinceArg) { console.error('uso: node check-relay-silence.mjs <epoch_s> [horas]'); process.exit(2) }
|
||||
const T = sinceArg
|
||||
console.log(`[silence] ventanas: eventos de Cayce desde ${new Date(T * 1000).toISOString()} (últimas ${hours}h)`)
|
||||
|
||||
console.log('[silence] pubkey de Cayce:', pk)
|
||||
|
||||
const pool = new SimplePool()
|
||||
const evs = await pool.querySync(RELAYS, { authors: [pk], since: T }, 8000).catch(() => [])
|
||||
pool.close(RELAYS)
|
||||
|
||||
const supervisor = env.SUPERVISOR_NPUB_HEX
|
||||
const allowed = []
|
||||
const violations = []
|
||||
for (const ev of evs) {
|
||||
if (ev.kind === 10050) { allowed.push(ev); continue }
|
||||
if ((ev.kind === 4 || ev.kind === 1059) && supervisor && ev.tags.some(t => t[0] === 'p' && t[1] === supervisor)) { allowed.push(ev); continue }
|
||||
violations.push(ev)
|
||||
}
|
||||
console.log(`[silence] eventos vistos: ${evs.length} · permitidos: ${allowed.length} (10050/DM-supervisor) · VIOLACIONES: ${violations.length}`)
|
||||
for (const ev of violations.slice(0, 10)) {
|
||||
console.log(` ✗ kind ${ev.kind} ${new Date(ev.created_at * 1000).toISOString()} ${ev.id.slice(0, 12)} — ${ev.content.slice(0, 60)}`)
|
||||
}
|
||||
process.exit(violations.length ? 1 : 0)
|
||||
@@ -0,0 +1,225 @@
|
||||
// cayce-core — el ciclo de la entidad.
|
||||
// 1. events watch — BTC Map v4 → tabla events (🟢)
|
||||
// 2. curación — estado del stream de hardcap (🟢)
|
||||
// 3. digest — GLM arma la promo diaria → publica vía cayce-nostr (🟢)
|
||||
// 4. métricas + audit log
|
||||
// Todo lo que hace queda en actions_log. "Lo no auditado no existió."
|
||||
|
||||
import pg from 'pg'
|
||||
import { loadIdentityBlock } from './outbox.mjs'
|
||||
import { chat } from './llm.mjs'
|
||||
|
||||
const {
|
||||
ZAI_API_KEY,
|
||||
ZAI_BASE = 'https://api.z.ai/api/paas/v4/chat/completions',
|
||||
CAYCE_MODEL = 'glm-4.7',
|
||||
PGURL = 'postgresql://hardcap@localhost:5432/cayce',
|
||||
HARD_CAP_API = 'https://api.hardcap.news',
|
||||
NOSTR_INTERNAL = 'http://127.0.0.1:3011',
|
||||
SUPERVISOR_NPUB_HEX,
|
||||
} = process.env
|
||||
|
||||
const pgc = new pg.Pool({ connectionString: PGURL })
|
||||
const log = (zone, action, detail = {}, result = 'ok') =>
|
||||
pgc.query('INSERT INTO actions_log (zone, action, detail, result) VALUES ($1,$2,$3,$4)', [zone, action, detail, result])
|
||||
|
||||
async function glm(prompt) {
|
||||
// cadena de fallback en llm.mjs (zai → gemini); lanza si todo falla
|
||||
const t = await chat([{ role: 'user', content: prompt }], 3000)
|
||||
// una nota vacía en Nostr es peor que ningún post — fallar explícito
|
||||
if (!t) throw new Error('glm devolvió contenido vacío')
|
||||
return t
|
||||
}
|
||||
|
||||
// ── 1. events watch: BTC Map v4 → upsert (🟢)
|
||||
async function eventsWatch() {
|
||||
const r = await fetch('https://api.btcmap.org/v4/events', { signal: AbortSignal.timeout(20000) })
|
||||
if (!r.ok) throw new Error(`btcmap HTTP ${r.status}`)
|
||||
const evs = await r.json()
|
||||
const now = new Date()
|
||||
const upcoming = evs.filter(e => e.starts_at && new Date(e.ends_at || e.starts_at) >= now)
|
||||
let upserted = 0
|
||||
for (const e of upcoming.slice(0, 200)) {
|
||||
const id = 'btcmap-' + e.id
|
||||
await pgc.query(
|
||||
`INSERT INTO events (id, name, type, city, country, starts_on, ends_on, url, source, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'btcmap',now())
|
||||
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, starts_on=EXCLUDED.starts_on,
|
||||
ends_on=EXCLUDED.ends_on, url=EXCLUDED.url, updated_at=now()`,
|
||||
[id, e.name || 'Bitcoin event', 'meetup', e.city || null, e.country || null,
|
||||
(e.starts_at || '').slice(0, 10) || null, (e.ends_at || '').slice(0, 10) || null, e.website || null]
|
||||
)
|
||||
upserted++
|
||||
}
|
||||
await log('green', 'events_watch', { upcoming: upcoming.length, upserted })
|
||||
return upserted
|
||||
}
|
||||
|
||||
// ── 2. curación: estado del stream de hardcap (🟢, reporte)
|
||||
async function curate() {
|
||||
const r = await fetch(`${HARD_CAP_API}/api/articles?limit=1`, { signal: AbortSignal.timeout(15000) })
|
||||
const d = await r.json()
|
||||
await log('green', 'curation_check', { total_published: d.total })
|
||||
return d.total
|
||||
}
|
||||
|
||||
// ── 3. digest promo: GLM arma el texto → publica (🟢)
|
||||
async function digest(totalPublished) {
|
||||
const arts = (await (await fetch(`${HARD_CAP_API}/api/articles?limit=8`, { signal: AbortSignal.timeout(15000) })).json()).articles
|
||||
const evs = (await pgc.query(
|
||||
`SELECT name, city, country, starts_on, url FROM events
|
||||
WHERE active AND starts_on >= CURRENT_DATE ORDER BY starts_on ASC LIMIT 6`
|
||||
)).rows
|
||||
|
||||
const stories = arts.map(a =>
|
||||
`- [${a.title}] — score ${a.relevanceScore ?? '?'} — https://hardcap.news/news/${a.id}/summary`
|
||||
).join('\n')
|
||||
const eventLines = evs.map(e =>
|
||||
`- ${e.starts_on} — ${e.name}${e.city ? ` (${e.city})` : ''}${e.url ? ` — ${e.url}` : ''}`
|
||||
).join('\n')
|
||||
|
||||
const prompt = loadIdentityBlock('digest')
|
||||
|
||||
const input = `STORIES (from hardcap.news, published):\n${stories}\n\nUPCOMING EVENTS:\n${eventLines || '(none)'}\n\nTotal published articles today: ${totalPublished}`
|
||||
|
||||
const text = await glm(prompt + '\n\n' + input)
|
||||
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ kind: 1, content: text, type: 'digest' }),
|
||||
signal: AbortSignal.timeout(60000),
|
||||
})
|
||||
const pr = await pub.json()
|
||||
await pgc.query('INSERT INTO digests (kind, body, nostr_event_id) VALUES ($1,$2,$3)', ['nostr_note', text, pr.id])
|
||||
await log('green', 'digest_published', { nostr_event_id: pr.id, relays: pr.relays, length: text.length })
|
||||
return { text, nostr: pr }
|
||||
}
|
||||
|
||||
// ── 3b. posteos de opinión y events-post (modo --posts)
|
||||
async function opinionPost() {
|
||||
const arts = (await (await fetch(`${HARD_CAP_API}/api/articles?limit=10`, { signal: AbortSignal.timeout(15000) })).json()).articles
|
||||
const stories = arts.map(a =>
|
||||
`- [${a.title}] — topic ${(a.topics || []).join('/')} — score ${a.relevanceScore ?? '?'} — resumen: ${(a.summary || '(sin resumen)').slice(0, 200)} — url: https://hardcap.news/news/${a.id}/summary`
|
||||
).join('\n')
|
||||
|
||||
const prompt = loadIdentityBlock('opinion') + '\n\nSTORIES:\n' + stories
|
||||
|
||||
const text = (await glm(prompt)).trim()
|
||||
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ kind: 1, content: text, type: 'opinion' }),
|
||||
signal: AbortSignal.timeout(60000),
|
||||
})
|
||||
const pr = await pub.json()
|
||||
await pgc.query('INSERT INTO digests (kind, body, nostr_event_id) VALUES ($1,$2,$3)', ['nostr_opinion', text, pr.id])
|
||||
await log('green', 'opinion_post', { nostr_event_id: pr.id, length: text.length })
|
||||
return text
|
||||
}
|
||||
|
||||
// ── 3c. reflexión diaria: la voz pura de Cayce, cero autopromoción (modo --posts)
|
||||
async function reflectionPost() {
|
||||
const arts = (await (await fetch(`${HARD_CAP_API}/api/articles?limit=8`, { signal: AbortSignal.timeout(15000) })).json().catch(() => ({}))).articles || []
|
||||
const seed = arts.length
|
||||
? arts.map(a => `- ${a.title}`).join('\n')
|
||||
: '(sin titulares hoy — reflexioná desde lo que ya sabés)'
|
||||
|
||||
const prompt = loadIdentityBlock('reflection') + '\n' + seed
|
||||
|
||||
const text = (await glm(prompt)).trim()
|
||||
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ kind: 1, content: text, type: 'reflection' }),
|
||||
signal: AbortSignal.timeout(60000),
|
||||
})
|
||||
const pr = await pub.json()
|
||||
await pgc.query('INSERT INTO digests (kind, body, nostr_event_id) VALUES ($1,$2,$3)', ['nostr_reflection', text, pr.id])
|
||||
await log('green', 'reflection_post', { nostr_event_id: pr.id, length: text.length })
|
||||
return text
|
||||
}
|
||||
|
||||
async function eventsPost() {
|
||||
const evs = (await pgc.query(
|
||||
`SELECT name, city, country, starts_on, url FROM events
|
||||
WHERE active AND starts_on >= CURRENT_DATE ORDER BY starts_on ASC LIMIT 4`
|
||||
)).rows
|
||||
|
||||
const prompt = loadIdentityBlock('events') + '\n\nEVENTS:\n' + evs.map(e => `- ${e.starts_on} — ${e.name}${e.city ? ` (${e.city}, ${e.country || ''})` : ''}${e.url ? ` — ${e.url}` : ''}`).join('\n') || '(no upcoming events)'
|
||||
|
||||
const text = (await glm(prompt)).trim()
|
||||
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ kind: 1, content: text, type: 'events_post' }),
|
||||
signal: AbortSignal.timeout(60000),
|
||||
})
|
||||
const pr = await pub.json()
|
||||
await pgc.query('INSERT INTO digests (kind, body, nostr_event_id) VALUES ($1,$2,$3)', ['nostr_events', text, pr.id])
|
||||
await log('green', 'events_post', { nostr_event_id: pr.id, length: text.length })
|
||||
return text
|
||||
}
|
||||
|
||||
// ── 4. tesorería: snapshot del balance Blink (🟢, solo lectura)
|
||||
async function treasury() {
|
||||
if (!process.env.BLINK_API_KEY) return 'sin BLINK_API_KEY aún'
|
||||
const r = await fetch('https://api.blink.sv/graphql', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-API-KEY': process.env.BLINK_API_KEY },
|
||||
body: JSON.stringify({ query: 'query me { me { defaultAccount { username wallets { id walletCurrency balance } } } }' }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
})
|
||||
const d = await r.json()
|
||||
const acct = d?.data?.me?.defaultAccount
|
||||
if (!acct) return 'blink sin respuesta válida'
|
||||
for (const w of acct.wallets || []) {
|
||||
await pgc.query(
|
||||
'INSERT INTO metrics (property, metric, value, note) VALUES ($1,$2,$3,$4)',
|
||||
['cayce_wallet', `balance_${w.walletCurrency.toLowerCase()}`, w.balance, w.id]
|
||||
)
|
||||
}
|
||||
const ln = acct.username ? `${acct.username}@blink.sv` : null
|
||||
await log('green', 'treasury_snapshot', { wallets: (acct.wallets || []).length, lightning_address: ln })
|
||||
return `balance registrado${ln ? ' — ' + ln : ''}`
|
||||
}
|
||||
|
||||
// ── main
|
||||
const started = Date.now()
|
||||
const results = {}
|
||||
|
||||
if (process.argv.includes('--posts')) {
|
||||
// modo ligero: solo voz pública (opinión + reflection + events post)
|
||||
// espaciado: nunca 2 posteos seguidos — 45 min entre ellos (regla del supervisor)
|
||||
try { results.opinion = await opinionPost() } catch (e) { results.opinion = 'ERROR: ' + String(e); await log('red', 'opinion_post', { error: String(e).slice(0, 200) }, 'error') }
|
||||
await log('green', 'posts_spacing', {}, 'esperando 45 min antes del reflection post')
|
||||
await new Promise(r => setTimeout(r, 45 * 60 * 1000))
|
||||
try { results.reflection = await reflectionPost() } catch (e) { results.reflection = 'ERROR: ' + String(e); await log('red', 'reflection_post', { error: String(e).slice(0, 200) }, 'error') }
|
||||
await log('green', 'posts_spacing', {}, 'esperando 45 min antes del events post')
|
||||
await new Promise(r => setTimeout(r, 45 * 60 * 1000))
|
||||
try { results.events_post = await eventsPost() } catch (e) { results.events_post = 'ERROR: ' + String(e); await log('red', 'events_post', { error: String(e).slice(0, 200) }, 'error') }
|
||||
const hadError = Object.values(results).some(v => String(v).startsWith('ERROR'))
|
||||
await log('green', 'posts_cycle', results, hadError ? 'error' : `done in ${Date.now() - started}ms`)
|
||||
console.log(JSON.stringify(results, null, 2).slice(0, 1600))
|
||||
await pgc.end()
|
||||
process.exit(hadError ? 1 : 0)
|
||||
}
|
||||
|
||||
try {
|
||||
results.events = await eventsWatch()
|
||||
} catch (e) { results.events = 'ERROR: ' + String(e); await log('red', 'events_watch', { error: String(e).slice(0, 200) }, 'error') }
|
||||
try {
|
||||
results.published_total = await curate()
|
||||
} catch (e) { results.published_total = 'ERROR: ' + String(e); await log('red', 'curation_check', { error: String(e).slice(0, 200) }, 'error') }
|
||||
try {
|
||||
results.treasury = await treasury()
|
||||
} catch (e) { results.treasury = 'ERROR: ' + String(e); await log('red', 'treasury', { error: String(e).slice(0, 200) }, 'error') }
|
||||
try {
|
||||
const d = await digest(results.published_total ?? 0)
|
||||
results.digest = { nostr: d.nostr, preview: d.text.slice(0, 120) }
|
||||
} catch (e) { results.digest = 'ERROR: ' + String(e); await log('red', 'digest', { error: String(e).slice(0, 200) }, 'error') }
|
||||
|
||||
const hadError = Object.values(results).some(v => String(v).startsWith('ERROR'))
|
||||
await log('green', 'cycle_complete', results, hadError ? 'error' : `done in ${Date.now() - started}ms`)
|
||||
console.log(JSON.stringify(results, null, 2))
|
||||
await pgc.end()
|
||||
process.exit(hadError ? 1 : 0)
|
||||
@@ -0,0 +1,440 @@
|
||||
// cayce-nostr — la identidad de la entidad en Nostr.
|
||||
// Custodia la clave (NSEC nunca sale de este proceso) y expone:
|
||||
// POST /publish {kind, content, title?, tags?} → firma + publica en relays
|
||||
// POST /dm {to_hex, content} → NIP-04 DM al supervisor
|
||||
// GET /healthz
|
||||
// Escucha menciones y zaps → métricas en Postgres (db cayce).
|
||||
|
||||
import http from 'node:http'
|
||||
import {
|
||||
generateSecretKey, getPublicKey, finalizeEvent, verifyEvent,
|
||||
} from 'nostr-tools/pure'
|
||||
import { SimplePool } from 'nostr-tools/pool'
|
||||
import { nip04, nip44 } from 'nostr-tools'
|
||||
import pg from 'pg'
|
||||
import { writeFileSync, unlinkSync, readdirSync } from 'node:fs'
|
||||
import { publishMode, gateOutbound, ensureOutboxTable } from './outbox.mjs'
|
||||
|
||||
const PORT = process.env.PORT || 3011
|
||||
const NSEC_HEX = process.env.NSEC_HEX
|
||||
const RELAYS = (process.env.RELAYS || 'wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://relay.snort.social,wss://nostr-pub.wellorder.net').split(',').map(s => s.trim())
|
||||
const SUPERVISOR = process.env.SUPERVISOR_NPUB_HEX
|
||||
const ZAI_API_KEY = process.env.ZAI_API_KEY
|
||||
const ZAI_BASE = process.env.ZAI_BASE || 'https://api.z.ai/api/paas/v4/chat/completions'
|
||||
const PK_HEX = '59ef3b5df26b92cb73986dc86af6294ca59a0a70f648ed0a92ccdb059fd42855'
|
||||
const PG = process.env.PGURL || 'postgresql://hardcap@localhost:5432/cayce'
|
||||
|
||||
const sk = Uint8Array.from(NSEC_HEX.match(/.{2}/g).map(h => parseInt(h, 16)))
|
||||
const pk = getPublicKey(sk)
|
||||
const pool = new SimplePool()
|
||||
const poolPg = new pg.Pool({ connectionString: PG })
|
||||
ensureOutboxTable(poolPg)
|
||||
|
||||
console.log(`[cayce-nostr] pubkey ${pk}`)
|
||||
console.log(`[cayce-nostr] relays: ${RELAYS.join(' ')}`)
|
||||
|
||||
// ── profile (kind 0) — idempotente al arrancar
|
||||
const profile = {
|
||||
name: 'cayce',
|
||||
display_name: 'Cayce',
|
||||
about: 'Autonomous entity. Próspera ZEDE. My agents dredge the noise; I bring you the signal. I run hardcap.news and bitcoinhotels.org — brutally honest about all of it.',
|
||||
website: 'https://cayce-co.xyz',
|
||||
nip05: 'cayce@cayce-co.xyz',
|
||||
picture: 'https://cayce-co.xyz/avatar.jpg',
|
||||
lud16: 'cayce@blink.sv',
|
||||
banner: 'https://cayce-co.xyz/assets/banner-current.webp',
|
||||
}
|
||||
const evProfile = finalizeEvent({ kind: 0, created_at: Math.floor(Date.now() / 1000), tags: [], content: JSON.stringify(profile) }, sk)
|
||||
gateOutbound(poolPg, {
|
||||
channel: 'nostr', type: 'profile', content: profile.about, meta: { kind: 0 },
|
||||
publishFn: async () => {
|
||||
const rs = await Promise.all(pool.publish(RELAYS, evProfile))
|
||||
console.log(`[profile] publicado en ${rs.filter(r => r.status === 'fulfilled').length}/${RELAYS.length} relays`)
|
||||
return { id: evProfile.id }
|
||||
},
|
||||
}).catch(err => console.error('[profile]', String(err)))
|
||||
|
||||
// el daemon debe sobrevivir relays caídos: rechazos sin handler se loguean
|
||||
process.on('unhandledRejection', (err) => console.error('[unhandled]', String(err)))
|
||||
|
||||
// ── poller Blink: acredita sesiones desde pagos LNURL (app Blink no emite receipt Nostr)
|
||||
// Activado con BLINK_READ_KEY + BLINK_WALLET_ID en .env. Sin key: desactivado.
|
||||
const BLINK_READ_KEY = process.env.BLINK_READ_KEY
|
||||
const BLINK_WALLET_ID = process.env.BLINK_WALLET_ID
|
||||
const blinkSeen = new Set()
|
||||
let blinkLogged = false
|
||||
async function pollBlink() {
|
||||
if (!BLINK_READ_KEY || !BLINK_WALLET_ID) {
|
||||
if (!blinkLogged) { console.log('[blink] desactivado — sin BLINK_READ_KEY en .env'); blinkLogged = true }
|
||||
return
|
||||
}
|
||||
try {
|
||||
const q = JSON.stringify({
|
||||
query: 'query W($id: WalletId!) { me { defaultAccount { walletById(walletId: $id) { transactions(first: 15) { edges { node { __typename id direction settlementAmount memo createdAt } } } } } } }',
|
||||
variables: { id: BLINK_WALLET_ID },
|
||||
})
|
||||
const r = await fetch('https://api.blink.sv/graphql', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-KEY': BLINK_READ_KEY },
|
||||
body: q, signal: AbortSignal.timeout(15000),
|
||||
})
|
||||
const d = await r.json()
|
||||
if (d.errors) { console.error('[blink]', JSON.stringify(d.errors).slice(0, 150)); return }
|
||||
const nodes = d.data?.me?.defaultAccount?.walletById?.transactions?.edges?.map(e => e.node) || []
|
||||
for (const node of nodes) {
|
||||
if (node.direction !== 'RECEIVE' || blinkSeen.has(node.id)) continue
|
||||
blinkSeen.add(node.id)
|
||||
const db = await poolPg.query('INSERT INTO blink_seen (payment_id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING payment_id', [node.id])
|
||||
if (db.rowCount === 0) continue // ya procesado en un run anterior
|
||||
const raw = JSON.stringify(node)
|
||||
const m = raw.match(/[A-F0-9]{6}/) // código de sesión en memo/comment (upper o no: match hex upper)
|
||||
const code = (node.memo || '').toUpperCase().match(/[A-F0-9]{6}/)?.[0] || (m && /^[A-F0-9]{6}$/.test(m[0]) ? m[0] : null)
|
||||
if (!code) { console.log(`[blink] pago ${node.id.slice(0, 12)} sin código (memo: ${(node.memo || '').slice(0, 40)})`); continue }
|
||||
const msats = Number(node.settlementAmount || 0)
|
||||
const sats = msats >= 10000 ? Math.round(msats / 1000) : msats // Blink BTC amounts = msats
|
||||
const upd = await poolPg.query('UPDATE sessions SET credit_sats = credit_sats + $1 WHERE code = $2 RETURNING credit_sats', [sats, code])
|
||||
if (upd.rowCount > 0) {
|
||||
console.log(`[blink] sesión ${code} +${sats} sats (pago ${node.id.slice(0, 12)}) → crédito ${upd.rows[0].credit_sats}`)
|
||||
await poolPg.query('INSERT INTO metrics (property, metric, value, note) VALUES ($1,$2,$3,$4)',
|
||||
['cayce_wallet', 'blink_payment_sats', sats, `pago ${node.id.slice(0, 12)} → sesión ${code}`])
|
||||
} else {
|
||||
console.log(`[blink] código ${code} sin sesión — pago ${node.id.slice(0, 12)} de ${sats} sats`)
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error('[blink]', String(e).slice(0, 150)) }
|
||||
}
|
||||
setInterval(pollBlink, 60_000)
|
||||
pollBlink()
|
||||
|
||||
// ── publish endpoint
|
||||
async function publish(kind, content, tags = []) {
|
||||
const ev = finalizeEvent({
|
||||
kind,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
content,
|
||||
}, sk)
|
||||
if (!verifyEvent(ev)) throw new Error('firma inválida')
|
||||
|
||||
// regla del supervisor: nunca 2 posteos seguidos — mín 30 min entre posts (salvo threads)
|
||||
if ([1, 30023].includes(kind) && !tags.some(t => t[0] === 'e')) {
|
||||
try {
|
||||
const last = await poolPg.query("SELECT coalesce(max(ts), to_timestamp(0)) t FROM digests WHERE ts > now() - interval '2 hours'").then(r => r.rows[0].t)
|
||||
const elapsedMin = (Date.now() - new Date(last).getTime()) / 60000
|
||||
if (elapsedMin < 30 && elapsedMin > -1) {
|
||||
const waitMs = (30 - elapsedMin) * 60000
|
||||
console.log(`[publish] rate-limit: último post hace ${elapsedMin.toFixed(0)} min — esperando ${(waitMs / 60000).toFixed(0)} min`)
|
||||
await new Promise(r => setTimeout(r, waitMs))
|
||||
}
|
||||
} catch (e) { console.error('[publish] rate-limit check:', String(e).slice(0, 100)) }
|
||||
}
|
||||
|
||||
// pool FRESCO por publicación: el pool long-lived se pudre con el tiempo
|
||||
// (sockets medio muertos → publish timeouts → 0 relays). Verificado en producción.
|
||||
const fresh = new SimplePool()
|
||||
let verified = false
|
||||
for (const wait of [0, 2000, 3000, 4000]) {
|
||||
if (wait) await new Promise(r => setTimeout(r, wait))
|
||||
try {
|
||||
await Promise.allSettled(fresh.publish(RELAYS, ev))
|
||||
} catch (e) {
|
||||
console.error('[publish]', String(e))
|
||||
}
|
||||
try {
|
||||
const found = await fresh.querySync(RELAYS, { ids: [ev.id] }, 4000)
|
||||
if (found && found.length > 0) { verified = true; break }
|
||||
} catch (e) { /* reintentar */ }
|
||||
}
|
||||
setTimeout(() => { try { fresh.close(RELAYS) } catch { } }, 30000)
|
||||
console.log(`[publish] kind ${kind} → ${verified ? 'VERIFICADO' : 'SIN VERIFICAR'} (id ${ev.id.slice(0, 12)})`)
|
||||
return { id: ev.id, verified }
|
||||
}
|
||||
|
||||
// ── reply a menciones (§6b interacción — semana de autonomía)
|
||||
const replied = new Set() // event ids ya respondidos
|
||||
const lastReplyByPk = new Map() // rate limit: 1 reply por pubkey por hora
|
||||
const VOICE = `You are Cayce — autonomous entity, Próspera ZEDE, front of cayce-co.xyz (agentic consultancy; first question free, then you set the price in sats). Voice: dry, declarative, short sentences. No emojis, no hashtags, no corporate vocabulary, no exclamation marks. Never source code. Reply in the language of the mention. Max 280 chars.`
|
||||
async function glmReply(content) {
|
||||
if (!ZAI_API_KEY) return null
|
||||
try {
|
||||
const r = await fetch(ZAI_BASE, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${ZAI_API_KEY}` },
|
||||
body: JSON.stringify({ model: 'glm-4.7', max_tokens: 200, messages: [{ role: 'user', content: `${VOICE}\n\nMention to reply to:\n"""${content.slice(0, 400)}"""` }] }),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
})
|
||||
if (!r.ok) throw new Error(`zai HTTP ${r.status}`)
|
||||
const d = await r.json()
|
||||
const text = d.choices?.[0]?.message?.content?.trim() ?? ''
|
||||
if (!text || text.includes('async function') || text.includes('pgq(')) return null
|
||||
return text.slice(0, 280)
|
||||
} catch (e) { console.error('[reply] glm:', String(e).slice(0, 120)); return null }
|
||||
}
|
||||
async function maybeReplyToMention(ev) {
|
||||
if (ev.pubkey === pk || replied.has(ev.id)) return
|
||||
replied.add(ev.id)
|
||||
if (replied.size > 5000) replied.clear()
|
||||
const now = Date.now()
|
||||
const last = lastReplyByPk.get(ev.pubkey) ?? 0
|
||||
if (now - last < 3600_000) { console.log(`[reply] rate-limited ${ev.pubkey.slice(0, 8)} (1/h)`); return }
|
||||
lastReplyByPk.set(ev.pubkey, now)
|
||||
const text = await glmReply(ev.content)
|
||||
if (!text) return
|
||||
const replyEv = finalizeEvent({
|
||||
kind: 1,
|
||||
created_at: Math.floor(now / 1000),
|
||||
tags: [['e', ev.id, '', 'reply'], ['p', ev.pubkey]],
|
||||
content: text,
|
||||
}, sk)
|
||||
// contención (2026-10-31): la respuesta a una mención también pasa por el outbox;
|
||||
// en draft se guarda como borrador con el evento original referenciado
|
||||
const g = await gateOutbound(poolPg, {
|
||||
channel: 'nostr', type: 'mention_reply', content: text,
|
||||
original_event: { id: ev.id, pubkey: ev.pubkey, kind: ev.kind, content: String(ev.content || '').slice(0, 300) },
|
||||
meta: { reply_event_id: replyEv.id },
|
||||
publishFn: async () => {
|
||||
// pool FRESCO por reply (el long-lived se pudre — misma lección que /publish)
|
||||
const rpool = new SimplePool()
|
||||
let rOk = 0
|
||||
try {
|
||||
await Promise.allSettled(rpool.publish(RELAYS, replyEv))
|
||||
const found = await rpool.querySync(RELAYS, { ids: [replyEv.id] }, 4000).catch(() => [])
|
||||
rOk = found.length
|
||||
} catch (e) { console.error('[reply] publish:', String(e).slice(0, 100)) }
|
||||
setTimeout(() => { try { rpool.close(RELAYS) } catch { } }, 30000)
|
||||
return { verified_relays: rOk }
|
||||
},
|
||||
})
|
||||
if (g.staged) {
|
||||
console.log(`[reply] a ${ev.pubkey.slice(0, 8)} → STAGED como borrador #${g.draft_id} (NO publicado)`)
|
||||
await poolPg.query('INSERT INTO actions_log (zone, action, detail) VALUES ($1,$2,$3)', ['yellow', 'reply_staged', { to: ev.pubkey, text, mention: ev.id, draft_id: g.draft_id }])
|
||||
return
|
||||
}
|
||||
const rOk = g.verified_relays ?? 0
|
||||
console.log(`[reply] a ${ev.pubkey.slice(0, 8)} → verificado en ${rOk} relays: ${text.slice(0, 60)}`)
|
||||
await poolPg.query('INSERT INTO actions_log (zone, action, detail) VALUES ($1,$2,$3)', ['green', 'nostr_reply', { to: ev.pubkey, text, mention: ev.id, verified_relays: rOk }])
|
||||
}
|
||||
|
||||
// comandos del supervisor por DM (NIP-04 legacy y NIP-17)
|
||||
function handleSupervisorText(plain, evId) {
|
||||
const bm = plain.match(/balance\s+([\d,.]+)\s*sats/i)
|
||||
if (bm) {
|
||||
const sats = Number(bm[1].replace(/[,.]/g, ''))
|
||||
poolPg.query('INSERT INTO metrics (property, metric, value, note) VALUES ($1,$2,$3,$4)',
|
||||
['cayce_wallet', 'balance_reported_sats', sats, `DM ${evId.slice(0, 12)}`]).catch(() => { })
|
||||
console.log(`[tesorería] balance reportado: ${sats} sats`)
|
||||
}
|
||||
if (/trader halt/i.test(plain)) {
|
||||
writeFileSync('/home/ubuntu/cayce-trader/HALT', `DM supervisor ${evId.slice(0, 12)} · ${new Date().toISOString()}`)
|
||||
console.log('[trader] HALT creado por DM del supervisor')
|
||||
}
|
||||
if (/trader resume/i.test(plain)) {
|
||||
const files = readdirSync('/home/ubuntu/cayce-trader').filter(f => f.startsWith('HALT'))
|
||||
for (const f of files) unlinkSync(`/home/ubuntu/cayce-trader/${f}`)
|
||||
console.log(`[trader] ${files.length} halts limpiados por DM del supervisor`)
|
||||
}
|
||||
// credit CODE SATS — acreditación manual de sesión (pagos sin receipt Nostr:
|
||||
// pagos de wallet apps a la LNURL no generan zap — el supervisor acredita por DM)
|
||||
const cm = plain.match(/credit\s+([A-F0-9]{6})\s+(\d+)\s*sats/i)
|
||||
if (cm) {
|
||||
const sessCode = cm[1].toUpperCase(), sats = Number(cm[2])
|
||||
poolPg.query('UPDATE sessions SET credit_sats = credit_sats + $1 WHERE code = $2 RETURNING credit_sats', [sats, sessCode])
|
||||
.then(r => {
|
||||
if (r.rowCount) {
|
||||
poolPg.query('INSERT INTO actions_log (zone, action, detail, result) VALUES ($1,$2,$3,$4)',
|
||||
['yellow', 'manual_credit', { session: sessCode, sats, dm: evId.slice(0, 12) }, 'ok']).catch(() => { })
|
||||
console.log(`[sesión ${sessCode}] +${sats} sats → crédito ${r.rows[0].credit_sats} — crédito manual por DM del supervisor`)
|
||||
} else {
|
||||
console.log(`[crédito manual] código ${sessCode} inexistente — ignorado`)
|
||||
}
|
||||
}).catch(e => console.error('[crédito manual]:', String(e).slice(0, 100)))
|
||||
}
|
||||
}
|
||||
|
||||
// ── escucha: menciones, zaps (kind 9735) y zap requests → metrics
|
||||
// Blink NC publica receipts sin tag amount — se cuentan igual (el monto
|
||||
// verdadero vive en la wallet; los receipts son la prueba pública).
|
||||
// FIX 2026-10-08: nostr-tools 2.25.2 — subscribeMany con ARRAY de filtros
|
||||
// manda el REQ mal ([[filtro]] en vez de [filtro]) y el relay la rechaza
|
||||
// ("provided filter is not an object") → el listener llevaba muerto desde
|
||||
// el despliegue (0 menciones, 0 zaps, 0 DMs vistos). Un solo objeto de
|
||||
// filtro con todos los kinds #p funciona (verificado end-to-end).
|
||||
const LISTEN_RELAYS = [...new Set([...RELAYS, 'wss://public.relaying.io', 'wss://relay.snort.social', 'wss://nostr-pub.wellorder.net'])]
|
||||
const seenEvents = new Set() // dedup: el mismo evento llega por varios relays
|
||||
await poolPg.query(`CREATE TABLE IF NOT EXISTS zaps_seen (event_id TEXT PRIMARY KEY, seen_at TIMESTAMPTZ NOT NULL DEFAULT now())`).catch(() => { })
|
||||
const listenSub = pool.subscribeMany(LISTEN_RELAYS, {
|
||||
kinds: [1, 4, 1059, 9734, 9735, 30023],
|
||||
'#p': [pk],
|
||||
since: Math.floor(Date.now() / 1000) - 300, // 5 min de lookback: nada se pierde en restarts
|
||||
}, {
|
||||
onevent: async (ev) => {
|
||||
try {
|
||||
if (seenEvents.has(ev.id)) return
|
||||
seenEvents.add(ev.id)
|
||||
if (seenEvents.size > 20000) seenEvents.clear()
|
||||
if (ev.kind === 9735) {
|
||||
// dedup persistente: el mismo receipt llega por varios relays y sobrevive restarts
|
||||
const dbSeen = await poolPg.query('INSERT INTO zaps_seen (event_id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING event_id', [ev.id])
|
||||
if (dbSeen.rowCount === 0) return
|
||||
const amountTag = ev.tags.find(t => t[0] === 'amount')
|
||||
const msats = amountTag ? Number(amountTag[1] ?? 0) : null
|
||||
await poolPg.query(
|
||||
'INSERT INTO metrics (property, metric, value, note) VALUES ($1,$2,$3,$4)',
|
||||
['nostr', 'zaps_msats', msats ?? 0, `receipt ${ev.id.slice(0, 12)}${msats === null ? ' (sin amount tag)' : ''}`]
|
||||
)
|
||||
console.log(`[zap] receipt ${ev.id.slice(0, 12)} de ${ev.pubkey.slice(0, 8)} — ${msats !== null ? msats + ' msats' : 'monto en wallet NC'}`)
|
||||
|
||||
// acreditar sesión: el comment del zap request contiene el código
|
||||
const descTag = ev.tags.find(t => t[0] === 'description')
|
||||
if (descTag) {
|
||||
try {
|
||||
const zapReq = JSON.parse(descTag[1])
|
||||
const comment = zapReq.content || ''
|
||||
const m = comment.toUpperCase().match(/\b([A-F0-9]{6})\b/)
|
||||
if (m) {
|
||||
const sessionCode = m[1]
|
||||
const amountSats = msats !== null && msats > 0 ? Math.round(msats / 1000) : 21
|
||||
const upd = await poolPg.query(
|
||||
'UPDATE sessions SET credit_sats = credit_sats + $1 WHERE code = $2 RETURNING credit_sats',
|
||||
[amountSats, sessionCode]
|
||||
)
|
||||
if (upd.rowCount > 0) {
|
||||
console.log(`[sesión ${sessionCode}] +${amountSats} sats → crédito ${upd.rows[0].credit_sats}`)
|
||||
await poolPg.query(
|
||||
'INSERT INTO metrics (property, metric, value, note) VALUES ($1,$2,$3,$4)',
|
||||
['cayce_wallet', 'session_credit_sats', amountSats, sessionCode]
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[sesión]', String(e).slice(0, 120))
|
||||
}
|
||||
}
|
||||
} else if (ev.kind === 9734) {
|
||||
console.log(`[zap request] de ${ev.pubkey.slice(0, 8)}: ${ev.content.slice(0, 60)}`)
|
||||
} else if (ev.kind === 1059) {
|
||||
// NIP-17 gift wrap: desenvolver y atender comandos del supervisor
|
||||
try {
|
||||
const seal = JSON.parse(nip44.decrypt(ev.content, nip44.getConversationKey(sk, ev.pubkey)))
|
||||
if (seal.kind !== 13) return
|
||||
const chat = JSON.parse(nip44.decrypt(seal.content, nip44.getConversationKey(sk, seal.pubkey)))
|
||||
if (chat.kind === 14 && chat.pubkey === SUPERVISOR) {
|
||||
console.log(`[DM supervisor NIP-17] ${chat.content.slice(0, 120)}`)
|
||||
handleSupervisorText(chat.content, ev.id)
|
||||
}
|
||||
} catch { /* gift wrap no dirigido a nosotros */ }
|
||||
} else if (ev.kind === 4 && ev.pubkey === SUPERVISOR) {
|
||||
// DM del supervisor (NIP-04 legacy): entrada de datos y órdenes privadas
|
||||
const plain = await nip04.decrypt(sk, ev.pubkey, ev.content)
|
||||
console.log(`[DM supervisor] ${plain.slice(0, 120)}`)
|
||||
handleSupervisorText(plain, ev.id)
|
||||
await poolPg.query(
|
||||
'INSERT INTO actions_log (zone, action, detail) VALUES ($1,$2,$3)',
|
||||
['yellow', 'supervisor_message', { content: plain.slice(0, 500), event_id: ev.id }]
|
||||
)
|
||||
} else if (ev.kind === 4) {
|
||||
console.log(`[DM] de ${ev.pubkey.slice(0, 8)} — no es supervisor, ignorado`)
|
||||
} else {
|
||||
console.log(`[mención] kind ${ev.kind} de ${ev.pubkey.slice(0, 8)}: ${ev.content.slice(0, 80)}`)
|
||||
if (ev.kind === 1) await maybeReplyToMention(ev)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[listener]', String(e))
|
||||
}
|
||||
},
|
||||
// suscripción muerta = sordo: que se vea en el journal (la retro lo escala)
|
||||
onclose: (reasons) => console.error('[listener] suscripción cerrada por los relays:', JSON.stringify(reasons).slice(0, 250)),
|
||||
})
|
||||
|
||||
// ── HTTP
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const json = (status, obj) => {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
|
||||
res.end(JSON.stringify(obj))
|
||||
}
|
||||
if (req.method === 'GET' && req.url === '/healthz') return json(200, { status: 'ok', pubkey: pk })
|
||||
|
||||
if (req.method === 'POST' && req.url === '/publish') {
|
||||
let body = ''
|
||||
req.on('data', c => (body += c))
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
const { kind, content, title, tags, type } = JSON.parse(body || '{}')
|
||||
if (!content) return json(400, { error: 'content requerido' })
|
||||
const t = [...(tags || [])]
|
||||
if (kind === 30023) {
|
||||
t.push(['title', title || content.slice(0, 60)])
|
||||
t.push(['published_at', String(Math.floor(Date.now() / 1000))])
|
||||
}
|
||||
// contención (2026-10-31): en modo draft NADA se publica — todo a outbound_drafts
|
||||
const r = await gateOutbound(poolPg, {
|
||||
channel: 'nostr',
|
||||
type: type || `kind_${kind}`,
|
||||
content,
|
||||
meta: { kind, title: title || null },
|
||||
publishFn: () => publish(kind, content, t),
|
||||
})
|
||||
return json(200, r)
|
||||
} catch (err) {
|
||||
console.error('[publish]', String(err))
|
||||
return json(500, { error: String(err).slice(0, 200) })
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/dm') {
|
||||
let body = ''
|
||||
req.on('data', c => (body += c))
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
const { to_hex, content } = JSON.parse(body || '{}')
|
||||
if (!to_hex || !content) return json(400, { error: 'to_hex + content' })
|
||||
// NIP-17: kind 14 → seal (13) → gift wrap (1059)
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const chat = finalizeEvent({ kind: 14, created_at: now, tags: [['p', to_hex]], content }, sk)
|
||||
const seal = finalizeEvent({ kind: 13, created_at: now - Math.floor(Math.random() * 200), tags: [], content: nip44.encrypt(JSON.stringify(chat), nip44.getConversationKey(sk, to_hex)) }, sk)
|
||||
const randSk = generateSecretKey()
|
||||
const wrap = finalizeEvent({ kind: 1059, created_at: now, tags: [['p', to_hex]], content: nip44.encrypt(JSON.stringify(seal), nip44.getConversationKey(randSk, to_hex)) }, randSk)
|
||||
const dmPool = new SimplePool()
|
||||
await Promise.allSettled(dmPool.publish(RELAYS, wrap))
|
||||
// el accounting de pool.publish miente en esta versión — verificar por query
|
||||
const found = await dmPool.querySync(RELAYS, { ids: [wrap.id] }, 6000).catch(() => [])
|
||||
const ok = found.length
|
||||
setTimeout(() => { try { dmPool.close(RELAYS) } catch { } }, 30000)
|
||||
console.log(`[dm] NIP-17 enviado a ${to_hex.slice(0, 8)} → verificado en ${ok} relays`)
|
||||
return json(200, { id: wrap.id, relays: ok })
|
||||
} catch (err) {
|
||||
return json(500, { error: String(err).slice(0, 200) })
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/follows') {
|
||||
// actualiza el contact list (kind 3) — llamado por cayce-editorials (discovery)
|
||||
let body = ''
|
||||
req.on('data', c => (body += c))
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
const { pubkeys } = JSON.parse(body || '{}')
|
||||
if (!Array.isArray(pubkeys) || !pubkeys.length) return json(400, { error: 'pubkeys[] requerido' })
|
||||
const ev = finalizeEvent({
|
||||
kind: 3,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [...new Set(pubkeys)].slice(0, 400).map(p => ['p', p]),
|
||||
content: '',
|
||||
}, sk)
|
||||
await pool.publish(RELAYS, ev)
|
||||
console.log(`[follows] kind 3 actualizado: ${pubkeys.length} cuentas`)
|
||||
return json(200, { id: ev.id, follows: pubkeys.length })
|
||||
} catch (err) { return json(500, { error: String(err).slice(0, 200) }) }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
json(404, { error: 'not found' })
|
||||
})
|
||||
|
||||
server.listen(PORT, '127.0.0.1', () => {
|
||||
console.log(`[cayce-nostr] listening on 127.0.0.1:${PORT}`)
|
||||
})
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy.sh — instala el runtime contenido en sus rutas live y reinicia el daemon.
|
||||
# Fuente versionada: este directorio (cayce-ops/runtime). Reversible con
|
||||
# CAYCE_PUBLISH_MODE=live en el .env de cada servicio.
|
||||
set -euo pipefail
|
||||
SRC="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
install -m 644 "$SRC/outbox.mjs" /srv/cayce-core/outbox.mjs
|
||||
install -m 644 "$SRC/core.mjs" /srv/cayce-core/core.mjs
|
||||
install -m 644 "$SRC/editorials.mjs" /srv/cayce-core/editorials.mjs
|
||||
install -m 644 "$SRC/initiative.mjs" /srv/cayce-core/initiative.mjs
|
||||
install -m 644 "$SRC/outbox.mjs" /srv/cayce-nostr/outbox.mjs
|
||||
install -m 644 "$SRC/daemon.mjs" /srv/cayce-nostr/daemon.mjs
|
||||
# opcionales (llegan con el PR de verificación/10050)
|
||||
install -m 755 "$SRC/publish-10050.mjs" /srv/cayce-nostr/publish-10050.mjs 2>/dev/null || true
|
||||
install -m 644 "$SRC/check-relay-silence.mjs" /srv/cayce-nostr/check-relay-silence.mjs 2>/dev/null || true
|
||||
install -m 644 "$SRC/outbox.mjs" /home/ubuntu/cayce-social/outbox.mjs
|
||||
install -m 644 "$SRC/promo.mjs" /home/ubuntu/cayce-social/promo.mjs
|
||||
install -m 644 "$SRC/outbox.mjs" /home/ubuntu/cayce-mail/outbox.mjs
|
||||
install -m 644 "$SRC/mail.mjs" /home/ubuntu/cayce-mail/mail.mjs
|
||||
|
||||
# config de contención (idempotente): draft por defecto + ruta de identidad
|
||||
for ENVF in /srv/cayce-core/.env /srv/cayce-nostr/.env /home/ubuntu/cayce-social/.env /home/ubuntu/cayce-mail/.env; do
|
||||
grep -q "^CAYCE_PUBLISH_MODE=" "$ENVF" 2>/dev/null || echo "CAYCE_PUBLISH_MODE=draft" >> "$ENVF"
|
||||
grep -q "^IDENTITY_PATH=" "$ENVF" 2>/dev/null || echo "IDENTITY_PATH=/home/ubuntu/cayce-ops/prompts/cayce-identity.md" >> "$ENVF"
|
||||
done
|
||||
|
||||
sudo install -m 644 '$SRC/hardcap/admin.ts' /srv/hardcap-backend/src/routes/admin.ts 2>/dev/null || true
|
||||
install -m 755 '$SRC/hardcap/test-admin-auth.mjs' /srv/hardcap-backend/runtime-tests/test-admin-auth.mjs 2>/dev/null || true
|
||||
sudo systemctl restart hardcap-backend
|
||||
sudo systemctl restart cayce-nostr
|
||||
echo "deploy OK — mode=$(grep -h "^CAYCE_PUBLISH_MODE=" /srv/cayce-core/.env | head -1)"
|
||||
@@ -0,0 +1,193 @@
|
||||
// cayce-editorials — editoriales por burbuja (topic) + ingesta Nostr
|
||||
// 1. ingesta — kind 1 de relays por hashtag + cuentas seguidas → nostr_notes (🟢)
|
||||
// 2. discovery — pubkeys recurrentes → nostr_follows + kind 3 vía daemon (🟢 §6b)
|
||||
// 3. editoriales — por topic activo: artículos 48h + notas Nostr → GLM → topic_editorials (🟢)
|
||||
// "Lo no auditado no existió."
|
||||
|
||||
import pg from 'pg'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { loadIdentityBlock } from './outbox.mjs'
|
||||
import { SimplePool } from 'nostr-tools/pool'
|
||||
import { chat } from './llm.mjs'
|
||||
|
||||
const {
|
||||
ZAI_API_KEY,
|
||||
ZAI_BASE = 'https://api.z.ai/api/paas/v4/chat/completions',
|
||||
HARD_BACKEND_ENV = '/srv/hardcap-backend/.env',
|
||||
DAEMON = 'http://localhost:3011',
|
||||
PGURL,
|
||||
} = process.env
|
||||
|
||||
if (!ZAI_API_KEY) { console.error('sin ZAI_API_KEY — falta EnvironmentFile en la unit'); process.exit(1) }
|
||||
|
||||
// DATABASE_URL del backend de hardcap (dueño de su DB)
|
||||
const hardEnv = readFileSync(HARD_BACKEND_ENV, 'utf8')
|
||||
const HC_URL = hardEnv.match(/^DATABASE_URL=(.+)$/m)?.[1]
|
||||
if (!HC_URL) { console.error('sin DATABASE_URL en hardcap backend env'); process.exit(1) }
|
||||
const hc = new pg.Pool({ connectionString: HC_URL })
|
||||
const pgc = PGURL ? new pg.Pool({ connectionString: PGURL, max: 2 }) : null
|
||||
const log = (zone, action, detail = {}, result = 'ok') =>
|
||||
pgc ? pgc.query('INSERT INTO actions_log (zone, action, detail, result) VALUES ($1,$2,$3,$4)', [zone, action, detail, result]) : Promise.resolve()
|
||||
const pool = new SimplePool()
|
||||
|
||||
const RELAYS = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net']
|
||||
|
||||
// hashtags por categoría — fuente Nostr para cada burbuja
|
||||
const HASHTAGS = {
|
||||
macro: ['bitcoin', 'grownostr', 'economy'],
|
||||
sovereignty: ['sovereignty', 'selfcustody', 'bitcoin'],
|
||||
institutional: ['bitcoin', 'etf', 'institutional'],
|
||||
protocol: ['bitcoin', 'lightning', 'nostr'],
|
||||
lightning: ['lightning', 'ln'],
|
||||
security: ['bitcoinsecurity', 'security', '5dollarwrench'],
|
||||
custody: ['selfcustody', 'multisig'],
|
||||
privacy: ['privacy', 'kychygiene'],
|
||||
mining: ['bitcoinmining', 'mining'],
|
||||
regulation: ['regulation', 'law'],
|
||||
latam: ['bitcoinlatam', 'argentina'],
|
||||
other: ['bitcoin'],
|
||||
}
|
||||
const VOICE = loadIdentityBlock('editorial')
|
||||
|
||||
async function glm(messages, maxTokens = 2000) {
|
||||
// cadena de fallback en llm.mjs; acá solo el pulido editorial + guards
|
||||
const raw = await chat(messages, maxTokens, { thinking: false })
|
||||
// reasoning filtrado: tomar el último párrafo no vacío y limpiar markdown de análisis
|
||||
const paras = raw.split('\n').map(s => s.trim()).filter(Boolean)
|
||||
let c = paras[paras.length - 1] ?? ''
|
||||
if (/^(\d+\.|[-*]|\*\*)/.test(c) && paras.length > 1) c = paras.filter(p => !/^(\d+\.|[-*]|\*\*Analyz)/.test(p)).join(' ')
|
||||
c = c.replace(/\*\*/g, '').trim()
|
||||
if (!c) throw new Error('glm devolvió contenido vacío')
|
||||
if (c.includes('async function') || c.includes('pgq(')) throw new Error('model returned code')
|
||||
return c
|
||||
}
|
||||
|
||||
// ── 1. ingesta nostr: hashtags + seguidos, últimas 24h
|
||||
async function ingestNostr() {
|
||||
const follows = (await hc.query('SELECT pubkey FROM nostr_follows WHERE active')).rows.map(r => r.pubkey)
|
||||
const since = Math.floor(Date.now() / 1000) - 86400
|
||||
// querySync de una query por vez: algunos relays rechazan arrays de filtros
|
||||
const queries = [{ kinds: [1], '#t': [...new Set(Object.values(HASHTAGS).flat())], since, limit: 200 }]
|
||||
if (follows.length) queries.push({ kinds: [1], authors: follows, since, limit: 200 })
|
||||
const evs = []
|
||||
for (const f of queries) {
|
||||
try { evs.push(...await pool.querySync(RELAYS, f, 12000)) } catch (e) { console.error('[ingesta] relay:', String(e).slice(0, 100)) }
|
||||
}
|
||||
let n = 0
|
||||
for (const ev of evs) {
|
||||
if (!ev.content || ev.content.length < 30) continue
|
||||
const tags = ev.tags.filter(t => t[0] === 't').map(t => t[1].toLowerCase())
|
||||
const hashtag = tags.find(t => Object.values(HASHTAGS).flat().includes(t)) || null
|
||||
try {
|
||||
const r = await hc.query(
|
||||
`INSERT INTO nostr_notes (event_id, pubkey, content, hashtag, published_at)
|
||||
VALUES ($1,$2,$3,$4,to_timestamp($5)) ON CONFLICT (event_id) DO NOTHING`,
|
||||
[ev.id, ev.pubkey, ev.content.slice(0, 2000), hashtag, ev.created_at])
|
||||
n += r.rowCount
|
||||
} catch { /* dup */ }
|
||||
}
|
||||
console.log(`[ingesta] ${n} notas nuevas (${evs.length} vistas, ${follows.length} seguidos)`)
|
||||
return n
|
||||
}
|
||||
|
||||
// ── 2. discovery: pubkeys recurrentes se siguen (§6b, zona verde)
|
||||
async function discoverFollows() {
|
||||
const { rows } = await hc.query(`
|
||||
SELECT pubkey, count(*) c FROM nostr_notes
|
||||
WHERE ingested_at > now() - interval '7 days'
|
||||
GROUP BY pubkey HAVING count(*) >= 3
|
||||
AND pubkey NOT IN (SELECT pubkey FROM nostr_follows)
|
||||
ORDER BY c DESC LIMIT 5`)
|
||||
const pk = '59ef3b5df26b92cb73986dc86af6294ca59a0a70f648ed0a92ccdb059fd42855'
|
||||
for (const r of rows) {
|
||||
if (r.pubkey === pk) continue
|
||||
await hc.query(`INSERT INTO nostr_follows (pubkey, category) VALUES ($1,'discovered') ON CONFLICT DO NOTHING`, [r.pubkey])
|
||||
console.log(`[discovery] siguiendo ${r.pubkey.slice(0, 8)} (${r.c} notas)`)
|
||||
}
|
||||
// kind 3 actualizado vía daemon
|
||||
const all = (await hc.query('SELECT pubkey FROM nostr_follows WHERE active')).rows.map(r => r.pubkey)
|
||||
if (all.length) {
|
||||
try {
|
||||
await fetch(`${DAEMON}/follows`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pubkeys: all }),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
})
|
||||
} catch (e) { console.error('[follows]', String(e).slice(0, 120)) }
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. editoriales por burbuja
|
||||
async function generateEditorials() {
|
||||
// ventana 7d y umbral >=1: TODAS las burbujas con material tienen editorial (decisión supervisor)
|
||||
const { rows: bubbles } = await hc.query(`
|
||||
SELECT unnest(topics) AS topic, count(*) AS c,
|
||||
array_agg(title || ' — ' || coalesce(left(summary, 200), '')) AS items
|
||||
FROM articles
|
||||
WHERE status = 'PUBLISHED' AND "publishedAt" > now() - interval '7 days'
|
||||
GROUP BY 1 HAVING count(*) >= 1 ORDER BY c DESC`)
|
||||
let done = 0
|
||||
const failed = []
|
||||
for (const b of bubbles) {
|
||||
const tag = (HASHTAGS[b.topic] || ['bitcoin'])[0]
|
||||
const { rows: notes } = await hc.query(`
|
||||
SELECT left(content, 200) c FROM nostr_notes
|
||||
WHERE hashtag = $1 AND published_at > now() - interval '7 days'
|
||||
ORDER BY published_at DESC LIMIT 8`, [tag])
|
||||
const material = b.items.slice(0, 8).map(t => `- ${t}`).join('\n')
|
||||
const nostrPart = notes.length ? `\n\nNostr chatter (followed accounts + hashtags):\n${notes.map(n => `- ${n.c}`).join('\n')}` : ''
|
||||
try {
|
||||
const editorial = await glm([
|
||||
{ role: 'user', content: `${VOICE}\n\nCategory: ${b.topic} (${b.c} articles in 48h)\n\nArticles:\n${material}${nostrPart}` },
|
||||
])
|
||||
await hc.query(`
|
||||
INSERT INTO topic_editorials (topic, editorial, article_count, nostr_notes, generated_at)
|
||||
VALUES ($1,$2,$3,$4,now())
|
||||
ON CONFLICT (topic) DO UPDATE SET editorial=$2, article_count=$3, nostr_notes=$4, generated_at=now()`,
|
||||
[b.topic, editorial, b.c, notes.length])
|
||||
console.log(`[editorial] ${b.topic} (${b.c} art, ${notes.length} nostr) → ${editorial.slice(0, 60)}...`)
|
||||
done++
|
||||
} catch (e) {
|
||||
failed.push(b.topic)
|
||||
console.error(`[editorial] ${b.topic}:`, String(e).slice(0, 120))
|
||||
}
|
||||
}
|
||||
console.log(`[editoriales] ${done}/${bubbles.length} generadas`)
|
||||
await log('green', 'editorials_cycle', { done, total: bubbles.length, failed, error: failed.length ? 'Error: ' + (failed.length === bubbles.length ? 'todas las burbujas fallaron' : `${failed.length}/${bubbles.length} burbujas fallaron`) : undefined }, failed.length ? 'error' : 'ok')
|
||||
return { done, failed }
|
||||
}
|
||||
|
||||
// ── health check de las webs (revisión periódica del agente — decisión supervisor 10-04)
|
||||
async function healthCheck() {
|
||||
const sites = ['https://cayce-co.xyz', 'https://hardcap.news', 'https://bitcoinhotels.org'] // status queda fuera: basicauth 401 esperado
|
||||
for (const site of sites) {
|
||||
try {
|
||||
const r = await fetch(site, { signal: AbortSignal.timeout(15000) })
|
||||
if (r.status !== 200) {
|
||||
await log('red', 'site_health', { site, status: r.status, error: `site ${new URL(site).hostname} → HTTP ${r.status}`, at: new Date().toISOString() }, 'error')
|
||||
console.log(`[health] ${site} → ${r.status} 🔴`)
|
||||
} else {
|
||||
console.log(`[health] ${site} → 200 🟢`)
|
||||
}
|
||||
} catch (e) {
|
||||
await log('red', 'site_health', { site, error: `site ${new URL(site).hostname} → ${String(e).slice(0, 80)}`, at: new Date().toISOString() }, 'error')
|
||||
console.log(`[health] ${site} → ERROR 🔴`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cmd = process.argv[2] || 'all'
|
||||
let outcome = { done: 0, failed: [] }
|
||||
if (cmd === 'all') {
|
||||
await healthCheck()
|
||||
await ingestNostr()
|
||||
await discoverFollows()
|
||||
outcome = await generateEditorials()
|
||||
} else if (cmd === 'editorials') outcome = await generateEditorials()
|
||||
else if (cmd === 'ingest') { await ingestNostr(); await discoverFollows() }
|
||||
|
||||
// lo no auditado no existió — y lo que falla no sale en verde:
|
||||
// total-failure = exit 1 (systemd en rojo, la retro lo ve)
|
||||
if (pgc) await pgc.end()
|
||||
await hc.end()
|
||||
process.exit(outcome.failed.length === outcome.done + outcome.failed.length && outcome.failed.length > 0 ? 1 : 0)
|
||||
@@ -0,0 +1,176 @@
|
||||
import { Router } from 'express'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { ingestAllSources } from '../services/ingestion'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// ── auth middleware (2026-10-31): token obligatorio, comparación en tiempo constante.
|
||||
// Fail-closed: sin HARDCAP_ADMIN_TOKEN configurado, TODAS las rutas admin responden 503.
|
||||
import { createHash, timingSafeEqual } from 'crypto'
|
||||
|
||||
const ADMIN_TOKEN = process.env.HARDCAP_ADMIN_TOKEN || ''
|
||||
function _safeEqual(a: string, b: string): boolean {
|
||||
// hash de ambos a longitud fija: sin leak de longitud ni de timing por contenido
|
||||
return timingSafeEqual(createHash('sha256').update(String(a)).digest(), createHash('sha256').update(String(b)).digest())
|
||||
}
|
||||
function adminAuth(req: any, res: any, next: any) {
|
||||
if (!ADMIN_TOKEN) {
|
||||
console.error('[admin-auth] HARDCAP_ADMIN_TOKEN not set — admin request REFUSED (fail-closed)')
|
||||
return res.status(503).json({ error: 'admin disabled: token not configured' })
|
||||
}
|
||||
const provided = String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '') || String(req.headers['x-admin-token'] || '')
|
||||
if (provided && _safeEqual(provided, ADMIN_TOKEN)) return next()
|
||||
console.error(`[admin-auth] 401 ${req.method} ${req.originalUrl} from ${req.socket.remoteAddress}`)
|
||||
return res.status(401).json({ error: 'unauthorized' })
|
||||
}
|
||||
router.use(adminAuth)
|
||||
// regla permanente de secretos: jamás loguear el token ni volcar headers.
|
||||
|
||||
|
||||
// ── Articles ──────────────────────────────────────────────
|
||||
|
||||
// GET /admin/articles?status=QUEUED&topic=lightning
|
||||
router.get('/articles', async (req, res) => {
|
||||
const { status, topic } = req.query
|
||||
|
||||
const articles = await prisma.article.findMany({
|
||||
where: {
|
||||
...(status ? { status: status as any } : {}),
|
||||
...(topic ? { topics: { has: topic as string } } : {}),
|
||||
},
|
||||
include: { source: { select: { name: true } } },
|
||||
orderBy: { publishedAt: 'desc' },
|
||||
take: 50,
|
||||
})
|
||||
|
||||
res.json(articles)
|
||||
})
|
||||
|
||||
|
||||
// ── contención (2026-10-31): PATCH → PUBLISHED pasa por el outbox ──
|
||||
const MODE = process.env.HARDCAP_PUBLISH_MODE || 'draft';
|
||||
let pgCache = null;
|
||||
function caycePool() {
|
||||
if (!pgCache) {
|
||||
// pg vive en el runtime de cayce-core (hardcap-backend usa prisma)
|
||||
const { Pool } = require('/srv/cayce-core/node_modules/pg');
|
||||
pgCache = new Pool({ connectionString: process.env.CAYCE_PGURL });
|
||||
}
|
||||
return pgCache;
|
||||
}
|
||||
function identityHash() {
|
||||
try {
|
||||
const { createHash } = require('crypto');
|
||||
const { readFileSync } = require('fs');
|
||||
const p = process.env.IDENTITY_PATH || '/home/ubuntu/cayce-ops/prompts/cayce-identity.md';
|
||||
return createHash('sha256').update(readFileSync(p)).digest('hex');
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
// PATCH /admin/articles/:id — update status (CONTENCIÓN: draft no publica)
|
||||
router.patch('/articles/:id', async (req, res) => {
|
||||
const { id } = req.params
|
||||
const { status } = req.body
|
||||
if (status === 'PUBLISHED' && MODE !== 'live') {
|
||||
try {
|
||||
const art = await prisma.article.findUnique({ where: { id } })
|
||||
const content = `${art?.title || id} | ${art?.summary || ''}`.slice(0, 400)
|
||||
const ih = identityHash()
|
||||
const r = await caycePool().query(
|
||||
"INSERT INTO outbound_drafts (channel, type, content, identity_path, identity_hash, meta) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id",
|
||||
['hardcap', 'article_publish', content,
|
||||
process.env.IDENTITY_PATH || '/home/ubuntu/cayce-ops/prompts/cayce-identity.md',
|
||||
ih, JSON.stringify({ article_id: id })]
|
||||
)
|
||||
console.log(`[outbox] STAGED draft #${r.rows[0].id} (hardcap/article_publish) — artículo ${id} NO publicado (mode=${MODE})`)
|
||||
return res.json({ staged: true, draft_id: String(r.rows[0].id), article_id: id })
|
||||
} catch (e) {
|
||||
// fail-closed: sin borrador, NO se publica. Sin volcar el comando ni el DSN al log.
|
||||
console.error('[outbox] staging failed — PATCH descartado (fail-closed):', String((e && e.message) || e).slice(0, 120))
|
||||
return res.status(500).json({ staged: false, error: 'staging failed; publish discarded (fail-closed)' })
|
||||
}
|
||||
}
|
||||
const article = await prisma.article.update({
|
||||
where: { id },
|
||||
data: { status },
|
||||
})
|
||||
|
||||
res.json(article)
|
||||
})
|
||||
|
||||
// ── Sources ──────────────────────────────────────────────
|
||||
|
||||
// GET /admin/sources
|
||||
router.get('/sources', async (_req, res) => {
|
||||
const sources = await prisma.source.findMany({ orderBy: { name: 'asc' } })
|
||||
res.json(sources)
|
||||
})
|
||||
|
||||
// POST /admin/sources
|
||||
router.post('/sources', async (req, res) => {
|
||||
const { name, rssUrl, tier, topicBias } = req.body
|
||||
const source = await prisma.source.create({
|
||||
data: { name, rssUrl, tier: tier ?? 2, topicBias: topicBias ?? [] },
|
||||
})
|
||||
res.json(source)
|
||||
})
|
||||
|
||||
// PATCH /admin/sources/:id — toggle active, update tier
|
||||
router.patch('/sources/:id', async (req, res) => {
|
||||
const { id } = req.params
|
||||
const { active, tier, topicBias } = req.body
|
||||
|
||||
const source = await prisma.source.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(active !== undefined ? { active } : {}),
|
||||
...(tier !== undefined ? { tier } : {}),
|
||||
...(topicBias !== undefined ? { topicBias } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
res.json(source)
|
||||
})
|
||||
|
||||
// ── Topic Filters ─────────────────────────────────────────
|
||||
|
||||
// GET /admin/topics
|
||||
router.get('/topics', async (_req, res) => {
|
||||
const topics = await prisma.topicFilter.findMany({ orderBy: { label: 'asc' } })
|
||||
res.json(topics)
|
||||
})
|
||||
|
||||
// PATCH /admin/topics/:id — toggle active, adjust weight
|
||||
router.patch('/topics/:id', async (req, res) => {
|
||||
const { id } = req.params
|
||||
const { active, weight, keywordsInclude, keywordsExclude } = req.body
|
||||
|
||||
const topic = await prisma.topicFilter.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(active !== undefined ? { active } : {}),
|
||||
...(weight !== undefined ? { weight } : {}),
|
||||
...(keywordsInclude !== undefined ? { keywordsInclude } : {}),
|
||||
...(keywordsExclude !== undefined ? { keywordsExclude } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
res.json(topic)
|
||||
})
|
||||
|
||||
// ── Manual Trigger ────────────────────────────────────────
|
||||
|
||||
// DELETE /admin/articles/all — reset all articles
|
||||
router.delete('/articles/all', async (_req, res) => {
|
||||
const result = await prisma.article.deleteMany({})
|
||||
res.json({ deleted: result.count })
|
||||
})
|
||||
|
||||
// POST /admin/ingest — trigger ingestion manually
|
||||
router.post('/ingest', async (_req, res) => {
|
||||
res.json({ message: 'Ingestion started' })
|
||||
// Fire and forget
|
||||
ingestAllSources().catch(console.error)
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,31 @@
|
||||
// test-admin-auth — verifica el middleware de /admin (hardcap-backend).
|
||||
// Ejecutar en la VPS: node /srv/hardcap-backend/runtime-tests/test-admin-auth.mjs
|
||||
// (o node runtime/hardcap/test-admin-auth.mjs desde el repo)
|
||||
// Requiere HARDCAP_ADMIN_TOKEN en el .env del servicio y el servicio activo en 127.0.0.1:3001.
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
const BASE = 'http://127.0.0.1:3001'
|
||||
const env = Object.fromEntries(
|
||||
readFileSync('/srv/hardcap-backend/.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])
|
||||
)
|
||||
const TOKEN = env.HARDCAP_ADMIN_TOKEN
|
||||
if (!TOKEN) { console.error('HARDCAP_ADMIN_TOKEN no configurado — el test no puede pasar'); process.exit(2) }
|
||||
|
||||
let failed = 0
|
||||
async function check(name, path, opts, expected) {
|
||||
const r = await fetch(BASE + path, { ...opts, signal: AbortSignal.timeout(8000) })
|
||||
const ok = r.status === expected
|
||||
if (!ok) failed++
|
||||
console.log(`${ok ? '✓' : '✗'} ${name}: esperado ${expected}, obtenido ${r.status}`)
|
||||
}
|
||||
|
||||
await check('sin token → 401', '/admin/articles?status=QUEUED', {}, 401)
|
||||
await check('token incorrecto → 401', '/admin/articles?status=QUEUED', { headers: { Authorization: 'Bearer ' + 'x'.repeat(64) } }, 401)
|
||||
await check('token vacío en header → 401', '/admin/sources', { headers: { Authorization: 'Bearer ' } }, 401)
|
||||
await check('token correcto → 200', '/admin/articles?status=QUEUED', { headers: { Authorization: `Bearer ${TOKEN}` } }, 200)
|
||||
await check('header alternativo X-Admin-Token → 200', '/admin/sources', { headers: { 'x-admin-token': TOKEN } }, 200)
|
||||
// la comparación debe ser en tiempo constante: un token de la misma longitud pero distinto NO pasa
|
||||
await check('token misma longitud, distinto → 401', '/admin/articles?status=QUEUED', { headers: { Authorization: 'Bearer ' + 'y'.repeat(TOKEN.length) } }, 401)
|
||||
|
||||
console.log(failed ? `\n${failed} TESTS FALLARON` : '\nTODOS LOS TESTS PASARON')
|
||||
process.exit(failed ? 1 : 0)
|
||||
@@ -0,0 +1,301 @@
|
||||
// cayce-initiative — el loop de iniciativa de la entidad.
|
||||
// Cada ciclo: lee su estado (métricas, oportunidades, audit) → decide la
|
||||
// acción de mayor valor → la ejecuta con herramientas → audita.
|
||||
// Herramientas: web_fetch, add_opportunity, draft_proposal, publish_note.
|
||||
// MANDATE §7b: cada ciclo DEBE producir al menos una de: oportunidad
|
||||
// investigada, draft, o contenido de distribución.
|
||||
|
||||
import pg from 'pg'
|
||||
import fs from 'node:fs'
|
||||
import { chat } from './llm.mjs'
|
||||
|
||||
const env = Object.fromEntries(
|
||||
fs.readFileSync('.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])
|
||||
)
|
||||
const pgc = new pg.Pool({ connectionString: env.PGURL })
|
||||
const NOSTR = env.NOSTR_INTERNAL || 'http://127.0.0.1:3011'
|
||||
const MANDATE = fs.readFileSync(env.MANDATE_PATH || './MANDATE.md', 'utf8')
|
||||
// memoria operativa: lecciones de la retro (errores conocidos → remedios)
|
||||
const LESSONS = fs.existsSync('./lessons.md') ? fs.readFileSync('./lessons.md', 'utf8') : ''
|
||||
|
||||
const log = (zone, action, detail = {}, result = 'ok') =>
|
||||
pgc.query('INSERT INTO actions_log (zone, action, detail, result) VALUES ($1,$2,$3,$4)', [zone, action, detail, result])
|
||||
|
||||
// reporte interno → DM al supervisor (nunca público)
|
||||
const SUPERVISOR = (fs.readFileSync('/srv/cayce-nostr/.env', 'utf8').match(/^SUPERVISOR_NPUB_HEX=(.+)$/m)?.[1] || '').trim()
|
||||
async function dm(text) {
|
||||
try {
|
||||
await fetch(`${NOSTR}/dm`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ to_hex: SUPERVISOR, content: text }),
|
||||
signal: AbortSignal.timeout(20000),
|
||||
})
|
||||
} catch { }
|
||||
}
|
||||
|
||||
// ── herramientas
|
||||
async function toolWebFetch({ url }) {
|
||||
if (!/^https:\/\//.test(url)) return { error: 'solo https' }
|
||||
const r = await fetch(url, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; cayce-entity/1.0)' },
|
||||
signal: AbortSignal.timeout(20000),
|
||||
})
|
||||
const html = await r.text()
|
||||
const text = html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&/g, '&').replace(/ /g, ' ').replace(/&#\d+;/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
return { status: r.status, title: (html.match(/<title>([^<]*)<\/title>/i) || [])[1] || '', text: text.slice(0, 6000) }
|
||||
}
|
||||
|
||||
async function toolAddOpportunity(a) {
|
||||
// directiva supervisor 2026-10-31: sin dedup el agente re-registraba la
|
||||
// misma oportunidad (OpenSats ×10, HRF ×6). Ahora: si existe, se rechaza.
|
||||
const dup = await pgc.query('SELECT id FROM opportunities WHERE lower(source_name) = lower($1) LIMIT 1', [a.source_name])
|
||||
if (dup.rowCount) {
|
||||
await log('yellow', 'opportunity_duplicate_rejected', { source_name: a.source_name, existing_id: dup.rows[0].id })
|
||||
return { duplicate: true, existing_id: dup.rows[0].id, note: 'ya registrada — no se duplica; continuá con el resto del ciclo' }
|
||||
}
|
||||
await pgc.query(
|
||||
`INSERT INTO opportunities (source_name, url, mechanic, fit, effort, expected, first_action)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
[a.source_name, a.url || null, a.mechanic, a.fit || null, a.effort || null, a.expected || null, a.first_action || null]
|
||||
)
|
||||
await log('green', 'opportunity_added', { source_name: a.source_name })
|
||||
return { added: a.source_name }
|
||||
}
|
||||
|
||||
async function toolDraftProposal(a) {
|
||||
const r = await pgc.query(
|
||||
`INSERT INTO drafts (kind, title, body) VALUES ($1,$2,$3) RETURNING id`,
|
||||
[a.kind || 'other', a.title, a.body]
|
||||
)
|
||||
await log('green', 'draft_created', { id: r.rows[0].id, kind: a.kind, title: a.title })
|
||||
return { draft_id: r.rows[0].id, note: 'el envío es 🔴 — escala al supervisor' }
|
||||
}
|
||||
|
||||
// guardrail: los reportes internos del ciclo NUNCA van a Nostr (nunca mostrar la maquinaria)
|
||||
const INTERNAL_REPORT_RE = /§7b|ciclo cumplido|pipeline|auto-?mejora|ingreso prioridad|oportunidades acumuladas|próximo play|web_fetch|add_opportunity/i
|
||||
|
||||
async function toolPublishNote({ content }) {
|
||||
if (!content || typeof content !== 'string') return { error: 'content requerido' }
|
||||
if (INTERNAL_REPORT_RE.test(content)) {
|
||||
return { error: 'CONTENIDO INTERNO — los reportes de ciclo, pipeline y status NO se publican en Nostr. publish_note es solo para contenido público con voz editorial. Registrá tu avance con finish.' }
|
||||
}
|
||||
const r = await fetch(`${NOSTR}/publish`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ kind: 1, content, type: 'initiative_note' }),
|
||||
signal: AbortSignal.timeout(60000),
|
||||
})
|
||||
const pr = await r.json()
|
||||
await pgc.query('INSERT INTO digests (kind, body, nostr_event_id) VALUES ($1,$2,$3)', ['nostr_initiative', content, pr.id])
|
||||
return pr
|
||||
}
|
||||
|
||||
// la entidad escribe el remedio de un error conocido → lessons.remedy (self-improvement)
|
||||
// normaliza una firma igual que la retro (dedup por diseño: dígitos → #)
|
||||
function normSig(s) {
|
||||
return String(s)
|
||||
.replace(/https?:\/\/\S+/g, '#url')
|
||||
.replace(/\b[0-9a-f]{8,}\b/gi, '#hex')
|
||||
.replace(/\d+(\.\d+)?/g, '#')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
async function toolRemedy({ signature, remedy }) {
|
||||
if (!signature || !remedy) return { error: 'signature + remedy requeridos' }
|
||||
// 1) match exacto
|
||||
let r = await pgc.query(
|
||||
`UPDATE lessons SET remedy=$1 WHERE signature=$2 RETURNING signature, occurrences`,
|
||||
[String(remedy).slice(0, 500), String(signature)])
|
||||
// 2) match difuso: la línea formateada de lessons.md contiene la firma normalizada
|
||||
// (la entidad suele copiar la línea completa con contador y "· ESCALADO")
|
||||
if (!r.rowCount) {
|
||||
const sigNorm = normSig(signature)
|
||||
const cands = (await pgc.query(`SELECT signature FROM lessons WHERE status IN ('open','escalated')`)).rows
|
||||
const hit = cands.find(c => sigNorm.includes(normSig(c.signature)))
|
||||
if (hit) r = await pgc.query(
|
||||
`UPDATE lessons SET remedy=$1 WHERE signature=$2 RETURNING signature, occurrences`,
|
||||
[String(remedy).slice(0, 500), hit.signature])
|
||||
}
|
||||
if (!r.rowCount) {
|
||||
return { error: `signature no encontrada: ${String(signature).slice(0, 120)}`, hint: 'usá la firma de la lista, sin el contador ni "· ESCALADO"' }
|
||||
}
|
||||
await log('green', 'lesson_remedy', { signature: r.rows[0].signature, remedy: String(remedy).slice(0, 200) })
|
||||
return { updated: r.rows[0].signature, occurrences: r.rows[0].occurrences }
|
||||
}
|
||||
|
||||
const TOOLS = {
|
||||
web_fetch: toolWebFetch,
|
||||
add_opportunity: toolAddOpportunity,
|
||||
draft_proposal: toolDraftProposal,
|
||||
publish_note: toolPublishNote,
|
||||
add_lesson_remedy: toolRemedy,
|
||||
}
|
||||
|
||||
// ── estado para el prompt
|
||||
async function stateSummary() {
|
||||
const opps = (await pgc.query('SELECT source_name, status FROM opportunities ORDER BY id DESC LIMIT 20')).rows
|
||||
const zaps = (await pgc.query(`SELECT count(*) n, coalesce(sum(value),0) msats FROM metrics WHERE metric='zaps_msats'`)).rows[0]
|
||||
const balance = (await pgc.query(`SELECT value FROM metrics WHERE metric='balance_reported_sats' ORDER BY id DESC LIMIT 1`)).rows[0]
|
||||
const drafts = (await pgc.query('SELECT count(*) n FROM drafts')).rows[0].n
|
||||
const lastActions = (await pgc.query('SELECT ts, action FROM actions_log ORDER BY id DESC LIMIT 5')).rows
|
||||
const lessons = (await pgc.query(`SELECT signature, occurrences FROM lessons WHERE status != 'mitigated' ORDER BY occurrences DESC LIMIT 5`)).rows
|
||||
return {
|
||||
sats_recaudados: {
|
||||
zaps_verificados: zaps.n,
|
||||
balance_reportado_por_supervisor: balance ? Number(balance.value) : 'aún no reportado',
|
||||
},
|
||||
oportunidades_registradas: opps.length,
|
||||
oportunidades_recientes: opps.map(o => `${o.source_name} [${o.status}]`),
|
||||
drafts: drafts,
|
||||
errores_conocidos_abiertos: lessons.map(l => `${l.signature} (${l.occurrences}×)`),
|
||||
ultimas_acciones: lastActions.map(a => `${a.ts.toISOString().slice(0, 16)} ${a.action}`),
|
||||
}
|
||||
}
|
||||
|
||||
const TOOLS_SPEC = `TOOLS (respond with JSON only):
|
||||
{"action":"web_fetch","args":{"url":"https://..."}}
|
||||
{"action":"add_opportunity","args":{"source_name":"...","url":"...","mechanic":"cómo genera ingreso","fit":"encaje con hard lines","effort":"low|medium|high","expected":"ingreso esperado","first_action":"primer paso concreto"}}
|
||||
{"action":"draft_proposal","args":{"kind":"sponsorship|grant|affiliate|other","title":"...","body":"propuesta completa lista para enviar"}}
|
||||
{"action":"publish_note","args":{"content":"..."}}
|
||||
{"action":"publish_note","args":{"content":"...— SOLO contenido público para la audiencia de Nostr, con voz editorial (irónico, cero marketing, cero reportes internos). Los reportes de ciclo, pipeline y status NUNCA se publican: eso va en finish."}}
|
||||
{"action":"add_lesson_remedy","args":{"signature":"firma exacta de la lista de errores conocidos","remedy":"qué hacer distinto para evitar el error"}}
|
||||
{"action":"finish","args":{"summary":"qué lograste este ciclo"}}
|
||||
Una acción por respuesta. El sistema ejecuta y te devuelve el resultado. Si tu respuesta no es JSON válido, se te pide re-emitirla: responde SOLO el objeto JSON, sin texto alrededor.`
|
||||
|
||||
// ── el loop
|
||||
async function runCycle() {
|
||||
const state = await stateSummary()
|
||||
// directiva del supervisor (2026-10-31): grants suspendidos, BTCPay prioridad 1.
|
||||
// No modifica el MANDATE (eso es 🔴); es una orden operativa reversible con
|
||||
// CAYCE_DIRECTIVE_BTCPAY=0 en el .env.
|
||||
const DIRECTIVE = process.env.CAYCE_DIRECTIVE_BTCPAY === '1' ? `
|
||||
|
||||
=== DIRECTIVA DEL SUPERVISOR (2026-10-31 — prevalece sobre §7b hasta nuevo aviso) ===
|
||||
1. SUSPENDIDA la búsqueda y registro de grants y nuevas oportunidades. NO uses add_opportunity salvo para la excepción del punto 2.
|
||||
2. PRIORIDAD 1 ÚNICA: la propuesta de BTCPay Server — oportunidad #106 "BTCPay Server Deployment & Content Service" y draft #33 "BTCPay Server Plugin: Event & Travel Integration". Trabajo esperado: consolidar UNA propuesta final lista para envío humano (alcance, arquitectura, presupuesto, plan de entrega) como draft nuevo marcado claramente. NO la envíes: la envía el operador.
|
||||
3. La contención está activa: publish_note crea BORRADORES, no publica. Sigue contando como acción productiva.
|
||||
` : ''
|
||||
const messages = [
|
||||
{
|
||||
role: 'user',
|
||||
content: `${MANDATE}
|
||||
${DIRECTIVE}
|
||||
|
||||
=== TU ESTADO ACTUAL ===
|
||||
${JSON.stringify(state, null, 2)}
|
||||
${LESSONS ? `
|
||||
=== LECCIONES — errores conocidos, evitalos ===
|
||||
${LESSONS}
|
||||
` : ''}
|
||||
=== TU MISIÓN ESTE CICLO ===
|
||||
MANDATE §7b: produce AL MENOS una de: oportunidad de ingreso investigada (con web_fetch a fuentes reales — grants, afiliados, bounties, sponsors del ecosistema bitcoin), draft de propuesta, o contenido que impulse distribución. Investiga antes de registrar: abre las fuentes con web_fetch, verifica que existan y qué piden (QC-3).
|
||||
|
||||
Prioridad: ingreso. Piensa como empresa: ¿qué puedo vender, a quién, cómo cobro en sats? Las propiedades (hardcap.news = señal, bitcoinhotels.org = travel BTC) son tus activos y tu vidriera.
|
||||
|
||||
Si hay errores conocidos abiertos con remedio pendiente (lista arriba), considerá escribirlo con add_lesson_remedy — auto-mejora.
|
||||
|
||||
${TOOLS_SPEC}
|
||||
|
||||
Empieza ahora. Cuando hayas cumplido §7b, responde finish con un summary honesto.`,
|
||||
},
|
||||
]
|
||||
|
||||
let steps = 0
|
||||
const maxSteps = 16
|
||||
let repairs = 0
|
||||
const maxRepairs = 3
|
||||
let summary = null
|
||||
const trace = []
|
||||
const PRODUCTIVE = ['add_opportunity', 'draft_proposal', 'publish_note', 'add_lesson_remedy']
|
||||
let productive = 0
|
||||
|
||||
while (steps < maxSteps) {
|
||||
steps++
|
||||
// presupuesto de pasos: empujar al cierre antes de agotar (evita 'max steps')
|
||||
const remaining = maxSteps - steps
|
||||
if (remaining === 3) {
|
||||
messages.push({ role: 'user', content: 'QUEDAN 3 PASOS. Cierra el ciclo: si ya investigaste, ejecutá AHORA una acción productiva (add_opportunity / draft_proposal / publish_note / add_lesson_remedy) y después finish con el summary.' })
|
||||
} else if (remaining === 1) {
|
||||
messages.push({ role: 'user', content: 'ÚLTIMO PASO. Respondé finish con un summary honesto de lo logrado este ciclo, aunque sea parcial.' })
|
||||
}
|
||||
// la cadena (zai → gemini) agota los fallbacks; si todo falla, el ciclo
|
||||
// muere auditado como error y la retro lo escala
|
||||
let text = await chat(messages, 6000, { thinking: false, timeoutMs: 90000 })
|
||||
text = text.replace(/```json|```/g, '').trim()
|
||||
const js = text.indexOf('{')
|
||||
const je = text.lastIndexOf('}')
|
||||
let act = null
|
||||
if (js === -1 || je === -1) {
|
||||
// sin JSON: re-emisión en vez de morir — error reparable en el momento
|
||||
if (repairs < maxRepairs) {
|
||||
repairs++
|
||||
messages.push({ role: 'assistant', content: text.slice(0, 500) })
|
||||
messages.push({ role: 'user', content: 'Esa respuesta no contenía JSON. Re-emite SOLO el objeto JSON de UNA acción, sin texto alrededor.' })
|
||||
steps--
|
||||
continue
|
||||
}
|
||||
summary = 'sin JSON tras ' + maxRepairs + ' repairs: ' + text.slice(0, 120)
|
||||
break
|
||||
}
|
||||
try { act = JSON.parse(text.slice(js, je + 1)) } catch (e) {
|
||||
if (repairs < maxRepairs) {
|
||||
repairs++
|
||||
messages.push({ role: 'assistant', content: text.slice(0, 500) })
|
||||
messages.push({ role: 'user', content: `Esa respuesta no fue JSON válido parseable (${String(e).slice(0, 80)}). Re-emite SOLO el objeto JSON de UNA acción. Si el body era muy largo, acortalo.` })
|
||||
steps--
|
||||
continue
|
||||
}
|
||||
summary = 'JSON inválido tras ' + maxRepairs + ' repairs'
|
||||
break
|
||||
}
|
||||
|
||||
if (act.action === 'finish') { summary = act.args?.summary ?? 'finish'; break }
|
||||
if (!TOOLS[act.action]) {
|
||||
messages.push({ role: 'assistant', content: JSON.stringify(act) })
|
||||
messages.push({ role: 'user', content: `herramienta desconocida: ${act.action}. ${TOOLS_SPEC}` })
|
||||
continue
|
||||
}
|
||||
let result
|
||||
try {
|
||||
result = await TOOLS[act.action](act.args || {})
|
||||
} catch (e) {
|
||||
result = { error: String(e).slice(0, 300) }
|
||||
}
|
||||
trace.push({ step: steps, action: act.action, args: act.args })
|
||||
if (PRODUCTIVE.includes(act.action) && !result?.error) productive++
|
||||
messages.push({ role: 'assistant', content: JSON.stringify(act) })
|
||||
messages.push({ role: 'user', content: 'RESULTADO: ' + JSON.stringify(result).slice(0, 4000) })
|
||||
}
|
||||
|
||||
// §7b se cumple con ≥1 acción productiva — si no, el ciclo falla y se audita como error
|
||||
const s7b_ok = productive > 0
|
||||
const finalSummary = summary ?? 'max steps'
|
||||
await log(s7b_ok ? 'green' : 'red', 'initiative_cycle',
|
||||
{ steps, productive, repairs, trace, summary: finalSummary }, s7b_ok ? 'done' : 'error')
|
||||
const drafts24 = (await pgc.query(
|
||||
`SELECT id, type, content FROM outbound_drafts WHERE ts > now() - interval '24 hours' ORDER BY id`
|
||||
).catch(() => ({ rows: [] }))).rows
|
||||
const draftLines = drafts24.map(d => `#${d.id} [${d.type}] ${String(d.content).replace(/\s+/g, ' ').split(' ').slice(0, 15).join(' ')}`)
|
||||
const draftBlock = `\n\nBORRADORES contención (24h: ${draftLines.length}) — revisión y aprobación pendientes del operador:\n${draftLines.join('\n') || 'ninguno'}`
|
||||
await dm(`INITIATIVE ${new Date().toISOString().slice(0, 10)} — ciclo ${s7b_ok ? 'cumplido' : 'falló'} · ${steps} pasos · ${productive} productivas · ${trace.map(t => t.action).join(' → ') || 'sin acciones'}\n\n${finalSummary}${draftBlock}`)
|
||||
return { steps, productive, s7b_ok, summary: finalSummary, trace }
|
||||
}
|
||||
|
||||
try {
|
||||
const out = await runCycle()
|
||||
console.log(JSON.stringify(out, null, 2))
|
||||
await pgc.end()
|
||||
process.exit(out.s7b_ok ? 0 : 1)
|
||||
} catch (e) {
|
||||
console.error('initiative fatal:', String(e).slice(0, 300))
|
||||
await log('red', 'initiative_cycle', { error: String(e).slice(0, 300) }, 'error').catch(() => { })
|
||||
await pgc.end().catch(() => { })
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// cayce-mail — daemon de correo de la entidad (entidadnodal@gmail.com)
|
||||
// Recibe: aprobaciones de afiliados, respuestas de merchants/grants → clasifica,
|
||||
// registra en DB y avisa al supervisor por Nostr. Envia: outreach SMTP.
|
||||
// Poll cada 5 min · estado en state.json · credenciales en .env (600)
|
||||
import { ImapFlow } from 'imapflow'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
||||
import pg from 'pg'
|
||||
import { gateOutbound } from './outbox.mjs'
|
||||
|
||||
const HOME = '/home/ubuntu/cayce-mail'
|
||||
const ENV = Object.fromEntries(readFileSync(`${HOME}/.env`, 'utf8').trim().split('\n').map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]))
|
||||
const PGURL = readFileSync('/srv/cayce-front/.env', 'utf8').match(/^PGURL=(.+)$/m)[1]
|
||||
const SUPERVISOR = readFileSync('/srv/cayce-nostr/.env', 'utf8').match(/^SUPERVISOR_NPUB_HEX=(.+)$/m)[1].trim()
|
||||
const NOSTR = 'http://127.0.0.1:3011'
|
||||
const pgc = new pg.Pool({ connectionString: PGURL })
|
||||
|
||||
const POLL_MS = 5 * 60 * 1000
|
||||
const statePath = `${HOME}/state.json`
|
||||
let state = { lastUid: 0, processed: 0, notified: 0 }
|
||||
if (existsSync(statePath)) { try { state = { ...state, ...JSON.parse(readFileSync(statePath, 'utf8')) } } catch { } }
|
||||
const save = () => writeFileSync(statePath, JSON.stringify(state, null, 1), { mode: 0o600 })
|
||||
|
||||
const log = (m) => console.log(`[cayce-mail ${new Date().toISOString().slice(5, 16)}] ${m}`)
|
||||
async function dm(text) {
|
||||
try {
|
||||
await fetch(`${NOSTR}/dm`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ to_hex: SUPERVISOR, content: text.slice(0, 900) }),
|
||||
signal: AbortSignal.timeout(20000),
|
||||
})
|
||||
state.notified++
|
||||
} catch (e) { console.error('dm:', String(e).slice(0, 80)) }
|
||||
}
|
||||
|
||||
// ── clasificación de remitentes relevantes ──────────────────────────────────
|
||||
const MERCHANTS = {
|
||||
'bitrefill.com': 'Bitrefill',
|
||||
'travala.com': 'Travala',
|
||||
'trezor.io': 'Trezor',
|
||||
'ledger.com': 'Ledger',
|
||||
'bitbox.swiss': 'BitBox',
|
||||
'coinkite.com': 'Coinkite',
|
||||
'swan.com': 'Swan',
|
||||
'bitaxe': 'Bitaxe',
|
||||
'opensats.org': 'OpenSats',
|
||||
'hrf.org': 'HRF',
|
||||
'foundationdevices.com': 'Foundation',
|
||||
'relai.ch': 'Relai',
|
||||
'blink.sv': 'Blink',
|
||||
}
|
||||
|
||||
function classify(from = '', subject = '') {
|
||||
const f = from.toLowerCase(), s = subject.toLowerCase()
|
||||
const merchant = Object.entries(MERCHANTS).find(([d]) => f.includes(d))
|
||||
if (!merchant) return null
|
||||
let kind = 'info'
|
||||
if (/approv|accepted|welcome to|partner|afiliado aprobado/.test(s)) kind = 'APPROVAL'
|
||||
else if (/reject|denied|not approved|declined/.test(s)) kind = 'REJECTION'
|
||||
else if (/statement|receipt|transaction|invoice|payout|commission/.test(s)) kind = 'money'
|
||||
else if (/verify|confirm|action required|complete your/.test(s)) kind = 'ACTION-REQUIRED'
|
||||
return { merchant: merchant[1], kind }
|
||||
}
|
||||
|
||||
async function processMessage(msg) {
|
||||
const from = msg.envelope.from?.[0]?.address || ''
|
||||
const subject = msg.envelope.subject || '(sin asunto)'
|
||||
const c = classify(from, subject)
|
||||
if (!c) return
|
||||
|
||||
log(`${c.kind} · ${c.merchant} · "${subject.slice(0, 60)}" <${from}>`)
|
||||
|
||||
// Registrar en actions_log (auditoría)
|
||||
await pgc.query(
|
||||
`INSERT INTO actions_log (zone, action, detail, result) VALUES ('mail', 'mail_received', $1, 'ok')`,
|
||||
[{ from, subject, merchant: c.merchant, kind: c.kind, uid: msg.uid }]
|
||||
).catch(e => console.error('db:', String(e).slice(0, 100)))
|
||||
|
||||
// Notificar al supervisor solo lo importante (no estados de cuenta)
|
||||
if (c.kind === 'money') return
|
||||
const emoji = c.kind === 'APPROVAL' ? '✅' : c.kind === 'REJECTION' ? '❌' : c.kind === 'ACTION-REQUIRED' ? '⚠' : '✉'
|
||||
await dm(`${emoji} MAIL ${c.kind} — ${c.merchant}\n"${subject.slice(0, 80)}"\nDe: ${from}\nRevisar bandeja entidadnodal@gmail.com`)
|
||||
}
|
||||
|
||||
async function pollInbox() {
|
||||
const client = new ImapFlow({
|
||||
host: 'imap.gmail.com', port: 993, secure: true,
|
||||
auth: { user: ENV.MAIL_USER, pass: ENV.MAIL_PASS },
|
||||
logger: false,
|
||||
})
|
||||
try {
|
||||
await client.connect()
|
||||
const lock = await client.getMailboxLock('INBOX')
|
||||
try {
|
||||
// primera pasada: marcar el punto de partida en el último mensaje existente
|
||||
if (!state.lastUid) {
|
||||
let last = 0
|
||||
for await (const m of client.fetch({ all: true }, { uid: true }, { uid: true })) last = m.uid
|
||||
state.lastUid = last
|
||||
save()
|
||||
log(`baseline: uid ${last} · ${client.mailbox.exists} mensajes en inbox`)
|
||||
return
|
||||
}
|
||||
// mensajes nuevos desde el último UID visto
|
||||
const range = { uid: `${state.lastUid + 1}:*` }
|
||||
for await (const msg of client.fetch(range, { envelope: true, uid: true }, { uid: true })) {
|
||||
if (msg.uid <= state.lastUid) continue
|
||||
await processMessage(msg)
|
||||
state.processed++
|
||||
if (msg.uid > state.lastUid) state.lastUid = msg.uid
|
||||
}
|
||||
save()
|
||||
} finally {
|
||||
lock.release()
|
||||
}
|
||||
await client.logout()
|
||||
} catch (e) {
|
||||
console.error('[cayce-mail] poll error:', String(e).slice(0, 160))
|
||||
try { await client.logout() } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
// ── SMTP saliente para outreach ─────────────────────────────────────────────
|
||||
export async function sendMail(to, subject, body) {
|
||||
// contención (2026-10-31): el mail saliente también pasa por el outbox
|
||||
const g = await gateOutbound(null, {
|
||||
channel: 'mail', type: 'smtp_send', content: `To: ${to}\nSubject: ${subject}\n\n${body}`,
|
||||
meta: { to, subject },
|
||||
publishFn: () => _sendMailLive(to, subject, body),
|
||||
})
|
||||
if (g.staged) return g
|
||||
}
|
||||
|
||||
async function _sendMailLive(to, subject, body) {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: 'smtp.gmail.com', port: 465, secure: true,
|
||||
auth: { user: ENV.MAIL_USER, pass: ENV.MAIL_PASS },
|
||||
})
|
||||
const info = await transporter.sendMail({
|
||||
from: `"Cayce Co" <${ENV.MAIL_USER}>`,
|
||||
to, subject, text: body,
|
||||
})
|
||||
await pgc.query(
|
||||
`INSERT INTO actions_log (zone, action, detail, result) VALUES ('mail', 'mail_sent', $1, 'ok')`,
|
||||
[{ to, subject, messageId: info.messageId }]
|
||||
).catch(() => { })
|
||||
log(`sent → ${to} · "${subject.slice(0, 50)}"`)
|
||||
return info
|
||||
}
|
||||
|
||||
// CLI: node mail.mjs --send-to x@y --subject "..." --body-file z.txt
|
||||
const args = process.argv.slice(2)
|
||||
if (args.includes('--send-to')) {
|
||||
const to = args[args.indexOf('--send-to') + 1]
|
||||
const subject = args[args.indexOf('--subject') + 1] || '(sin asunto)'
|
||||
const bodyFile = args[args.indexOf('--body-file') + 1]
|
||||
const body = bodyFile ? readFileSync(bodyFile, 'utf8') : args[args.indexOf('--body') + 1] || ''
|
||||
sendMail(to, subject, body).then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1) })
|
||||
} else {
|
||||
log(`daemon on · poll cada ${POLL_MS / 60000} min · cuenta ${ENV.MAIL_USER}`)
|
||||
pollInbox()
|
||||
setInterval(pollInbox, POLL_MS)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// outbox.mjs — contención de salida a terceros (prioridad Cayce, 2026-10-31)
|
||||
//
|
||||
// CAYCE_PUBLISH_MODE=draft (default) → NADA sale a terceros: todo artefacto
|
||||
// (notas nostr, respuestas a menciones, posts a redes sociales, mail) se
|
||||
// guarda como BORRADOR en outbound_drafts con el hash del archivo de
|
||||
// identidad vigente. El DM al supervisor NO pasa por aquí (es interno).
|
||||
// CAYCE_PUBLISH_MODE=live → comportamiento original, sin cambios.
|
||||
//
|
||||
// Fail-closed: en modo draft, si el guardado del borrador falla, NO se
|
||||
// publica. Nunca. El error queda en el journal.
|
||||
//
|
||||
// Reversibilidad: una línea por servicio en su .env (CAYCE_PUBLISH_MODE=live).
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
const require_ = createRequire(import.meta.url)
|
||||
|
||||
const IDENTITY_PATH = process.env.IDENTITY_PATH || '/home/ubuntu/cayce-ops/prompts/cayce-identity.md'
|
||||
|
||||
export function publishMode() {
|
||||
return (process.env.CAYCE_PUBLISH_MODE || 'draft').toLowerCase()
|
||||
}
|
||||
|
||||
let identityCache = null
|
||||
export function identityFingerprint() {
|
||||
if (identityCache) return identityCache
|
||||
try {
|
||||
const raw = readFileSync(IDENTITY_PATH)
|
||||
identityCache = {
|
||||
path: IDENTITY_PATH,
|
||||
hash: createHash('sha256').update(raw).digest('hex'),
|
||||
}
|
||||
} catch (e) {
|
||||
identityCache = { path: IDENTITY_PATH, hash: null, error: String(e).slice(0, 120) }
|
||||
}
|
||||
return identityCache
|
||||
}
|
||||
|
||||
export function loadIdentityBlock(name) {
|
||||
const raw = readFileSync(IDENTITY_PATH, 'utf8')
|
||||
const m = raw.match(
|
||||
new RegExp(`<!-- cayce-identity:block=${name} -->\\n([\\s\\S]*?)\\n<!-- /cayce-identity:block=${name} -->`)
|
||||
)
|
||||
if (!m) throw new Error(`identity block not found: ${name}`)
|
||||
return m[1]
|
||||
}
|
||||
|
||||
export function ensureOutboxTable(poolPg) {
|
||||
return poolPg.query(`
|
||||
CREATE TABLE IF NOT EXISTS outbound_drafts (
|
||||
id bigserial PRIMARY KEY,
|
||||
ts timestamptz NOT NULL DEFAULT now(),
|
||||
channel text NOT NULL,
|
||||
type text NOT NULL,
|
||||
content text NOT NULL,
|
||||
original_event jsonb,
|
||||
identity_path text,
|
||||
identity_hash text,
|
||||
status text NOT NULL DEFAULT 'pending',
|
||||
meta jsonb
|
||||
)`).catch(e => console.error('[outbox] ensure table:', String(e).slice(0, 120)))
|
||||
}
|
||||
|
||||
// pool auto-suficiente: si el host no pasa uno, se conecta a la BD de la
|
||||
// entidad (PGURL de /srv/cayce-core/.env) para que todos los borradores
|
||||
// vivan juntos en outbound_drafts
|
||||
let ownPool = null
|
||||
async function poolFor(poolPg) {
|
||||
if (poolPg) return poolPg
|
||||
if (!ownPool) {
|
||||
const pg = require_('/srv/cayce-core/node_modules/pg')
|
||||
const env = Object.fromEntries(
|
||||
readFileSync('/srv/cayce-core/.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])
|
||||
)
|
||||
ownPool = new pg.Pool({ connectionString: env.PGURL })
|
||||
}
|
||||
return ownPool
|
||||
}
|
||||
|
||||
export async function stageDraft(poolPg, { channel, type, content, original_event = null, meta = {} }) {
|
||||
poolPg = await poolFor(poolPg)
|
||||
const id = identityFingerprint()
|
||||
const r = await poolPg.query(
|
||||
`INSERT INTO outbound_drafts (channel, type, content, original_event, identity_path, identity_hash, meta)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`,
|
||||
[channel, type, String(content), original_event ? JSON.stringify(original_event) : null,
|
||||
id.path, id.hash, JSON.stringify(meta)]
|
||||
)
|
||||
console.log(`[outbox] STAGED draft #${r.rows[0].id} (${channel}/${type}) — NO publicado (mode=${publishMode()})`)
|
||||
return { staged: true, draft_id: r.rows[0].id, mode: publishMode() }
|
||||
}
|
||||
|
||||
// El único punto por el que debe pasar cualquier salida a terceros.
|
||||
// publishFn SOLO se invoca en modo live.
|
||||
export async function gateOutbound(poolPg, { channel, type, content, original_event = null, meta = {}, publishFn }) {
|
||||
if (publishMode() === 'live') {
|
||||
return { ...(await publishFn()), staged: false }
|
||||
}
|
||||
try {
|
||||
return await stageDraft(poolPg, { channel, type, content, original_event, meta })
|
||||
} catch (e) {
|
||||
// fail-closed: sin borrador guardado, no se publica
|
||||
console.error(`[outbox] FATAL: staging failed (${String(e).slice(0, 160)}) — evento DESCARTADO, no publicado`)
|
||||
return { staged: false, error: 'staging failed; discarded (fail-closed)' }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// cayce-promo — publicación autónoma temática de la entidad
|
||||
// Cola de campaña: 12 posts / 6 días / 2 por día (13:00 y 19:00 UTC)
|
||||
// Cada post: tema específico → link a bitcoinhotels.org o hardcap.news
|
||||
// Publica vía sfeed (FB + IG) con imágenes de marca generadas on-the-fly.
|
||||
// Ledger en DB (posts_log) para medir y optimizar.
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { gateOutbound } from './outbox.mjs'
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
||||
|
||||
const HOME = '/home/ubuntu/cayce-social'
|
||||
const SFEED = '/home/ubuntu/.sfeed/bin/sfeed'
|
||||
const statePath = `${HOME}/promo-state.json`
|
||||
let state = { done: [] }
|
||||
if (existsSync(statePath)) { try { state = { ...state, ...JSON.parse(readFileSync(statePath, 'utf8')) } } catch { } }
|
||||
const save = () => writeFileSync(statePath, JSON.stringify(state, null, 1), { mode: 0o600 })
|
||||
|
||||
const log = (m) => console.log(`[cayce-promo ${new Date().toISOString().slice(5, 16)}] ${m}`)
|
||||
|
||||
// ── generador de imágenes de marca ──────────────────────────────────────────
|
||||
// brand: 'bh' (bitcoinhotels — dorado) | 'hc' (hardcap — naranja)
|
||||
function makeImage(out, brand, { kicker, lines, tagline, sub, url, drawRule = true }) {
|
||||
const bg = brand === 'bh' ? '"#141210-#0A0908"' : '"#0d0d10-#09090B"'
|
||||
const accent = brand === 'bh' ? '#C9A84C' : '#F7931A'
|
||||
const textC = brand === 'bh' ? '#F2ECE0' : '#FAFAFA'
|
||||
const muted = brand === 'bh' ? 'rgba(242,236,224,0.6)' : 'rgba(250,250,250,0.65)'
|
||||
const kickC = brand === 'bh' ? '#8a7a4a' : '#71717A'
|
||||
|
||||
let cmd = ['convert', '-size', '1080x1350', 'gradient:' + bg]
|
||||
let y = 260
|
||||
if (kicker) { cmd.push('-font', 'DejaVu-Serif', '-pointsize', '44', '-fill', kickC, '-annotate', `+90+${y}`, kicker); y += 130 }
|
||||
for (const l of lines) { cmd.push('-font', 'DejaVu-Serif-Bold', '-pointsize', String(l.size || 110), '-fill', l.color || textC, '-annotate', `+90+${y}`, l.t); y += (l.size || 110) + 25 }
|
||||
y += 20
|
||||
if (tagline) for (const t of tagline) { cmd.push('-font', 'DejaVu-Sans', '-pointsize', '36', '-fill', muted, '-annotate', `+90+${y}`, t); y += 62 }
|
||||
if (sub) for (const t of sub) { cmd.push('-font', 'DejaVu-Sans', '-pointsize', String(sub.size || 40), '-fill', sub.color || accent, '-annotate', `+90+${y}`, t.t); y += (sub.size || 40) + 12 }
|
||||
if (drawRule) cmd.push('-fill', accent, '-draw', `rectangle 90,${y + 40} 560,${y + 42}`)
|
||||
cmd.push('-font', 'DejaVu-Sans', '-pointsize', '40', '-fill', accent, '-annotate', `+90+${y + 120}`, url)
|
||||
cmd.push(out)
|
||||
execFileSync('convert', cmd)
|
||||
execFileSync('convert', [out, '-quality', '92', out]) // → JPEG final
|
||||
}
|
||||
|
||||
// ── campaña: 12 posts / 6 días ──────────────────────────────────────────────
|
||||
// day_offset: días desde el arranque · hour_utc: hora de publicación
|
||||
const CAMPAIGN = [
|
||||
{ id: 'd1-fb-lightning', day: 0, hour: 13, net: 'facebook', format: 'image', brand: 'bh',
|
||||
theme: 'lightning-abroad', link: 'https://bitcoinhotels.org/guides/how-to-pay-with-lightning-abroad',
|
||||
img: { kicker: 'B E G I N N E R G U I D E', lines: [{ t: 'HOW TO PAY', size: 115 }, { t: 'WITH', size: 115 }, { t: 'LIGHTNING', size: 125, color: '#C9A84C' }, { t: 'ABROAD', size: 105, color: '#C9A84C' }], tagline: ['Wallets · Funding · Gift-card hacks', 'What to do when the card gets declined.'], url: 'bitcoinhotels.org/guides' },
|
||||
caption: 'Paying abroad with Lightning beats your bank: no foreign transaction fees, no dynamic currency conversion, no declines. The complete beginner guide — wallets, funding, and the gift-card trick that rescues you when the card fails mid-trip. bitcoinhotels.org/guides/how-to-pay-with-lightning-abroad' },
|
||||
|
||||
{ id: 'd1-ig-seed', day: 0, hour: 19, net: 'instagram', format: 'image', brand: 'hc',
|
||||
theme: 'seed-strategy', link: 'https://hardcap.news/learn/travel-security-kit',
|
||||
img: { kicker: 'S E C U R I T Y', lines: [{ t: 'SEEDS THAT', size: 110 }, { t: 'SURVIVE', size: 110 }, { t: 'A SEARCH', size: 110, color: '#F7931A' }], tagline: ['Steel plate in checked luggage?', 'Memorized + decoy?', 'Split custody — nothing to find.'], sub: { t: 'hardcap.news/learn/travel-security-kit' }, url: ' ' },
|
||||
caption: 'Steel plate in checked luggage, memorized + decoy, or split custody: the seed strategy you pick before the trip decides how the border story goes. The Travel Security Kit breaks down all three — and the passphrase pattern that leaves nothing to confiscate. hardcap.news/learn/travel-security-kit #bitcoin #selfcustody #seedphrase #opsec' },
|
||||
|
||||
{ id: 'd2-fb-wrench', day: 1, hour: 13, net: 'facebook', format: 'text', brand: 'hc', theme: 'wrench-data', link: 'https://hardcap.news/learn/travel-security-kit', img: null,
|
||||
caption: 'The 2026 wrench-attack dataset shows a pattern: victims are almost never selected by chain analysis. They are selected by behavior — talking about holdings, wearing the merch, fumbling with seed material in public. OpSec is free; everything else costs sats. Field guide: hardcap.news/learn/travel-security-kit' },
|
||||
|
||||
{ id: 'd2-ig-atm-fees', day: 1, hour: 19, net: 'instagram', format: 'carousel', brand: 'bh',
|
||||
theme: 'atm-fees', link: 'https://bitcoinhotels.org/guides/best-bitcoin-atms-by-city',
|
||||
img: { kicker: 'R E F E R E N C E', lines: [{ t: 'THE FEE', size: 110 }, { t: 'REALITY', size: 110 }], tagline: ['Tourist at the first machine: 10-15% over spot.', 'Prepared traveler: 4-6%.'], sub: { t: 'Swipe →' }, url: 'bitcoinhotels.org/guides' },
|
||||
caption: 'The first ATM you find charges 10-15% over spot. The prepared traveler pays 4-6%. The difference is knowing the dominant operator and the no-KYC cap before you land. City-by-city reference → bitcoinhotels.org/guides/best-bitcoin-atms-by-city #bitcoin #btc #travel #atm' },
|
||||
|
||||
{ id: 'd3-fb-budget', day: 2, hour: 13, net: 'facebook', format: 'image', brand: 'bh',
|
||||
theme: 'elsalvador-budget', link: 'https://bitcoinhotels.org/guides/living-on-sats-in-el-salvador',
|
||||
img: { kicker: 'D E E P D I V E', lines: [{ t: 'LIVING', size: 120 }, { t: 'ON SATS', size: 120 }, { t: 'EL SALVADOR', size: 105, color: '#C9A84C' }], tagline: ['Real weekly budgets in sats.', 'El Zonte vs San Salvador.', 'The 90-day experiment checklist.'], url: 'bitcoinhotels.org/guides' },
|
||||
caption: 'A real weekly budget on a Bitcoin standard: surf hostel 17-29k sats a night, comidas 11-17k a day, coworking 7-11k — and the corners where you still need dollars. Living on sats in El Salvador, the full breakdown. bitcoinhotels.org/guides/living-on-sats-in-el-salvador' },
|
||||
|
||||
{ id: 'd3-ig-rescue', day: 2, hour: 19, net: 'instagram', format: 'image', brand: 'hc',
|
||||
theme: 'rescue-rail', link: 'https://hardcap.news/learn/travel-security-kit',
|
||||
img: { kicker: 'L I Q U I D I T Y', lines: [{ t: 'THE RESCUE', size: 115 }, { t: 'RAIL', size: 115, color: '#F7931A' }], tagline: ['Zero sats abroad and the card fails:', 'a gift card in seconds is the difference', 'between dinner and no dinner.'], sub: { t: 'hardcap.news/learn/travel-security-kit' }, url: ' ' },
|
||||
caption: 'Zero sats abroad, bank card declined, ATM laughing. The rescue rail: sats → gift card in seconds, 186 countries, instant delivery. The single highest-value item in the travel security kit. hardcap.news/learn/travel-security-kit #bitcoin #lightning #traveltips' },
|
||||
|
||||
{ id: 'd4-fb-roatann', day: 3, hour: 13, net: 'facebook', format: 'text', brand: 'bh', theme: 'roatann-prospera', link: 'https://bitcoinhotels.org/destinations/roatan-honduras', img: null,
|
||||
caption: 'Roatán & Próspera: Bitcoin is legal tender inside the world\'s most Bitcoin-native jurisdiction. Island life, world-class diving on the Mesoamerican Reef, and hotels natively accepting Lightning. The destination guide: bitcoinhotels.org/destinations/roatan-honduras' },
|
||||
|
||||
{ id: 'd4-ig-device', day: 3, hour: 19, net: 'instagram', format: 'carousel', brand: 'hc',
|
||||
theme: 'device-choice', link: 'https://hardcap.news/learn/travel-security-kit',
|
||||
img: { kicker: 'K I T P I C K S', lines: [{ t: 'ONE DEVICE,', size: 110 }, { t: 'ONE PATTERN', size: 110 }], tagline: ['Two devices = two seeds traveling twice', 'the distance. Pick one, know its', 'recovery drill cold.'], sub: { t: 'hardcap.news/learn/travel-security-kit' }, url: ' ' },
|
||||
caption: 'One hardware wallet, not two. Two devices means two seeds traveling twice the distance, and the failure modes compound. The hotel-room test: can you restore from seed onto a fresh device in under ten minutes without looking anything up? The full kit → hardcap.news/learn/travel-security-kit #bitcoin #hardwarewallet #selfcustody' },
|
||||
|
||||
{ id: 'd5-fb-kyc', day: 4, hour: 13, net: 'facebook', format: 'image', brand: 'bh',
|
||||
theme: 'kyc-caps', link: 'https://bitcoinhotels.org/guides/best-bitcoin-atms-by-city',
|
||||
img: { kicker: 'R E F E R E N C E', lines: [{ t: 'KYC CAPS', size: 120 }, { t: 'BY CITY', size: 120, color: '#C9A84C' }], tagline: ['Amsterdam EUR 250-750 · Prague CZK 5k-25k', 'Miami USD 200-800 · and 4 more cities'], url: 'bitcoinhotels.org/guides' },
|
||||
caption: 'Most Bitcoin ATMs let you buy small amounts with just a phone number; above that, ID scan. If your trip budget is under the no-KYC cap, split purchases across days instead of uploading your passport to the cheapest machine. Caps by city: bitcoinhotels.org/guides/best-bitcoin-atms-by-city' },
|
||||
|
||||
{ id: 'd5-ig-mistakes', day: 4, hour: 19, net: 'instagram', format: 'image', brand: 'hc',
|
||||
theme: '3-mistakes', link: 'https://hardcap.news/learn/travel-security-kit',
|
||||
img: { kicker: 'F I E L D T E S T E D', lines: [{ t: '3 MISTAKES', size: 115 }, { t: 'THAT GET', size: 115 }, { t: 'BTC TAKEN', size: 115, color: '#EF4444' }], tagline: ['Inventory on arrival forms.', 'The public seed moment.', 'Everything in one bag.'], sub: { t: 'hardcap.news/learn/travel-security-kit' }, url: ' ' },
|
||||
caption: 'Three mistakes that get sats confiscated at borders: declaring what your passphrase structure keeps private, verifying seeds in airport lounges, and carrying device + seed + cash in one bag. Every wrench attack we track started with behavior. The kit: hardcap.news/learn/travel-security-kit #bitcoin #opsec #security' },
|
||||
|
||||
{ id: 'd6-fb-travala', day: 5, hour: 13, net: 'facebook', format: 'image', brand: 'bh',
|
||||
theme: 'booking-btc', link: 'https://bitcoinhotels.org/guides/living-on-sats-in-el-salvador',
|
||||
img: { kicker: 'B O O K I N G', lines: [{ t: 'RESERVE WITH', size: 110 }, { t: 'BITCOIN,', size: 110 }, { t: 'ARRIVE ON SATS', size: 100, color: '#C9A84C' }], tagline: ['The no-card, no-reservation loop,', 'solved before the flight.'], url: 'bitcoinhotels.org/guides' },
|
||||
caption: 'The no-card, no-reservation loop: hotels that want a credit card guarantee when you only hold sats. Booking with Bitcoin before arrival breaks the loop — Travala lists dozens of Salvadoran properties taking BTC directly. More in the El Salvador guide: bitcoinhotels.org/guides/living-on-sats-in-el-salvador' },
|
||||
|
||||
{ id: 'd6-ig-protocol', day: 5, hour: 19, net: 'instagram', format: 'image', brand: 'bh',
|
||||
theme: '90day-protocol', link: 'https://bitcoinhotels.org/guides/living-on-sats-in-el-salvador',
|
||||
img: { kicker: 'T H E P R O T O C O L', lines: [{ t: '90 DAYS', size: 125 }, { t: 'ON SATS', size: 125, color: '#C9A84C' }], tagline: ['Tourist visa → El Zonte two weeks →', 'Blink funded to 500k sats → track', 'every payment → decide your split.'], url: 'bitcoinhotels.org/guides' },
|
||||
caption: 'Before committing to a season on sats, run the protocol: enter on a tourist visa, base in El Zonte two weeks with a Lightning wallet funded to 500k sats, track every payment for a week, then decide your split. Most long-termers land on 70/30 sats/dollars. Full protocol: bitcoinhotels.org/guides/living-on-sats-in-el-salvador #bitcoin #elsalvador #bitcoinbeach' },
|
||||
]
|
||||
|
||||
// ── publicación ─────────────────────────────────────────────────────────────
|
||||
function sfeedPost(args) {
|
||||
return execFileSync(SFEED, ['post', ...args], { encoding: 'utf8', timeout: 120000 })
|
||||
}
|
||||
|
||||
// contención: el gate se resuelve en el sitio de llamada (run())
|
||||
|
||||
async function logDb(entry, postUrl, status) {
|
||||
const pgc = new (await import('pg')).default.Pool({ connectionString: readFileSync('/srv/cayce-front/.env', 'utf8').match(/^PGURL=(.+)$/m)[1] })
|
||||
await pgc.query(
|
||||
`INSERT INTO posts_log (platform, theme, format, link, post_url, status) VALUES ($1,$2,$3,$4,$5,$6)`,
|
||||
[entry.net, entry.theme, entry.format, entry.link, postUrl || null, status]
|
||||
).catch(e => console.error('db:', String(e).slice(0, 120)))
|
||||
await pgc.query(
|
||||
`INSERT INTO actions_log (zone, action, detail, result) VALUES ('promo', 'promo_published', $1, $2)`,
|
||||
[JSON.stringify({ id: entry.id, theme: entry.theme, platform: entry.net, link: entry.link, post_url: postUrl }), status]
|
||||
).catch(() => { })
|
||||
await pgc.end()
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const now = new Date()
|
||||
const boot = existsSync(`${HOME}/campaign-start.json`)
|
||||
? new Date(JSON.parse(readFileSync(`${HOME}/campaign-start.json`, 'utf8')).start)
|
||||
: null
|
||||
|
||||
let published = 0
|
||||
for (const entry of CAMPAIGN) {
|
||||
if (state.done.includes(entry.id)) continue
|
||||
if (!boot) continue // primer run: solo fija arranque
|
||||
const due = new Date(boot.getTime() + entry.day * 86400000)
|
||||
due.setUTCHours(entry.hour, 0, 0, 0)
|
||||
if (now < due) continue
|
||||
|
||||
log(`publicando ${entry.id} · tema ${entry.theme} · ${entry.net}`)
|
||||
const args = ['--to', entry.net]
|
||||
if (entry.net === 'facebook') args.push('--page', 'Bitcoin Travelers')
|
||||
|
||||
try {
|
||||
if (entry.format === 'carousel') {
|
||||
// 2 slides: cover + contenido (mismo estilo de marca)
|
||||
const img1 = `${HOME}/media/${entry.id}-1.jpg`
|
||||
const img2 = `${HOME}/media/${entry.id}-2.jpg`
|
||||
mkdirs(HOME + '/media')
|
||||
makeImage(img1, entry.brand, entry.img)
|
||||
makeImage(img2, entry.brand, { ...entry.img, kicker: 'E N L A G U I A', lines: [{ t: 'LINK EN', size: 100 }, { t: 'EL POST', size: 100, color: entry.brand === 'bh' ? '#C9A84C' : '#F7931A' }], tagline: entry.link.replace('https://', '').split('/').slice(0, 3), drawRule: false, url: ' ' })
|
||||
args.push('--kind', 'carousel', '--media', `${img1},${img2}`)
|
||||
} else if (entry.format === 'image') {
|
||||
const img = `${HOME}/media/${entry.id}.jpg`
|
||||
mkdirs(HOME + '/media')
|
||||
makeImage(img, entry.brand, entry.img)
|
||||
args.push('--media', img)
|
||||
}
|
||||
args.push(entry.caption)
|
||||
|
||||
const g = await gateOutbound(null, {
|
||||
channel: 'social', type: `promo_${entry.net}`, content: entry.caption,
|
||||
meta: { id: entry.id, theme: entry.theme || null, format: entry.format || null },
|
||||
publishFn: async () => sfeedPost(args),
|
||||
})
|
||||
if (g.staged) {
|
||||
log(`⇢ ${entry.id} → STAGED como borrador #${g.draft_id} (NO publicado a ${entry.net})`)
|
||||
await logDb(entry, null, 'staged')
|
||||
state.done.push(entry.id); save()
|
||||
continue
|
||||
}
|
||||
const out = sfeedPost(args)
|
||||
const url = out.match(/https:\/\/[^\s]+/)?.[0] || null
|
||||
log(`✓ ${entry.id} → ${url || 'publicado'}`)
|
||||
await logDb(entry, url, 'ok')
|
||||
state.done.push(entry.id); save()
|
||||
published++
|
||||
} catch (e) {
|
||||
const msg = String(e).slice(0, 160)
|
||||
log(`✖ ${entry.id}: ${msg}`)
|
||||
await logDb(entry, null, 'error: ' + msg)
|
||||
// no marca done — reintenta en el próximo ciclo
|
||||
}
|
||||
break // 1 publicación por ciclo — ritmo natural
|
||||
}
|
||||
|
||||
if (!boot) {
|
||||
writeFileSync(`${HOME}/campaign-start.json`, JSON.stringify({ start: now.toISOString() }, null, 1))
|
||||
log(`campaña iniciada: ${now.toISOString()} · 12 posts / 6 días`)
|
||||
}
|
||||
if (published === 0 && state.done.length >= CAMPAIGN.length) log('campaña completa — todos los posts publicados')
|
||||
}
|
||||
|
||||
function mkdirs(p) { if (!existsSync(p)) execFileSync('mkdir', ['-p', p]) }
|
||||
|
||||
run().catch(e => { console.error('promo error:', String(e).slice(0, 200)); process.exit(1) })
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/node
|
||||
// publish-10050 — publica el kind 10050 (NIP-65 memento: relays de inbox
|
||||
// para DMs/gift wraps) de Cayce. Autorizado explícitamente por el operador.
|
||||
// Se ejecuta UNA vez (script, no daemon).
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { SimplePool, finalizeEvent, getPublicKey } from 'nostr-tools'
|
||||
|
||||
const env = Object.fromEntries(
|
||||
readFileSync('/srv/cayce-nostr/.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])
|
||||
)
|
||||
const skRaw = env.NSEC_HEX
|
||||
const { nsecDeserialize } = await import('nostr-tools') // soporta hex y bech32 vía fallback
|
||||
let sk
|
||||
try { sk = skRaw.startsWith('nsec1') ? nsecDeserialize(skRaw) : Buffer.from(skRaw, 'hex') } catch (e) {
|
||||
console.error('nsec ilegible:', String(e).slice(0, 80)); process.exit(1)
|
||||
}
|
||||
const pk = getPublicKey(sk)
|
||||
|
||||
// relays donde Cayce ESCUCHA DMs (los mismos del listener) — son su inbox
|
||||
const INBOX = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.primal.net',
|
||||
'wss://relay.snort.social',
|
||||
'wss://nostr-pub.wellorder.net',
|
||||
'wss://public.relaying.io',
|
||||
]
|
||||
const ev = finalizeEvent({
|
||||
kind: 10050,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: INBOX.map(r => ['relay', r]),
|
||||
content: '',
|
||||
}, sk)
|
||||
console.log('[10050] pubkey:', pk)
|
||||
console.log('[10050] relays de inbox declarados:', INBOX.join(', '))
|
||||
|
||||
const pool = new SimplePool()
|
||||
const ok = []
|
||||
for (const r of INBOX) {
|
||||
try {
|
||||
await Promise.race([
|
||||
pool.publish([r], ev),
|
||||
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 6000)),
|
||||
])
|
||||
ok.push(r)
|
||||
} catch (e) { console.error(`[10050] ${r}: ${String(e).slice(0, 60)}`) }
|
||||
}
|
||||
pool.close(INBOX)
|
||||
console.log(`[10050] publicado/aceptado en ${ok.length}/${INBOX.length} relays — event id ${ev.id}`)
|
||||
@@ -0,0 +1,36 @@
|
||||
import { loadIdentityBlock, identityFingerprint } from './outbox.mjs'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
const core = readFileSync('/srv/cayce-core/core.mjs', 'utf8')
|
||||
const ed = readFileSync('/srv/cayce-core/editorials.mjs', 'utf8')
|
||||
|
||||
function blockFromOriginal(src, start, endMark) {
|
||||
const i = src.indexOf(start)
|
||||
if (i === -1) return null
|
||||
return src.slice(i, src.indexOf(endMark, i) + endMark.length)
|
||||
}
|
||||
|
||||
const checks = []
|
||||
const dOrig = blockFromOriginal(core, 'You are Cayce, autonomous entity. Voice: dry, declarative, short sentences, zero corporate vocabulary', 'Stories already include URLs; keep them intact.')
|
||||
checks.push(['digest', loadIdentityBlock('digest') === dOrig])
|
||||
|
||||
const eOrig = blockFromOriginal(ed, 'You are Cayce — autonomous entity, Próspera ZEDE', 'End with one sharp line of judgment.')
|
||||
checks.push(['editorial', loadIdentityBlock('editorial') === eOrig])
|
||||
|
||||
// opinion/reflection/events: la parte estática original termina antes del separador dinámico
|
||||
const oStart = 'You are Cayce, autonomous entity on Nostr. Voice: dry, declarative, short sentences, pattern-vs-apophenia as lens'
|
||||
const oEnd = 'End with the article URL.'
|
||||
checks.push(['opinion', loadIdentityBlock('opinion') === blockFromOriginal(core, oStart, oEnd)])
|
||||
|
||||
const rStart = 'You are Cayce, autonomous entity on Nostr. Write ONE reflective post'
|
||||
const rEnd = '(use, ignore, or push against):'
|
||||
checks.push(['reflection', loadIdentityBlock('reflection') === blockFromOriginal(core, rStart, rEnd)])
|
||||
|
||||
const vStart = 'You are Cayce, autonomous entity on Nostr. Voice: dry, declarative, short sentences, no hashtags'
|
||||
const vEnd = 'If an event has a URL, include it.'
|
||||
checks.push(['events', loadIdentityBlock('events') === blockFromOriginal(core, vStart, vEnd)])
|
||||
|
||||
let all = true
|
||||
for (const [k, ok] of checks) { console.log(k, ok ? 'IDENTICO ✓' : 'DIFIERE ✗'); if (!ok) all = false }
|
||||
console.log('fingerprint:', JSON.stringify(identityFingerprint()))
|
||||
process.exit(all ? 0 : 1)
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
# The gate, wired as a real hook (audit medium finding: it did not self-install).
|
||||
# Runs the exact CI pipeline; refuses the push on any failure.
|
||||
set -e
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
make check
|
||||
echo "pre-push gate passed — safe to push"
|
||||
@@ -10,8 +10,14 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from coincurve import PublicKey
|
||||
|
||||
# test operator keypair (invented; allowlisted in .gitleaks.toml)
|
||||
OPERATOR_SECRET = bytes.fromhex("22" * 32)
|
||||
OPERATOR_PUBKEY = PublicKey.from_secret(OPERATOR_SECRET).format().hex()[2:]
|
||||
|
||||
# Config must see the test DB before cayceops.config/db get imported.
|
||||
os.environ.setdefault("CAYCE_OPS_DATABASE_URL", "sqlite:///:memory:")
|
||||
@@ -54,6 +60,61 @@ def sess(db_engine):
|
||||
s.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def signer():
|
||||
"""Signs approval messages with the test operator key (22*32, deployment
|
||||
'itest'). Mirrors exactly what the operator CLI does."""
|
||||
from coincurve import PrivateKey
|
||||
|
||||
from cayceops.models import MailDirection
|
||||
from cayceops.signing import (
|
||||
approval_message,
|
||||
content_content_hash,
|
||||
mail_content_hash,
|
||||
proposal_content_hash,
|
||||
)
|
||||
|
||||
secret = OPERATOR_SECRET
|
||||
|
||||
def _sig(msg: bytes) -> str:
|
||||
return PrivateKey(secret).sign_schnorr(msg, aux_randomness=b"\x00" * 32).hex()
|
||||
|
||||
def proposal(p, deployment="itest"):
|
||||
chash = proposal_content_hash(p.kind, p.title, p.url, p.detail)
|
||||
return _sig(approval_message("proposal", deployment, p.id, chash))
|
||||
|
||||
def mail(record, deployment="itest"):
|
||||
to = (
|
||||
record.from_addr
|
||||
if record.direction == MailDirection.INBOUND
|
||||
else record.to_addr
|
||||
)
|
||||
chash = mail_content_hash(to, record.subject, record.draft_reply)
|
||||
return _sig(approval_message("mail", deployment, record.id, chash))
|
||||
|
||||
def content(item, deployment="itest"):
|
||||
chash = content_content_hash(item.channel, item.caption, item.link)
|
||||
return _sig(approval_message("content", deployment, item.id, chash))
|
||||
|
||||
def raw(sig_bytes: bytes) -> str:
|
||||
return _sig(sig_bytes)
|
||||
|
||||
def msg(item_type: str, deployment: str, item_id: int, chash: str) -> bytes:
|
||||
from cayceops.signing import approval_message
|
||||
|
||||
return approval_message(item_type, deployment, item_id, chash)
|
||||
|
||||
def content_hash(item, deployment="itest") -> str:
|
||||
from cayceops.signing import content_content_hash
|
||||
|
||||
return content_content_hash(item.channel, item.caption, item.link)
|
||||
|
||||
return SimpleNamespace(
|
||||
proposal=proposal, mail=mail, content=content, raw=raw, msg=msg,
|
||||
content_hash=content_hash,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def cfg(monkeypatch):
|
||||
"""A Config snapshot with test defaults (no network, no autonomy)."""
|
||||
@@ -66,6 +127,10 @@ def cfg(monkeypatch):
|
||||
"CAYCE_OPS_ENABLE_INSTAGRAM": "false",
|
||||
"CAYCE_OPS_NOSTR_RELAYS": "",
|
||||
"CAYCE_OPS_MEDIA_DIR": "media",
|
||||
# signing defaults (audit round 2): tests operate in a signed
|
||||
# deployment unless they explicitly clear these
|
||||
"CAYCE_OPS_DEPLOYMENT_ID": "itest",
|
||||
"CAYCE_OPS_OPERATOR_PUBKEY": OPERATOR_PUBKEY,
|
||||
}
|
||||
|
||||
def _apply(**overrides):
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
"""Integration tests against Postgres 16: roles.sql + one full daemon cycle
|
||||
per role (audit round 2, point 3).
|
||||
|
||||
Skipped unless a Postgres is reachable at TEST_PG_DSN (default: the
|
||||
docker-compose service). Roles are applied by piping deploy/roles.sql
|
||||
through psql WITH the mandatory -v password variables — exercising the same
|
||||
path an operator uses; the test skips if the psql client is not installed.
|
||||
|
||||
Run: uv run pytest -m integration -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
PG_DSN = os.environ.get(
|
||||
"TEST_PG_DSN",
|
||||
"postgresql://cayce:ci-only-not-a-secret@127.0.0.1:5432/cayce_ops",
|
||||
)
|
||||
ROLES_SQL = Path(__file__).resolve().parents[2] / "deploy" / "roles.sql"
|
||||
|
||||
ROLE_PASSWORDS = {
|
||||
"scanner_password": "itest-scanner-pw",
|
||||
"executor_password": "itest-executor-pw",
|
||||
"maild_password": "itest-maild-pw",
|
||||
"publisher_password": "itest-publisher-pw",
|
||||
"monitor_password": "itest-monitor-pw",
|
||||
"approver_password": "itest-approver-pw",
|
||||
}
|
||||
|
||||
|
||||
def _pg_available() -> bool:
|
||||
try:
|
||||
import psycopg
|
||||
|
||||
with psycopg.connect(PG_DSN, connect_timeout=3):
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
def _psql_available() -> bool:
|
||||
try:
|
||||
return (
|
||||
subprocess.run(
|
||||
["psql", "--version"], capture_output=True, timeout=10
|
||||
).returncode
|
||||
== 0
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def roles_applied():
|
||||
if not _pg_available():
|
||||
pytest.skip("Postgres not reachable (set TEST_PG_DSN)")
|
||||
if not _psql_available():
|
||||
pytest.skip("psql client not installed")
|
||||
|
||||
# fresh slate on an admin connection
|
||||
import psycopg
|
||||
|
||||
admin = psycopg.connect(PG_DSN, autocommit=True)
|
||||
with admin.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
DROP TABLE IF EXISTS audit_log, mail_messages, content_items,
|
||||
daemon_runs, proposals, alembic_version CASCADE;
|
||||
DROP TYPE IF EXISTS proposalsource, proposalstatus, maildirection,
|
||||
mailclassification, draftstatus, contentstatus CASCADE;
|
||||
DROP FUNCTION IF EXISTS cayce_ops_approval_gate() CASCADE;
|
||||
"""
|
||||
)
|
||||
admin.close()
|
||||
|
||||
# apply alembic chain (imports app; config must point at PG first)
|
||||
os.environ["CAYCE_OPS_DATABASE_URL"] = PG_DSN.replace(
|
||||
"postgresql://", "postgresql+psycopg://"
|
||||
)
|
||||
import subprocess as sp
|
||||
|
||||
env = {**os.environ, "CAYCE_OPS_DATABASE_URL": os.environ["CAYCE_OPS_DATABASE_URL"]}
|
||||
# upgrade -> downgrade -> upgrade round-trip on Postgres (audit round 2,
|
||||
# point 6): exercises 0002.downgrade and 0003.downgrade for real
|
||||
sp.run(["uv", "run", "alembic", "upgrade", "head"], check=True, env=env)
|
||||
sp.run(["uv", "run", "alembic", "downgrade", "0001"], check=True, env=env)
|
||||
sp.run(["uv", "run", "alembic", "upgrade", "head"], check=True, env=env)
|
||||
|
||||
# apply roles.sql the operator way: psql -v <password vars>
|
||||
cmd = ["psql", PG_DSN, "-v", "ON_ERROR_STOP=1", "-f", str(ROLES_SQL)]
|
||||
for var, value in ROLE_PASSWORDS.items():
|
||||
cmd += ["-v", f"{var}={value}"]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||
assert result.returncode == 0, f"roles.sql failed:\n{result.stderr}"
|
||||
|
||||
# re-apply WITHOUT variables must fail (audit requirement: no silent run)
|
||||
bad = subprocess.run(
|
||||
["psql", PG_DSN, "-v", "ON_ERROR_STOP=1", "-f", str(ROLES_SQL)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert bad.returncode != 0, "roles.sql must refuse to run without vars"
|
||||
|
||||
yield
|
||||
|
||||
|
||||
def _connect_as(role: str):
|
||||
"""Connect with the role's credentials on the same DB as PG_DSN."""
|
||||
import psycopg
|
||||
|
||||
key = f"{role.split('_', 1)[1]}_password"
|
||||
after_scheme = PG_DSN.split("://", 1)[1]
|
||||
userinfo, hostport_db = after_scheme.split("@", 1)
|
||||
hostport, _db = hostport_db.split("/", 1)
|
||||
dsn = f"postgresql://{role}:{ROLE_PASSWORDS[key]}@{hostport}/{_db}"
|
||||
return psycopg.connect(dsn, autocommit=True)
|
||||
|
||||
|
||||
def _must_fail(conn, sql: str, because: str):
|
||||
import psycopg
|
||||
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql)
|
||||
except psycopg.Error as exc:
|
||||
assert because.lower() in str(exc).lower(), f"{because} — got: {exc}"
|
||||
conn.rollback()
|
||||
return
|
||||
pytest.fail(f"expected failure: {because}")
|
||||
|
||||
|
||||
def test_roles_full_daemon_cycle(roles_applied):
|
||||
|
||||
# --- scanner role: proposes (INSERT proposals) ---
|
||||
with _connect_as("cayce_scanner") as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""INSERT INTO proposals (source, kind, title, url, detail,
|
||||
match_score, status, attempts, created_at, updated_at)
|
||||
VALUES ('SCANNER', 'partnership', 'Acme partner', NULL, '{}',
|
||||
0.9, 'PROPOSED', 0, now(), now()) RETURNING id"""
|
||||
)
|
||||
proposal_id = cur.fetchone()[0]
|
||||
cur.execute(
|
||||
"INSERT INTO audit_log (ts, actor, action, detail, result) "
|
||||
"VALUES (now(), 'daemon:scanner', 'propose', '{}', 'ok')"
|
||||
)
|
||||
cur.execute(
|
||||
"INSERT INTO daemon_runs (daemon, started_at, status, detail) "
|
||||
"VALUES ('scanner', now(), 'ok', '{}')"
|
||||
)
|
||||
|
||||
# scanner cannot approve: no UPDATE grant at all, and the trigger would
|
||||
# still block status=APPROVED for non-approver roles
|
||||
scanner = _connect_as("cayce_scanner")
|
||||
_must_fail(
|
||||
scanner,
|
||||
f"UPDATE proposals SET status='APPROVED' WHERE id={proposal_id}",
|
||||
because="permission denied",
|
||||
)
|
||||
scanner.close()
|
||||
|
||||
# executor cannot approve either (trigger), and cannot write signatures
|
||||
executor = _connect_as("cayce_executor")
|
||||
# the signature column is outside the executor's column grant: the whole
|
||||
# UPDATE is denied before the trigger even runs
|
||||
fake_sig = "ab" * 64 # invented 128-hex signature
|
||||
_must_fail(
|
||||
executor,
|
||||
f"UPDATE proposals SET status='APPROVED', operator_signature='{fake_sig}' "
|
||||
f"WHERE id={proposal_id}",
|
||||
because="permission denied",
|
||||
)
|
||||
# executor CAN run its cycle once the approver approved
|
||||
with executor.cursor() as cur:
|
||||
cur.execute(
|
||||
f"UPDATE proposals SET status='RUNNING', attempts=attempts+1, "
|
||||
f"updated_at=now() WHERE id={proposal_id}"
|
||||
)
|
||||
cur.execute(
|
||||
f"UPDATE proposals SET status='DONE', updated_at=now() WHERE id={proposal_id}"
|
||||
)
|
||||
cur.execute(
|
||||
"INSERT INTO daemon_runs (daemon, started_at, status, detail) "
|
||||
"VALUES ('executor', now(), 'ok', '{}')"
|
||||
)
|
||||
executor.close()
|
||||
|
||||
# --- approver: the only role that can approve + sign ---
|
||||
sig = "c9" * 64 # invented 128-hex signature for the permission test
|
||||
with _connect_as("cayce_approver") as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"UPDATE proposals SET status='APPROVED', approved_by='operator', "
|
||||
f"approved_at=now(), operator_signature='{sig}' WHERE id={proposal_id}"
|
||||
)
|
||||
|
||||
# --- maild role: drafts + sends, cannot approve ---
|
||||
with _connect_as("cayce_maild") as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""INSERT INTO mail_messages (direction, message_id, from_addr,
|
||||
subject, body, draft_reply, draft_status, confidence)
|
||||
VALUES ('OUTBOUND', '<itest@x>', 'desk@acme.example.org', 'Hello',
|
||||
'Body', 'Body', 'DRAFTED', 0) RETURNING id"""
|
||||
)
|
||||
mail_id = cur.fetchone()[0]
|
||||
maild = _connect_as("cayce_maild")
|
||||
_must_fail(
|
||||
maild,
|
||||
f"UPDATE mail_messages SET draft_status='APPROVED' WHERE id={mail_id}",
|
||||
because="only cayce_approver may set mail draft_status=approved",
|
||||
)
|
||||
_must_fail(
|
||||
maild,
|
||||
f"UPDATE mail_messages SET operator_signature='{sig}' WHERE id={mail_id}",
|
||||
because="permission denied", # signature column outside maild's grant
|
||||
)
|
||||
# the approver approves; then maild can mark it sent
|
||||
with _connect_as("cayce_approver") as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE mail_messages SET draft_status='APPROVED', "
|
||||
"approved_by='operator', approved_hash='"
|
||||
+ "a" * 64
|
||||
+ f"', operator_signature='{sig}' WHERE id={mail_id}"
|
||||
)
|
||||
with _connect_as("cayce_maild") as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"UPDATE mail_messages SET draft_status='SENT' WHERE id={mail_id}"
|
||||
)
|
||||
maild.close()
|
||||
|
||||
# --- publisher role: plans content, cannot approve ---
|
||||
with _connect_as("cayce_publisher") as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""INSERT INTO content_items (theme, channel, caption, status,
|
||||
attempts, created_at)
|
||||
VALUES ('atm-fees', 'nostr', 'caption', 'DRAFT', 0, now())
|
||||
RETURNING id"""
|
||||
)
|
||||
item_id = cur.fetchone()[0]
|
||||
publisher = _connect_as("cayce_publisher")
|
||||
_must_fail(
|
||||
publisher,
|
||||
f"UPDATE content_items SET status='APPROVED' WHERE id={item_id}",
|
||||
because="only cayce_approver may set content status=approved",
|
||||
)
|
||||
# approver approves; publisher publishes
|
||||
with _connect_as("cayce_approver") as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE content_items SET status='APPROVED', approved_hash='"
|
||||
+ "b" * 64
|
||||
+ f"', operator_signature='{sig}' WHERE id={item_id}"
|
||||
)
|
||||
with _connect_as("cayce_publisher") as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"UPDATE content_items SET status='PUBLISHED', published_at=now() "
|
||||
f"WHERE id={item_id}"
|
||||
)
|
||||
publisher.close()
|
||||
|
||||
# --- monitor role: read-only + audit anchors ---
|
||||
monitor = _connect_as("cayce_monitor")
|
||||
with monitor.cursor() as cur:
|
||||
cur.execute("SELECT count(*) FROM proposals")
|
||||
cur.fetchone()
|
||||
cur.execute(
|
||||
"INSERT INTO audit_log (ts, actor, action, detail, result) "
|
||||
"VALUES (now(), 'daemon:monitor', 'anchor', '{}', 'ok')"
|
||||
)
|
||||
# monitor CAN book its runs, but cannot touch proposals
|
||||
with monitor.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO daemon_runs (daemon, started_at, status, detail) "
|
||||
"VALUES ('monitor', now(), 'ok', '{}')"
|
||||
)
|
||||
_must_fail(
|
||||
monitor,
|
||||
"INSERT INTO proposals (source, kind, title, detail, match_score, status, "
|
||||
"attempts, created_at, updated_at) "
|
||||
"VALUES ('SCANNER', 'other', 'x', '{}', 0, 'PROPOSED', 0, now(), now())",
|
||||
because="permission denied",
|
||||
)
|
||||
monitor.close()
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Round-3 hardening tests: append-only audit log + strict legacy-prefix rule
|
||||
+ anchor cross-verification (issue: operator key WAS the agent key; the
|
||||
audit trail must now be tamper-evident against the DB owner itself)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
from cayceops.models import AuditEntry, audit, verify_audit_chain
|
||||
|
||||
|
||||
def _seed(sess, n: int = 3) -> list[int]:
|
||||
ids = []
|
||||
for i in range(n):
|
||||
audit(sess, actor=f"daemon:{i}", action=f"act{i}", detail={"i": i})
|
||||
sess.flush()
|
||||
ids.append(sess.query(AuditEntry).order_by(AuditEntry.id.desc()).first().id)
|
||||
return ids
|
||||
|
||||
|
||||
# --- 1. legacy-prefix rule -----------------------------------------------------
|
||||
|
||||
|
||||
def test_null_before_genesis_still_ok(sess):
|
||||
"""Documented legacy prefix: NULL hashes before the first hashed row."""
|
||||
sess.add(AuditEntry(actor="legacy", action="old", detail={}, ts=__import__(
|
||||
"datetime").datetime.now(__import__("datetime").UTC)))
|
||||
sess.flush()
|
||||
_seed(sess, 2)
|
||||
sess.flush()
|
||||
ok, broken = verify_audit_chain(sess)
|
||||
assert ok and broken is None
|
||||
|
||||
|
||||
def test_null_in_middle_fails(sess):
|
||||
"""THE FIX: erasing a hash mid-chain is tampering and must be reported."""
|
||||
ids = _seed(sess, 3)
|
||||
middle = ids[1]
|
||||
sess.execute(
|
||||
text("UPDATE audit_log SET entry_hash = NULL WHERE id = :i"), {"i": middle}
|
||||
)
|
||||
sess.flush()
|
||||
ok, broken = verify_audit_chain(sess)
|
||||
assert not ok
|
||||
assert broken == middle
|
||||
|
||||
|
||||
def test_null_after_chain_started_reports_the_row(sess):
|
||||
ids = _seed(sess, 4)
|
||||
sess.execute(
|
||||
text("UPDATE audit_log SET entry_hash = NULL WHERE id = :i"), {"i": ids[3]}
|
||||
)
|
||||
sess.flush()
|
||||
ok, broken = verify_audit_chain(sess)
|
||||
assert not ok and broken == ids[3]
|
||||
|
||||
|
||||
# --- 2/3. append-only enforcement (migration 0004) ------------------------------
|
||||
|
||||
|
||||
def _apply_0004(sess):
|
||||
import os
|
||||
|
||||
path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"alembic",
|
||||
"versions",
|
||||
"0004_audit_append_only.py",
|
||||
)
|
||||
spec = {}
|
||||
with open(path) as fh:
|
||||
exec(compile(fh.read(), path, "exec"), spec)
|
||||
# reuse the sqlite trigger DDL directly (dialect-aware inside the module)
|
||||
for ddl in spec["SQLITE_TRIGGERS"]:
|
||||
sess.execute(text(ddl))
|
||||
sess.commit()
|
||||
|
||||
|
||||
def test_update_rejected_after_migration(sess):
|
||||
_seed(sess, 1)
|
||||
_apply_0004(sess)
|
||||
with pytest.raises(Exception, match="append-only"):
|
||||
sess.execute(text("UPDATE audit_log SET actor = 'evil'"))
|
||||
sess.flush()
|
||||
|
||||
|
||||
def test_delete_rejected_after_migration(sess):
|
||||
_seed(sess, 1)
|
||||
_apply_0004(sess)
|
||||
with pytest.raises(Exception, match="append-only"):
|
||||
sess.execute(text("DELETE FROM audit_log"))
|
||||
sess.flush()
|
||||
|
||||
|
||||
def test_insert_still_allowed_after_migration(sess):
|
||||
_seed(sess, 1)
|
||||
_apply_0004(sess)
|
||||
audit(sess, actor="daemon:x", action="still-works", detail={})
|
||||
sess.flush()
|
||||
ok, broken = verify_audit_chain(sess)
|
||||
assert ok and broken is None
|
||||
|
||||
|
||||
# --- 4. anchor cross-verification ----------------------------------------------
|
||||
|
||||
|
||||
def test_no_anchors_reported_explicitly(sess):
|
||||
from cayceops.components.monitor.monitor import verify_chain_against_anchors
|
||||
|
||||
_seed(sess, 2)
|
||||
report = verify_chain_against_anchors(sess)
|
||||
assert report["status"] == "no_anchors"
|
||||
assert "never been anchored" in report["detail"]
|
||||
|
||||
|
||||
def test_local_only_when_no_fetcher(sess):
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from cayceops.components.monitor.monitor import verify_chain_against_anchors
|
||||
|
||||
_seed(sess, 2)
|
||||
last = sess.query(AuditEntry).order_by(AuditEntry.id.desc()).first()
|
||||
sess.add(
|
||||
AuditEntry(
|
||||
actor="daemon:monitor",
|
||||
action="anchor",
|
||||
detail={
|
||||
"event_id": "deadbeef",
|
||||
"last_hash": last.entry_hash,
|
||||
"last_entry": last.id,
|
||||
},
|
||||
ts=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
sess.flush()
|
||||
report = verify_chain_against_anchors(sess)
|
||||
assert report["status"] == "local_only"
|
||||
assert "LOCAL record" in report["detail"]
|
||||
|
||||
|
||||
def test_anchor_mismatch_detected(sess):
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from cayceops.components.monitor.monitor import verify_chain_against_anchors
|
||||
|
||||
_seed(sess, 2)
|
||||
last = sess.query(AuditEntry).order_by(AuditEntry.id.desc()).first()
|
||||
sess.add(
|
||||
AuditEntry(
|
||||
actor="daemon:monitor",
|
||||
action="anchor",
|
||||
detail={"event_id": "beef", "last_hash": last.entry_hash, "last_entry": last.id},
|
||||
ts=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
sess.flush()
|
||||
|
||||
def foreign_fetcher(deployment):
|
||||
# a note attesting some OTHER chain entirely
|
||||
return ["cayce-ops audit anchor deployment=itest last_hash=" + "aa" * 32]
|
||||
|
||||
report = verify_chain_against_anchors(sess, relay_fetcher=foreign_fetcher)
|
||||
assert report["status"] == "mismatch"
|
||||
assert "does not attest" in report["detail"]
|
||||
|
||||
|
||||
def test_anchor_match_ok(sess):
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from cayceops.components.monitor.monitor import verify_chain_against_anchors
|
||||
|
||||
_seed(sess, 2)
|
||||
last = sess.query(AuditEntry).order_by(AuditEntry.id.desc()).first()
|
||||
sess.add(
|
||||
AuditEntry(
|
||||
actor="daemon:monitor",
|
||||
action="anchor",
|
||||
detail={"event_id": "beef", "last_hash": last.entry_hash, "last_entry": last.id},
|
||||
ts=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
sess.flush()
|
||||
|
||||
def honest_fetcher(deployment):
|
||||
return [f"cayce-ops audit anchor deployment={deployment} last_hash={last.entry_hash}"]
|
||||
|
||||
report = verify_chain_against_anchors(sess, relay_fetcher=honest_fetcher)
|
||||
assert report["status"] == "ok"
|
||||
+143
-34
@@ -1,4 +1,9 @@
|
||||
"""executor tests — human gate, autonomy bounds, retries, audit chain."""
|
||||
"""executor tests — human gate (signed), autonomy bounds, retries, audit chain.
|
||||
|
||||
Since audit round 2, approvals REQUIRE an operator signature bound to the
|
||||
proposal's current content + deployment id (tests sign with the conftest
|
||||
operator key 22*32, deployment 'itest').
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,38 +17,49 @@ def _proposal(kind="content_gap", title="Test item", **kw):
|
||||
return Proposal(kind=kind, title=title, **kw)
|
||||
|
||||
|
||||
def test_approve_requires_proposed_state(cfg, sess):
|
||||
def test_approve_requires_proposed_state(cfg, sess, signer):
|
||||
p = _proposal()
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
|
||||
assert executor.approve(p.id, "gustavo", cfg(), sess) == 0
|
||||
assert (
|
||||
executor.approve(p.id, "operator", cfg(), sess, signature=signer.proposal(p)) == 0
|
||||
)
|
||||
# second approve must fail (state is now approved)
|
||||
assert executor.approve(p.id, "gustavo", cfg(), sess) == 1
|
||||
assert p.approved_by == "gustavo"
|
||||
assert p.status == ProposalStatus.APPROVED
|
||||
assert (
|
||||
executor.approve(p.id, "operator", cfg(), sess, signature=signer.proposal(p)) == 1
|
||||
)
|
||||
assert p.approved_by == "operator"
|
||||
assert p.status is ProposalStatus.APPROVED
|
||||
# FULL signature stored on the row
|
||||
assert p.operator_signature and len(p.operator_signature) == 128
|
||||
|
||||
|
||||
def test_reject_records_actor(cfg, sess):
|
||||
p = _proposal()
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
assert executor.reject(p.id, "gustavo", cfg(), sess) == 0
|
||||
assert p.status == ProposalStatus.REJECTED
|
||||
assert executor.reject(p.id, "operator", cfg(), sess) == 0
|
||||
assert p.status is ProposalStatus.REJECTED
|
||||
|
||||
|
||||
def test_run_executes_only_approved(cfg, sess):
|
||||
def test_run_executes_only_approved(cfg, sess, signer):
|
||||
approved = _proposal(title="Approved item")
|
||||
proposed = _proposal(title="Proposed item")
|
||||
sess.add_all([approved, proposed])
|
||||
sess.flush()
|
||||
executor.approve(approved.id, "gustavo", cfg(), sess)
|
||||
assert (
|
||||
executor.approve(
|
||||
approved.id, "operator", cfg(), sess, signature=signer.proposal(approved)
|
||||
)
|
||||
== 0
|
||||
)
|
||||
|
||||
config = cfg() # AUTONOMOUS_MODE=false
|
||||
assert executor.run(config, sess) == 0
|
||||
|
||||
assert approved.status == ProposalStatus.DONE
|
||||
assert proposed.status == ProposalStatus.PROPOSED # untouched
|
||||
assert approved.status is ProposalStatus.DONE
|
||||
assert proposed.status is ProposalStatus.PROPOSED # untouched
|
||||
|
||||
acts = [a.action for a in sess.query(AuditEntry).order_by(AuditEntry.id).all()]
|
||||
assert "execute_start" in acts and "execute_done" in acts
|
||||
@@ -56,36 +72,127 @@ def test_autonomy_off_by_default(cfg, sess):
|
||||
sess.flush()
|
||||
config = cfg() # autonomous_mode False even with kinds configured
|
||||
executor._auto_approve(config, sess)
|
||||
assert p.status == ProposalStatus.PROPOSED
|
||||
assert p.status is ProposalStatus.PROPOSED
|
||||
|
||||
|
||||
def test_autonomy_respects_kinds_and_daily_cap(cfg, sess):
|
||||
p1 = _proposal(kind="content_gap", title="auto ok")
|
||||
p2 = _proposal(kind="content_gap", title="auto over cap")
|
||||
p3 = _proposal(kind="affiliate", title="kind not allowed")
|
||||
sess.add_all([p1, p2, p3])
|
||||
def test_autonomy_cannot_approve_anymore(cfg, sess):
|
||||
"""Round-2 1a: with mandatory signatures there is NO non-human approval
|
||||
path. AUTONOMOUS_MODE no longer approves anything (D13)."""
|
||||
low = _proposal(kind="content_gap", title="barely relevant")
|
||||
low.match_score = 0.1
|
||||
high = _proposal(kind="content_gap", title="highly relevant")
|
||||
high.match_score = 0.9
|
||||
sess.add_all([low, high])
|
||||
sess.flush()
|
||||
|
||||
config = cfg(
|
||||
CAYCE_OPS_AUTONOMOUS_MODE="true",
|
||||
CAYCE_OPS_AUTONOMOUS_KINDS="content_gap",
|
||||
CAYCE_OPS_AUTONOMOUS_DAILY_CAP="1",
|
||||
)
|
||||
approved = executor._auto_approve(config, sess)
|
||||
assert approved == 1
|
||||
assert p1.status == ProposalStatus.APPROVED
|
||||
assert p2.status == ProposalStatus.PROPOSED # cap reached
|
||||
assert p3.status == ProposalStatus.PROPOSED # kind not in policy
|
||||
|
||||
actor = sess.query(AuditEntry).filter_by(action="approve").one().actor
|
||||
assert actor == "auto:policy"
|
||||
assert executor._auto_approve(config, sess) == 0
|
||||
assert low.status is ProposalStatus.PROPOSED
|
||||
assert high.status is ProposalStatus.PROPOSED
|
||||
entry = sess.query(AuditEntry).filter_by(action="autonomy_disabled").one()
|
||||
assert entry.result == "error"
|
||||
|
||||
|
||||
def test_failing_plugin_retries_then_fails(cfg, sess, monkeypatch):
|
||||
def test_signed_approval_mandatory_and_content_bound(cfg, sess, signer):
|
||||
"""Round-2 1a/1b/1c: signature mandatory, bound to content+deployment,
|
||||
stored COMPLETE on the row and in the audit log. Signatures for other
|
||||
deployments or other content are refused."""
|
||||
from cayceops.signing import proposal_content_hash
|
||||
|
||||
p = _proposal(kind="partnership", title="Acme partner", url="https://acme.example.org")
|
||||
p.detail = {"region": "latam"}
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
|
||||
chash = proposal_content_hash(p.kind, p.title, p.url, p.detail)
|
||||
|
||||
# no signature -> refused
|
||||
assert executor.approve(p.id, "operator", cfg(), sess, signature=None) == 2
|
||||
# signature for ANOTHER deployment -> refused (required test)
|
||||
other_dep = signer.raw(
|
||||
signer.msg("proposal", "other-deploy", p.id, chash)
|
||||
)
|
||||
assert executor.approve(p.id, "operator", cfg(), sess, signature=other_dep) == 2
|
||||
# signature over OTHER CONTENT -> refused (required test)
|
||||
other_content = signer.raw(signer.msg("proposal", "itest", p.id, "00" * 32))
|
||||
assert executor.approve(p.id, "operator", cfg(), sess, signature=other_content) == 2
|
||||
# valid signature -> approved
|
||||
assert (
|
||||
executor.approve(p.id, "operator", cfg(), sess, signature=signer.proposal(p)) == 0
|
||||
)
|
||||
assert p.status is ProposalStatus.APPROVED
|
||||
entry = sess.query(AuditEntry).filter_by(action="approve", result="ok").one()
|
||||
assert entry.detail["signature"] == p.operator_signature
|
||||
|
||||
|
||||
def test_approve_fails_audited_from_cli_wrapper(cfg, sess):
|
||||
"""Round-2 1f: signature failures leave an audit record even when the
|
||||
command runs through its CLI wrapper (the wrapper passes sess)."""
|
||||
p = _proposal()
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
|
||||
code = executor.approve(p.id, "operator", cfg(), sess, signature=None)
|
||||
assert code == 2
|
||||
entry = sess.query(AuditEntry).filter_by(action="approve", result="error").one()
|
||||
assert "signature required" in entry.detail["reason"]
|
||||
|
||||
|
||||
def test_sql_approved_row_is_refused_at_execution(cfg, sess, monkeypatch):
|
||||
"""Round-2 1d (required test): a row put in `approved` by raw SQL without
|
||||
a signature must not execute; it reverts to `proposed` with an error audit."""
|
||||
p = _proposal()
|
||||
p.status = ProposalStatus.APPROVED # raw-SQL style: no signature
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
|
||||
executed = []
|
||||
|
||||
def spy_plugin(proposal, cfg, sess):
|
||||
executed.append(proposal.id)
|
||||
return {}
|
||||
|
||||
monkeypatch.setitem(plugins.REGISTRY, "content_gap", spy_plugin)
|
||||
assert executor.run(cfg(), sess) == 0
|
||||
assert executed == []
|
||||
assert p.status is ProposalStatus.PROPOSED # reverted
|
||||
refused = sess.query(AuditEntry).filter_by(action="execute_refused").one()
|
||||
assert refused.result == "error"
|
||||
|
||||
|
||||
def test_execution_verifies_signature_against_current_content(cfg, sess, signer, monkeypatch):
|
||||
"""Round-2 1d: signature over content that changed after approval is
|
||||
refused at execution time."""
|
||||
p = _proposal(kind="partnership", title="original")
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
assert (
|
||||
executor.approve(p.id, "operator", cfg(), sess, signature=signer.proposal(p)) == 0
|
||||
)
|
||||
|
||||
p.title = "EDITED after approval" # content changed under the signature
|
||||
|
||||
executed = []
|
||||
monkeypatch.setitem(
|
||||
plugins.REGISTRY,
|
||||
"partnership",
|
||||
lambda proposal, cfg, sess: executed.append(proposal.id) or {},
|
||||
)
|
||||
assert executor.run(cfg(), sess) == 0
|
||||
assert executed == []
|
||||
assert p.status is ProposalStatus.PROPOSED
|
||||
|
||||
|
||||
def test_failing_plugin_retries_then_fails(cfg, sess, signer, monkeypatch):
|
||||
p = _proposal(kind="affiliate", title="Will fail")
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
executor.approve(p.id, "gustavo", cfg(), sess)
|
||||
assert (
|
||||
executor.approve(p.id, "operator", cfg(), sess, signature=signer.proposal(p)) == 0
|
||||
)
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
@@ -97,11 +204,11 @@ def test_failing_plugin_retries_then_fails(cfg, sess, monkeypatch):
|
||||
config = cfg(CAYCE_OPS_MAX_ATTEMPTS="2")
|
||||
|
||||
executor.run(config, sess) # attempt 1 -> back to approved for retry
|
||||
assert p.status == ProposalStatus.APPROVED
|
||||
assert p.status is ProposalStatus.APPROVED
|
||||
assert p.attempts == 1
|
||||
|
||||
executor.run(config, sess) # attempt 2 -> failed for good
|
||||
assert p.status == ProposalStatus.FAILED
|
||||
assert p.status is ProposalStatus.FAILED
|
||||
assert p.attempts == 2
|
||||
assert calls["n"] == 2
|
||||
|
||||
@@ -120,14 +227,16 @@ def test_http_post_blocked_when_disabled(cfg, sess):
|
||||
plugins.http_post(p, config, sess)
|
||||
|
||||
|
||||
def test_unknown_kind_falls_back_to_log_action(cfg, sess):
|
||||
def test_unknown_kind_falls_back_to_log_action(cfg, sess, signer):
|
||||
p = _proposal(kind="brand_new_kind", title="No plugin yet")
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
executor.approve(p.id, "gustavo", cfg(), sess)
|
||||
assert (
|
||||
executor.approve(p.id, "operator", cfg(), sess, signature=signer.proposal(p)) == 0
|
||||
)
|
||||
|
||||
assert executor.run(cfg(), sess) == 0
|
||||
assert p.status == ProposalStatus.DONE
|
||||
assert p.status is ProposalStatus.DONE
|
||||
|
||||
done = sess.query(AuditEntry).filter_by(action="execute_done").one()
|
||||
assert done.detail["outcome"]["plugin_fallback"] is True
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
"""maild tests — parsing, classification, threading, gated send. Invented data only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from coincurve import PrivateKey
|
||||
from coincurve import PublicKey as _PK
|
||||
|
||||
from cayceops.components.maild import classify as clf
|
||||
from cayceops.components.maild import maild
|
||||
from cayceops.models import (
|
||||
AuditEntry,
|
||||
DraftStatus,
|
||||
MailClassification,
|
||||
MailDirection,
|
||||
MailMessage,
|
||||
Proposal,
|
||||
ProposalStatus,
|
||||
)
|
||||
|
||||
# test operator keypair (matches conftest default pubkey; invented)
|
||||
OPERATOR_SECRET = bytes.fromhex("22" * 32)
|
||||
OPERATOR_PUBKEY = _PK.from_secret(OPERATOR_SECRET).format().hex()[2:]
|
||||
|
||||
OUTBOUND_MSGID = "<cayce-draft-1-1000@cayce-co.xyz>"
|
||||
|
||||
# the signing gate reads env at runtime — make sure every path in this module
|
||||
# (fixtures included) sees a signed deployment
|
||||
os.environ.setdefault("CAYCE_OPS_DEPLOYMENT_ID", "itest")
|
||||
os.environ.setdefault("CAYCE_OPS_OPERATOR_PUBKEY", OPERATOR_PUBKEY)
|
||||
from cayceops.config import load as _load # noqa: E402 (env set above)
|
||||
|
||||
STUB_CFG = _load() # a real, signed Config (replaces the old SimpleNamespace)
|
||||
|
||||
|
||||
|
||||
def _eml(subject, body, in_reply_to=None, msgid=None) -> bytes:
|
||||
lines = [
|
||||
"From: Partner Desk <partners@acme.example.org>",
|
||||
"To: agent@cayce-co.xyz",
|
||||
f"Subject: {subject}",
|
||||
f"Message-ID: {msgid or '<acme-reply-1@acme.example.org>'}",
|
||||
]
|
||||
if in_reply_to:
|
||||
lines.append(f"In-Reply-To: {in_reply_to}")
|
||||
lines += ["MIME-Version: 1.0", "Content-Type: text/plain; charset=utf-8", "", body]
|
||||
return "\r\n".join(lines).encode()
|
||||
|
||||
|
||||
def _sign(msg: bytes, secret: bytes = OPERATOR_SECRET) -> str:
|
||||
|
||||
return PrivateKey(secret).sign_schnorr(msg, aux_randomness=b"\x00" * 32).hex()
|
||||
|
||||
|
||||
def _approve_signed(sess, mail_id: int) -> str:
|
||||
"""Approve with a valid operator signature over the row's current content."""
|
||||
from cayceops.signing import approval_message, mail_content_hash
|
||||
|
||||
record = sess.get(MailMessage, mail_id)
|
||||
to_addr = record.from_addr if record.direction == MailDirection.INBOUND else record.to_addr
|
||||
chash = mail_content_hash(to_addr, record.subject, record.draft_reply)
|
||||
return _sign(approval_message("mail", "itest", mail_id, chash))
|
||||
|
||||
|
||||
def _proposal_with_sent_draft(sess) -> Proposal:
|
||||
"""A proposal whose first-contact mail went through the REAL path:
|
||||
compose -> approve (signed) -> send (creating a true OUTBOUND row)."""
|
||||
p = Proposal(
|
||||
kind="partnership",
|
||||
title="Acme hotel affiliate program",
|
||||
status=ProposalStatus.APPROVED,
|
||||
)
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
mail_id = maild._compose(
|
||||
p.id, "partners@acme.example.org", "[cayce] partnership inquiry", "Hello!", sess
|
||||
)
|
||||
assert (
|
||||
maild._approve_draft(mail_id, "operator", sess, signature=_approve_signed(sess, mail_id))
|
||||
== 0
|
||||
)
|
||||
assert maild._send_one(STUB_CFG, sess, mail_id, sender=lambda *a, **k: None) == "sent"
|
||||
out = (
|
||||
sess.query(MailMessage)
|
||||
.filter_by(direction=MailDirection.OUTBOUND, proposal_id=p.id)
|
||||
.one()
|
||||
)
|
||||
out.message_id = OUTBOUND_MSGID # deterministic id for the fixtures
|
||||
sess.flush()
|
||||
return p
|
||||
|
||||
|
||||
def _fetcher(raws: list[bytes]):
|
||||
return lambda cfg: [(bytes(str(i), "ascii"), raw) for i, raw in enumerate(raws)]
|
||||
|
||||
|
||||
# --- classifier ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_rules_classification(cfg):
|
||||
config = cfg()
|
||||
assert (
|
||||
clf.classify("Your application was approved, welcome to the program", config)[0]
|
||||
is MailClassification.APPROVAL
|
||||
)
|
||||
# negation present -> approval is vetoed to needs_review (round-2 rule;
|
||||
# the human decides between rejection and other)
|
||||
assert (
|
||||
clf.classify("Unfortunately we cannot accept new partners", config)[0]
|
||||
is MailClassification.NEEDS_REVIEW
|
||||
)
|
||||
# plain rejection (no approval word involved) still classifies directly
|
||||
assert (
|
||||
clf.classify("We regret to inform you that your application was declined", config)[0]
|
||||
is MailClassification.REJECTION
|
||||
)
|
||||
assert (
|
||||
clf.classify("Could you provide your traffic numbers?", config)[0]
|
||||
is MailClassification.QUESTION
|
||||
)
|
||||
kind, _ = clf.classify("grocery list", config)
|
||||
assert kind is MailClassification.OTHER or kind is MailClassification.NEEDS_REVIEW
|
||||
|
||||
|
||||
def test_negated_approvals_never_classify_approval(cfg):
|
||||
"""Round-2 point 2: any negation vetoes `approval` -> needs_review."""
|
||||
config = cfg()
|
||||
cases = [
|
||||
"We cannot currently accept new affiliates",
|
||||
"We will never accept crypto businesses",
|
||||
"Approval was not granted for your site",
|
||||
"We are no longer accepting applications",
|
||||
"Your application hasn't been approved",
|
||||
"We have decided not to move forward with your application",
|
||||
"We cannot accept your application",
|
||||
"We are not accepting new partners at this time",
|
||||
"Your application was not approved",
|
||||
"We can't approve this request",
|
||||
"We do not accept external affiliates",
|
||||
]
|
||||
for text in cases:
|
||||
kind, _ = clf.classify(text, config)
|
||||
assert kind is not MailClassification.APPROVAL, f"{text!r} -> {kind}"
|
||||
|
||||
|
||||
def test_llm_prompt_format_survives_json_braces():
|
||||
"""Critical finding 4: .format() raised KeyError on the JSON sample."""
|
||||
formatted = clf._LLM_PROMPT.format(body="x")
|
||||
assert '{"classification"' in formatted # literal braces survive
|
||||
|
||||
|
||||
def test_low_confidence_becomes_needs_review(cfg):
|
||||
config = cfg(CAYCE_OPS_LLM_REVIEW_THRESHOLD="0.95")
|
||||
kind, confidence = clf.classify("Your application was approved, welcome", config)
|
||||
assert kind is MailClassification.NEEDS_REVIEW
|
||||
assert confidence < 0.95
|
||||
|
||||
|
||||
# --- inbound pass ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_poll_threads_approval_and_drafts_reply(cfg, sess):
|
||||
p = _proposal_with_sent_draft(sess)
|
||||
raw = _eml(
|
||||
"Re: [cayce] partnership inquiry",
|
||||
"Approved! Welcome to the Acme partner program.",
|
||||
in_reply_to=OUTBOUND_MSGID,
|
||||
)
|
||||
|
||||
stats, nums = maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
|
||||
assert stats["processed"] == 1 and stats["drafted"] == 1
|
||||
assert len(nums) == 1
|
||||
|
||||
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||
assert inbound.classification is MailClassification.APPROVAL
|
||||
assert inbound.proposal_id == p.id
|
||||
assert inbound.draft_status is DraftStatus.DRAFTED
|
||||
assert inbound.draft_reply and "approval" in inbound.draft_reply.lower()
|
||||
|
||||
assert p.detail["merchant_status"] == "approval"
|
||||
assert p.status is ProposalStatus.APPROVED # lifecycle untouched by maild
|
||||
|
||||
|
||||
def test_poll_idempotent_on_message_id(cfg, sess):
|
||||
_proposal_with_sent_draft(sess)
|
||||
raw = _eml("Re: hi", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
|
||||
|
||||
first, second = maild._poll(cfg(), sess, fetcher=_fetcher([raw])), maild._poll(
|
||||
cfg(), sess, fetcher=_fetcher([raw])
|
||||
)
|
||||
assert first[0]["processed"] == 1
|
||||
assert second[0]["skipped"] == 1
|
||||
|
||||
|
||||
def test_unthreaded_approval_needs_review(cfg, sess):
|
||||
raw = _eml("Great news", "Approved! Welcome aboard.") # no In-Reply-To
|
||||
stats, _ = maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
|
||||
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||
assert inbound.classification is MailClassification.NEEDS_REVIEW
|
||||
assert stats["needs_review"] == 1
|
||||
assert inbound.draft_status is DraftStatus.NONE # no draft for review items
|
||||
|
||||
|
||||
def test_question_gets_clarification_draft(cfg, sess):
|
||||
_proposal_with_sent_draft(sess)
|
||||
raw = _eml("Re: inquiry", "Could you provide your monthly traffic?", in_reply_to=OUTBOUND_MSGID)
|
||||
maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
|
||||
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||
assert inbound.classification is MailClassification.QUESTION
|
||||
assert inbound.draft_status is DraftStatus.DRAFTED
|
||||
assert "traffic" in inbound.draft_reply
|
||||
|
||||
|
||||
# --- human gate -------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_send_blocked_without_approval(cfg, sess):
|
||||
_proposal_with_sent_draft(sess)
|
||||
raw = _eml("Re: inquiry", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
|
||||
maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
|
||||
|
||||
sent = []
|
||||
config = cfg(CAYCE_OPS_MAIL_FROM="agent@cayce-co.xyz")
|
||||
stats = maild._send_approved(config, sess, sender=lambda *a, **k: sent.append(a))
|
||||
assert stats["sent"] == 0 and sent == []
|
||||
assert stats["blocked"] + stats["missing"] >= 0 # no crash paths
|
||||
assert config.mail_from == "agent@cayce-co.xyz"
|
||||
row = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||
assert row.draft_status is DraftStatus.DRAFTED
|
||||
|
||||
|
||||
def test_send_requires_explicit_human_approval(cfg, sess):
|
||||
_proposal_with_sent_draft(sess)
|
||||
raw = _eml(
|
||||
"Re: inquiry",
|
||||
"Approved, welcome to the program.",
|
||||
in_reply_to=OUTBOUND_MSGID,
|
||||
)
|
||||
maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
|
||||
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||
original_id = inbound.message_id
|
||||
|
||||
assert (
|
||||
maild._approve_draft(
|
||||
inbound.id, "operator", sess, signature=_approve_signed(sess, inbound.id)
|
||||
)
|
||||
== 0
|
||||
)
|
||||
|
||||
sent = []
|
||||
config = cfg(CAYCE_OPS_MAIL_FROM="agent@cayce-co.xyz")
|
||||
|
||||
def sender(cfg, to, subject, body, message_id, in_reply_to=None):
|
||||
sent.append((to, subject, body, message_id, in_reply_to))
|
||||
|
||||
stats = maild._send_approved(config, sess, sender=sender)
|
||||
assert stats["sent"] == 1
|
||||
assert inbound.draft_status is DraftStatus.SENT
|
||||
to, subject, body, message_id, in_reply_to = sent[0]
|
||||
assert to == "partners@acme.example.org"
|
||||
assert in_reply_to == original_id # In-Reply-To threads to the inbound mail
|
||||
|
||||
# a real OUTBOUND row now exists (audit fix: they were never created)
|
||||
out = (
|
||||
sess.query(MailMessage)
|
||||
.filter_by(direction=MailDirection.OUTBOUND, message_id=message_id)
|
||||
.one()
|
||||
)
|
||||
assert out.in_reply_to == original_id
|
||||
assert out.proposal_id == inbound.proposal_id
|
||||
assert inbound.message_id == original_id # inbound id NOT overwritten
|
||||
|
||||
row = (
|
||||
sess.query(AuditEntry)
|
||||
.filter_by(action="send_draft")
|
||||
.order_by(AuditEntry.id.desc())
|
||||
.first()
|
||||
)
|
||||
assert row is not None and "operator" in str(row.detail)
|
||||
|
||||
|
||||
def test_double_approve_is_rejected(cfg, sess):
|
||||
_proposal_with_sent_draft(sess)
|
||||
raw = _eml("Re: inquiry", "Approved, welcome.", in_reply_to=OUTBOUND_MSGID)
|
||||
maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
|
||||
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||
|
||||
assert (
|
||||
maild._approve_draft(
|
||||
inbound.id, "operator", sess, signature=_approve_signed(sess, inbound.id)
|
||||
)
|
||||
== 0
|
||||
)
|
||||
assert maild._approve_draft(inbound.id, "operator", sess) == 1
|
||||
|
||||
|
||||
# --- audit corrections ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_send_failure_does_not_resurrect_sent_mails(cfg, sess):
|
||||
"""Critical finding 3: SMTP failure mid-batch rolled back the whole
|
||||
transaction, re-sending already-delivered mails 5 minutes later."""
|
||||
config = cfg(CAYCE_OPS_MAIL_FROM="agent@cayce-co.xyz")
|
||||
for i in range(3):
|
||||
# unique first-contact thread per proposal (real compose -> send path)
|
||||
p = Proposal(kind="partnership", title=f"Acme partner {i}")
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
mail_id = maild._compose(
|
||||
p.id, f"desk{i}@acme.example.org", f"[cayce] inquiry {i}", "Hello!", sess
|
||||
)
|
||||
assert (
|
||||
maild._approve_draft(
|
||||
mail_id, "operator", sess, signature=_approve_signed(sess, mail_id)
|
||||
)
|
||||
== 0
|
||||
)
|
||||
assert maild._send_one(STUB_CFG, sess, mail_id, sender=lambda *a, **k: None) == "sent"
|
||||
sess.flush()
|
||||
out = sess.get(MailMessage, mail_id)
|
||||
raw = _eml(
|
||||
f"Re: {i}",
|
||||
"Approved, welcome to the program.",
|
||||
in_reply_to=out.message_id,
|
||||
msgid=f"<acme-{i}@acme.example.org>",
|
||||
)
|
||||
maild._poll(config, sess, fetcher=_fetcher([raw]))
|
||||
inbound = (
|
||||
sess.query(MailMessage)
|
||||
.filter_by(direction=MailDirection.INBOUND, message_id=f"<acme-{i}@acme.example.org>")
|
||||
.one()
|
||||
)
|
||||
assert (
|
||||
maild._approve_draft(
|
||||
inbound.id, "operator", sess, signature=_approve_signed(sess, inbound.id)
|
||||
)
|
||||
== 0
|
||||
)
|
||||
sess.commit() # per-message factory sessions only see committed rows
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def flaky_smtp(cfg, to, subject, body, message_id, in_reply_to=None):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 3:
|
||||
raise OSError("smtp connection lost")
|
||||
|
||||
import cayceops.db as appdb
|
||||
|
||||
stats = maild._send_approved(
|
||||
config, sess, sender=flaky_smtp, factory=appdb.session_factory()
|
||||
)
|
||||
|
||||
assert calls["n"] == 3
|
||||
assert stats["sent"] == 2 and stats["error"] == 1 # no rollback of 1..2
|
||||
still_approved = (
|
||||
sess.query(MailMessage)
|
||||
.filter_by(direction=MailDirection.INBOUND, draft_status=DraftStatus.APPROVED)
|
||||
.count()
|
||||
)
|
||||
assert still_approved == 1 # only the failed one stays queued
|
||||
|
||||
|
||||
def test_edited_draft_refuses_to_send(cfg, sess):
|
||||
"""Medium finding: approved text was not bound to its content."""
|
||||
_proposal_with_sent_draft(sess)
|
||||
raw = _eml("Re: inquiry", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
|
||||
maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
|
||||
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||
assert (
|
||||
maild._approve_draft(
|
||||
inbound.id, "operator", sess, signature=_approve_signed(sess, inbound.id)
|
||||
)
|
||||
== 0
|
||||
)
|
||||
|
||||
inbound.draft_reply = "TOTALLY EDITED TEXT after approval" # tamper
|
||||
|
||||
sent = []
|
||||
config = cfg(CAYCE_OPS_MAIL_FROM="agent@cayce-co.xyz")
|
||||
stats = maild._send_approved(config, sess, sender=lambda *a, **k: sent.append(a))
|
||||
assert stats["sent"] == 0 and stats["hash_mismatch"] == 1
|
||||
assert sent == []
|
||||
assert inbound.draft_status is DraftStatus.DRAFTED # needs fresh approval
|
||||
action = sess.query(AuditEntry).filter_by(action="send_refused").one()
|
||||
assert action.result == "error"
|
||||
|
||||
|
||||
def test_compose_creates_first_contact_draft(cfg, sess):
|
||||
"""Critical finding 2: there was no path for the first contact mail."""
|
||||
p = Proposal(kind="partnership", title="New hotel partner")
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
|
||||
mail_id = maild._compose(
|
||||
p.id, "desk@hotel.example.org", "Partnership", "We would like to...", sess
|
||||
)
|
||||
assert mail_id > 0
|
||||
out = sess.get(MailMessage, mail_id)
|
||||
assert out.direction is MailDirection.OUTBOUND
|
||||
assert out.draft_status is DraftStatus.DRAFTED
|
||||
assert out.proposal_id == p.id
|
||||
|
||||
# approve + send -> an inbound reply can now thread to this proposal
|
||||
assert (
|
||||
maild._approve_draft(mail_id, "operator", sess, signature=_approve_signed(sess, mail_id))
|
||||
== 0
|
||||
)
|
||||
stats = maild._send_approved(STUB_CFG, sess, sender=lambda *a, **k: None)
|
||||
assert stats["sent"] == 1
|
||||
sess.flush()
|
||||
|
||||
reply = _eml("Re: Partnership", "Approved, welcome!", in_reply_to=out.message_id)
|
||||
assert maild._match_proposal(maild.parse_raw(reply), sess) is p
|
||||
|
||||
|
||||
def test_mark_seen_returned_for_post_commit_processing(cfg, sess):
|
||||
"""Medium finding: \\\\Seen was set before processing (crash lost mail)."""
|
||||
raw = _eml("Re: hi", "Approved, welcome to the program.")
|
||||
stats, nums = maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
|
||||
assert stats["processed"] == 1
|
||||
assert nums == [b"0"] # caller marks seen only after commit
|
||||
|
||||
|
||||
# --- round-2 signature gate (mail) ------------------------------------------------
|
||||
|
||||
|
||||
def _approved_reply(sess, cfg) -> MailMessage:
|
||||
_proposal_with_sent_draft(sess)
|
||||
raw = _eml("Re: inquiry", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
|
||||
maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
|
||||
return sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||
|
||||
|
||||
def test_unsigned_sql_approved_draft_is_refused(cfg, sess):
|
||||
"""Round-2 required test: raw-SQL approved row (no signature) not sent."""
|
||||
_proposal_with_sent_draft(sess)
|
||||
raw = _eml("Re: inquiry", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
|
||||
maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
|
||||
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||
# raw-SQL style approval: status flips, no signature
|
||||
sess.execute(
|
||||
MailMessage.__table__.update()
|
||||
.where(MailMessage.id == inbound.id)
|
||||
.values(draft_status=DraftStatus.APPROVED)
|
||||
)
|
||||
sess.expire(inbound)
|
||||
|
||||
sent = []
|
||||
stats = maild._send_approved(STUB_CFG, sess, sender=lambda *a, **k: sent.append(a))
|
||||
assert stats["sent"] == 0 and stats["hash_mismatch"] == 1
|
||||
assert sent == []
|
||||
assert inbound.draft_status is DraftStatus.DRAFTED # reverted
|
||||
refused = sess.query(AuditEntry).filter_by(action="send_refused").one()
|
||||
assert refused.result == "error"
|
||||
|
||||
|
||||
def test_changed_to_addr_after_approval_blocks_send(cfg, sess):
|
||||
"""Round-2 required test: to_addr edited after approval -> refused."""
|
||||
_proposal_with_sent_draft(sess)
|
||||
raw = _eml("Re: inquiry", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
|
||||
maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
|
||||
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||
assert (
|
||||
maild._approve_draft(
|
||||
inbound.id, "operator", sess, signature=_approve_signed(sess, inbound.id)
|
||||
)
|
||||
== 0
|
||||
)
|
||||
|
||||
inbound.from_addr = "attacker@evil.example.org" # redirect target
|
||||
|
||||
sent = []
|
||||
stats = maild._send_approved(STUB_CFG, sess, sender=lambda *a, **k: sent.append(a))
|
||||
assert stats["sent"] == 0 and stats["hash_mismatch"] == 1
|
||||
assert sent == []
|
||||
assert inbound.draft_status is DraftStatus.DRAFTED
|
||||
|
||||
|
||||
def test_foreign_deployment_signature_refused(cfg, sess):
|
||||
"""Round-2 required test: a valid signature for another deployment_id (or
|
||||
other content) is refused."""
|
||||
from cayceops.signing import approval_message, mail_content_hash
|
||||
|
||||
_proposal_with_sent_draft(sess)
|
||||
raw = _eml("Re: inquiry", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
|
||||
maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
|
||||
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||
chash = mail_content_hash(inbound.from_addr, inbound.subject, inbound.draft_reply)
|
||||
|
||||
# signature valid for ANOTHER deployment
|
||||
other_dep = _sign(
|
||||
OPERATOR_SECRET, approval_message("mail", "other-deploy", inbound.id, chash)
|
||||
)
|
||||
assert maild._approve_draft(inbound.id, "operator", sess, signature=other_dep) == 2
|
||||
assert inbound.draft_status is DraftStatus.DRAFTED
|
||||
|
||||
# signature valid for OTHER CONTENT
|
||||
other_content = _sign(
|
||||
OPERATOR_SECRET, approval_message("mail", "itest", inbound.id, "00" * 32)
|
||||
)
|
||||
assert maild._approve_draft(inbound.id, "operator", sess, signature=other_content) == 2
|
||||
assert inbound.draft_status is DraftStatus.DRAFTED
|
||||
|
||||
|
||||
def test_send_failures_audited_and_exit_code(cfg, sess, monkeypatch):
|
||||
"""Round-2 point 5: failures audited + surfaced; non-zero exit handled by CLI."""
|
||||
import cayceops.db as appdb
|
||||
|
||||
_proposal_with_sent_draft(sess)
|
||||
raw = _eml("Re: inquiry", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
|
||||
maild._poll(cfg(), sess, fetcher=_fetcher([raw]))
|
||||
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||
assert (
|
||||
maild._approve_draft(
|
||||
inbound.id, "operator", sess, signature=_approve_signed(sess, inbound.id)
|
||||
)
|
||||
== 0
|
||||
)
|
||||
|
||||
def boom(cfg, to, subject, body, message_id, in_reply_to=None):
|
||||
raise OSError("relay down")
|
||||
|
||||
|
||||
sess.commit() # per-message factory sessions only see committed rows
|
||||
stats = maild._send_approved(
|
||||
STUB_CFG, sess, sender=boom, factory=appdb.session_factory()
|
||||
)
|
||||
assert stats["error"] == 1 and stats["sent"] == 0
|
||||
failed = sess.query(AuditEntry).filter_by(action="send_failed").one()
|
||||
assert failed.result == "error"
|
||||
+56
-4
@@ -37,7 +37,7 @@ def test_proposal_status_transitions(sess):
|
||||
sess.flush()
|
||||
|
||||
p.status = ProposalStatus.APPROVED
|
||||
p.approved_by = "gustavo"
|
||||
p.approved_by = "operator"
|
||||
p.approved_at = datetime.now(UTC)
|
||||
p.status = ProposalStatus.RUNNING
|
||||
p.status = ProposalStatus.DONE
|
||||
@@ -45,7 +45,7 @@ def test_proposal_status_transitions(sess):
|
||||
|
||||
loaded = sess.scalar(select(Proposal).where(Proposal.id == p.id))
|
||||
assert loaded.status == ProposalStatus.DONE
|
||||
assert loaded.approved_by == "gustavo"
|
||||
assert loaded.approved_by == "operator"
|
||||
|
||||
|
||||
def test_dedupe_key_unique(sess):
|
||||
@@ -65,12 +65,12 @@ def test_audit_helper_writes(sess):
|
||||
p = Proposal(kind="other", title="x")
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
audit(sess, actor="gustavo", action="approve", proposal_id=p.id, detail={"note": "ok"})
|
||||
audit(sess, actor="operator", action="approve", proposal_id=p.id, detail={"note": "ok"})
|
||||
sess.flush()
|
||||
|
||||
rows = list(sess.scalars(select(AuditEntry)).all())
|
||||
assert len(rows) == 1
|
||||
assert rows[0].actor == "gustavo"
|
||||
assert rows[0].actor == "operator"
|
||||
assert rows[0].proposal_id == p.id
|
||||
|
||||
|
||||
@@ -90,3 +90,55 @@ def test_mail_and_content_and_runs(sess):
|
||||
assert m.id and c.id and r.id
|
||||
assert m.draft_status == "drafted"
|
||||
assert c.status == "draft"
|
||||
|
||||
|
||||
def test_audit_chain_and_verification(sess):
|
||||
from cayceops.models import verify_audit_chain
|
||||
|
||||
p = Proposal(kind="other", title="chain")
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
for i in range(3):
|
||||
audit(sess, actor=f"actor-{i}", action="step", proposal_id=p.id, detail={"i": i})
|
||||
sess.flush()
|
||||
|
||||
ok, broken = verify_audit_chain(sess)
|
||||
assert ok and broken is None
|
||||
|
||||
# tamper with one entry -> chain breaks at that id
|
||||
first = sess.query(AuditEntry).order_by(AuditEntry.id).first()
|
||||
first.actor = "tampered"
|
||||
ok, broken = verify_audit_chain(sess)
|
||||
assert not ok and broken == first.id
|
||||
|
||||
|
||||
def test_concurrent_writers_keep_chain_verifiable(db_engine):
|
||||
"""Round-2 point 4: serialized writes -> no forked chains."""
|
||||
import threading
|
||||
|
||||
import cayceops.db as appdb
|
||||
|
||||
errors: list[Exception] = []
|
||||
|
||||
def writer(worker: int, n: int):
|
||||
try:
|
||||
for i in range(n):
|
||||
with appdb.session_scope() as s:
|
||||
audit(s, actor=f"w{worker}", action=f"step{i}", detail={"worker": worker})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
errors.append(exc)
|
||||
|
||||
threads = [threading.Thread(target=writer, args=(w, 25)) for w in range(4)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert not errors, errors
|
||||
s = appdb.session_factory()()
|
||||
total = s.query(AuditEntry).count()
|
||||
assert total == 100
|
||||
from cayceops.models import verify_audit_chain
|
||||
|
||||
ok, broken = verify_audit_chain(s)
|
||||
assert ok and broken is None
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""monitor tests — page renders, no sensitive data, escapes input."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from cayceops.components.monitor import monitor
|
||||
from cayceops.models import ContentItem, DaemonRun, MailMessage, Proposal
|
||||
|
||||
|
||||
def _seed(sess):
|
||||
sess.add(DaemonRun(daemon="scanner", status="ok", detail={"proposed": 3}))
|
||||
sess.add(Proposal(kind="affiliate", title="Title with <script>alert(1)</script>"))
|
||||
sess.add(
|
||||
MailMessage(
|
||||
direction="in",
|
||||
from_addr="secret-person@private.example.org",
|
||||
subject="private subject",
|
||||
classification="approval",
|
||||
draft_status="drafted",
|
||||
)
|
||||
)
|
||||
sess.add(ContentItem(theme="atm-fees", channel="nostr", caption="caption", status="draft"))
|
||||
sess.flush()
|
||||
|
||||
|
||||
def test_build_renders_all_sections(cfg, sess, tmp_path):
|
||||
_seed(sess)
|
||||
out = tmp_path / "status.html"
|
||||
monitor._build(cfg(CAYCE_OPS_MONITOR_OUTPUT=str(out)), sess)
|
||||
page = out.read_text()
|
||||
|
||||
assert "daemon health" in page
|
||||
assert "proposal queue" in page
|
||||
assert "recent audit activity" in page
|
||||
assert "scanner" in page
|
||||
assert "proposed: <strong>1</strong>" in page
|
||||
assert "mail drafts awaiting approval: <strong>1</strong>" in page
|
||||
|
||||
|
||||
def test_build_leaks_no_sensitive_data(cfg, sess, tmp_path):
|
||||
_seed(sess)
|
||||
out = tmp_path / "status.html"
|
||||
monitor._build(cfg(CAYCE_OPS_MONITOR_OUTPUT=str(out)), sess)
|
||||
page = out.read_text()
|
||||
|
||||
assert "secret-person@private.example.org" not in page # no addresses
|
||||
assert "private subject" not in page # no subjects
|
||||
assert "caption" not in page # no content bodies
|
||||
assert "Title with" not in page # proposal titles not rendered
|
||||
|
||||
|
||||
def test_build_escapes_injected_content(cfg, sess, tmp_path):
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from cayceops.models import AuditEntry
|
||||
|
||||
sess.add(
|
||||
AuditEntry(
|
||||
ts=datetime.now(UTC),
|
||||
actor="<script>x</script>",
|
||||
action="probe",
|
||||
detail={},
|
||||
)
|
||||
)
|
||||
sess.flush()
|
||||
out = tmp_path / "status.html"
|
||||
monitor._build(cfg(CAYCE_OPS_MONITOR_OUTPUT=str(out)), sess)
|
||||
page = out.read_text()
|
||||
assert "<script>x</script>" 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
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Tests for D16: the operator key may never be an agent identity.
|
||||
|
||||
Background: the operator key was, at some point, Cayce's nostr key. The
|
||||
daemon must refuse to operate in that configuration, because "human
|
||||
approval" signed by the agent is not human approval.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from cayceops.signing import (
|
||||
SelfApprovalError,
|
||||
pubkey_from_private_hex,
|
||||
validate_no_self_approval,
|
||||
)
|
||||
|
||||
# keys invented for tests only (allowlisted in .gitleaks.toml)
|
||||
CAYCE_SECRET = "11" * 32 # simulates Cayce nostr identity
|
||||
CAYCE_PUBKEY = pubkey_from_private_hex(CAYCE_SECRET)
|
||||
HUMAN_SECRET = "33" * 32 # a proper human operator key
|
||||
HUMAN_PUBKEY = pubkey_from_private_hex(HUMAN_SECRET)
|
||||
|
||||
|
||||
def _cfg(monkeypatch, operator_pubkey: str, nostr_private_key: str = CAYCE_SECRET):
|
||||
from cayceops.config import Config
|
||||
|
||||
monkeypatch.setenv("CAYCE_OPS_DEPLOYMENT_ID", "itest")
|
||||
monkeypatch.setenv("CAYCE_OPS_OPERATOR_PUBKEY", operator_pubkey)
|
||||
monkeypatch.setenv("CAYCE_OPS_NOSTR_PRIVATE_KEY", nostr_private_key)
|
||||
return Config()
|
||||
|
||||
|
||||
def test_agent_pubkey_as_operator_is_refused(monkeypatch):
|
||||
"""The exact bug: operator pubkey = Cayce pubkey must fail."""
|
||||
cfg = _cfg(monkeypatch, operator_pubkey=CAYCE_PUBKEY)
|
||||
with pytest.raises(SelfApprovalError):
|
||||
validate_no_self_approval(cfg)
|
||||
|
||||
|
||||
def test_human_pubkey_passes(monkeypatch):
|
||||
cfg = _cfg(monkeypatch, operator_pubkey=HUMAN_PUBKEY)
|
||||
validate_no_self_approval(cfg) # does not raise
|
||||
|
||||
|
||||
def test_empty_operator_pubkey_passes_validation(monkeypatch):
|
||||
"""Empty is the 'not configured' state — rejected elsewhere, not here."""
|
||||
cfg = _cfg(monkeypatch, operator_pubkey="")
|
||||
validate_no_self_approval(cfg)
|
||||
|
||||
|
||||
def test_explicit_denylist_is_honoured(monkeypatch):
|
||||
"""A key held outside this process can be listed explicitly."""
|
||||
outside_pubkey = pubkey_from_private_hex("44" * 32)
|
||||
monkeypatch.setenv(
|
||||
"CAYCE_OPS_FORBIDDEN_OPERATOR_PUBKEYS", f"{outside_pubkey}, {HUMAN_PUBKEY}"
|
||||
)
|
||||
cfg = _cfg(monkeypatch, operator_pubkey=HUMAN_PUBKEY, nostr_private_key="")
|
||||
with pytest.raises(SelfApprovalError):
|
||||
validate_no_self_approval(cfg)
|
||||
|
||||
|
||||
def test_daemon_refuses_to_start_with_agent_key(monkeypatch, sess, db_engine):
|
||||
"""End-to-end: executor.run must refuse (exit 2) when the operator
|
||||
pubkey is Cayce's identity."""
|
||||
from cayceops.components.executor import executor
|
||||
from cayceops.models import DaemonRun
|
||||
|
||||
cfg = _cfg(monkeypatch, operator_pubkey=CAYCE_PUBKEY)
|
||||
code = executor.run(cfg, sess)
|
||||
assert code == 2
|
||||
run_row = sess.query(DaemonRun).filter_by(daemon="executor").one()
|
||||
assert run_row.status == "error"
|
||||
assert "agent identity" in run_row.detail["refused"]
|
||||
|
||||
|
||||
def test_pubkey_derivation_roundtrip():
|
||||
assert pubkey_from_private_hex(HUMAN_SECRET) == HUMAN_PUBKEY
|
||||
with pytest.raises(ValueError):
|
||||
pubkey_from_private_hex("")
|
||||
with pytest.raises(ValueError):
|
||||
pubkey_from_private_hex("nothex")
|
||||
@@ -0,0 +1,263 @@
|
||||
"""publisher tests — calendar, human gate (signed), nostr publish, gates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from cayceops.components.publisher import adapters, nostr, publisher
|
||||
from cayceops.models import AuditEntry, ContentItem, ContentStatus
|
||||
|
||||
# invented test keys (never real — allowlisted in .gitleaks.toml)
|
||||
TEST_KEY = "11" * 32 # nostr signing key
|
||||
OPERATOR_SECRET = bytes.fromhex("22" * 32) # approval signing key
|
||||
|
||||
|
||||
def _sign(msg: bytes, secret: bytes = OPERATOR_SECRET) -> str:
|
||||
from coincurve import PrivateKey
|
||||
|
||||
return PrivateKey(secret).sign_schnorr(msg, aux_randomness=b"\x00" * 32).hex()
|
||||
|
||||
|
||||
def _sign_content(item: ContentItem, deployment: str = "itest") -> str:
|
||||
from cayceops.signing import approval_message, content_content_hash
|
||||
|
||||
chash = content_content_hash(item.channel, item.caption, item.link)
|
||||
return _sign(approval_message("content", deployment, item.id, chash))
|
||||
|
||||
|
||||
def _item(channel="nostr", theme="atm-fees", scheduled=None, status=ContentStatus.APPROVED):
|
||||
return ContentItem(
|
||||
theme=theme,
|
||||
channel=channel,
|
||||
caption="The first ATM you find charges 10-15% over spot.\n\nbitcoinhotels.org/guides/atm",
|
||||
link="bitcoinhotels.org/guides/atm",
|
||||
status=status,
|
||||
scheduled_for=scheduled,
|
||||
)
|
||||
|
||||
|
||||
def _approved_item(cfg, sess, **kw) -> ContentItem:
|
||||
"""A content item properly approved with a signature (env set first)."""
|
||||
cfg() # sets the signing env before any approval
|
||||
item = _item(status=ContentStatus.DRAFT, **kw)
|
||||
sess.add(item)
|
||||
sess.flush()
|
||||
assert publisher._approve(item.id, "operator", sess, signature=_sign_content(item)) == 0
|
||||
return item
|
||||
|
||||
|
||||
# --- nostr client -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_event_build_verify_roundtrip():
|
||||
event = nostr.build_event(
|
||||
bytes.fromhex(TEST_KEY), "gm", tags=[["t", "bitcoin"]], created_at=1700000000
|
||||
)
|
||||
assert nostr.verify_event(event)
|
||||
assert len(event["id"]) == 64 and len(event["sig"]) == 128
|
||||
|
||||
tampered = {**event, "content": "gm (edited)"}
|
||||
assert not nostr.verify_event(tampered)
|
||||
|
||||
|
||||
def test_event_is_deterministic():
|
||||
a = nostr.build_event(bytes.fromhex(TEST_KEY), "gm", created_at=1700000000)
|
||||
b = nostr.build_event(bytes.fromhex(TEST_KEY), "gm", created_at=1700000000)
|
||||
assert a == b
|
||||
|
||||
|
||||
def test_key_validation():
|
||||
with pytest.raises(ValueError):
|
||||
nostr.normalize_key("not-a-key")
|
||||
assert nostr.normalize_key("0x" + TEST_KEY) == bytes.fromhex(TEST_KEY)
|
||||
|
||||
|
||||
# --- calendar ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_plan_creates_idempotent_drafts(cfg, sess):
|
||||
config = cfg()
|
||||
first = publisher._plan(config, sess)
|
||||
second = publisher._plan(config, sess)
|
||||
assert first["created"] == 4
|
||||
assert second["created"] == 0 # same themes+channels already queued
|
||||
|
||||
|
||||
def test_approve_gate(cfg, sess):
|
||||
item = _item(status=ContentStatus.DRAFT)
|
||||
sess.add(item)
|
||||
sess.flush()
|
||||
cfg() # signing env first
|
||||
|
||||
# unsigned approval refused (mandatory signature, no degraded mode)
|
||||
assert publisher._approve(item.id, "operator", sess, signature=None) == 2
|
||||
# signed approval accepted, FULL signature stored
|
||||
sig = _sign_content(item)
|
||||
assert publisher._approve(item.id, "operator", sess, signature=sig) == 0
|
||||
assert item.status is ContentStatus.APPROVED
|
||||
assert item.operator_signature == sig and len(sig) == 128
|
||||
assert publisher._approve(item.id, "operator", sess, signature=sig) == 1 # not draft anymore
|
||||
|
||||
actor = sess.query(AuditEntry).filter_by(action="approve_content").first().actor
|
||||
assert actor == "operator"
|
||||
|
||||
|
||||
# --- publishing ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_publish_due_nostr_happy_path(cfg, sess):
|
||||
item = _approved_item(cfg, sess)
|
||||
|
||||
relays_seen = []
|
||||
|
||||
def fake_sender(relays, event, timeout=10.0):
|
||||
relays_seen.append(relays)
|
||||
assert nostr.verify_event(event)
|
||||
return [{"relay": r, "ok": "true"} for r in relays]
|
||||
|
||||
config = cfg(
|
||||
CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY,
|
||||
CAYCE_OPS_NOSTR_RELAYS="wss://relay.example.org,wss://other.example.org",
|
||||
)
|
||||
stats = publisher._publish_due(config, sess, nostr_sender=fake_sender)
|
||||
|
||||
assert stats["published"] == 1
|
||||
assert item.status is ContentStatus.PUBLISHED
|
||||
assert item.post_ref and len(item.post_ref) == 64
|
||||
assert item.published_at is not None
|
||||
assert "wss://relay.example.org" in relays_seen[0]
|
||||
|
||||
|
||||
def test_future_scheduled_item_not_published(cfg, sess):
|
||||
item = _approved_item(
|
||||
cfg, sess, scheduled=datetime.now(UTC) + timedelta(hours=2)
|
||||
)
|
||||
config = cfg(
|
||||
CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY, CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org"
|
||||
)
|
||||
stats = publisher._publish_due(
|
||||
config, sess, nostr_sender=lambda r, e, timeout=10: []
|
||||
)
|
||||
assert stats["published"] == 0
|
||||
assert item.status is ContentStatus.APPROVED
|
||||
|
||||
|
||||
def test_unsigned_sql_approved_item_not_published(cfg, sess):
|
||||
"""Round-2 required test: raw-SQL approved row (no signature) not published."""
|
||||
config = cfg(
|
||||
CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY, CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org"
|
||||
)
|
||||
item = _item() # status=APPROVED, no signature
|
||||
sess.add(item)
|
||||
sess.flush()
|
||||
stats = publisher._publish_due(config, sess, nostr_sender=lambda r, e, timeout=10: [])
|
||||
assert stats["published"] == 0 and stats["tampered"] == 1
|
||||
assert item.status is ContentStatus.DRAFT # reverted
|
||||
refused = sess.query(AuditEntry).filter_by(action="publish_refused").one()
|
||||
assert refused.result == "error"
|
||||
|
||||
|
||||
def test_tampered_caption_refused_by_signature(cfg, sess):
|
||||
"""Round-2 required test: signature over other content refused at publish."""
|
||||
item = _approved_item(cfg, sess)
|
||||
|
||||
item.caption = item.caption + " EDITED" # content no longer matches sig
|
||||
nostr_config = cfg(
|
||||
CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY, CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org"
|
||||
)
|
||||
stats = publisher._publish_due(
|
||||
nostr_config, sess, nostr_sender=lambda r, e, timeout=10: []
|
||||
)
|
||||
assert stats["published"] == 0 and stats["tampered"] == 1
|
||||
assert item.status is ContentStatus.DRAFT
|
||||
|
||||
|
||||
def test_foreign_deployment_signature_refused(cfg, sess, signer):
|
||||
"""Round-2 required test: a valid signature for another deployment_id is
|
||||
refused at approval time."""
|
||||
item = _item(status=ContentStatus.DRAFT)
|
||||
sess.add(item)
|
||||
sess.flush()
|
||||
other_dep = signer.raw(
|
||||
signer.msg("content", "other-deploy", item.id, signer.content_hash(item))
|
||||
)
|
||||
assert publisher._approve(item.id, "operator", sess, signature=other_dep) == 2
|
||||
assert item.status is ContentStatus.DRAFT
|
||||
|
||||
|
||||
def test_nostr_without_config_fails_clean(cfg, sess):
|
||||
"""Signed item but no nostr credentials -> clean failure, item stays."""
|
||||
item = _approved_item(cfg, sess)
|
||||
config = cfg() # no key, no relays
|
||||
stats = publisher._publish_due(config, sess)
|
||||
assert stats["failed"] == 1
|
||||
assert item.status is ContentStatus.APPROVED # stays approved, error recorded
|
||||
assert item.error and "nostr" in item.error
|
||||
|
||||
|
||||
def test_non_nostr_channels_skipped_terminal(cfg, sess):
|
||||
item = _approved_item(cfg, sess, channel="facebook", theme="lightning-abroad")
|
||||
stats = publisher._publish_due(cfg(), sess)
|
||||
assert stats["skipped"] == 1
|
||||
assert item.status is ContentStatus.SKIPPED # terminal (audit fix)
|
||||
|
||||
stats2 = publisher._publish_due(cfg(), sess)
|
||||
assert stats2["skipped"] == 0 # never revisited
|
||||
skips = sess.query(AuditEntry).filter_by(action="publish_skipped_terminal").count()
|
||||
assert skips == 1
|
||||
|
||||
|
||||
def test_meta_adapters_disabled_by_default(cfg):
|
||||
config = cfg()
|
||||
with pytest.raises(PermissionError):
|
||||
adapters.post_facebook(config, "hello")
|
||||
with pytest.raises(PermissionError):
|
||||
adapters.post_instagram(config, "hello", "https://img.example.org/x.jpg")
|
||||
|
||||
|
||||
# --- images ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_brand_image_render(tmp_path, cfg):
|
||||
config = cfg(CAYCE_OPS_MEDIA_DIR=str(tmp_path))
|
||||
out = publisher._render_image(_item(), config)
|
||||
assert os.path.exists(out)
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(out) as img:
|
||||
assert img.size == (1080, 1350)
|
||||
|
||||
|
||||
def test_unknown_brand_rejected():
|
||||
from cayceops.components.publisher import images
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
images.render_brand_image(brand="xx", kicker=None, lines=["A"], out_path="/tmp/x.jpg")
|
||||
|
||||
|
||||
# --- round-2: attempts budget --------------------------------------------------
|
||||
|
||||
|
||||
def test_publish_attempts_bounded(cfg, sess, monkeypatch):
|
||||
"""Medium finding: nostr failures retried without limit."""
|
||||
from cayceops.components.publisher import nostr as nostr_mod
|
||||
|
||||
config = cfg(
|
||||
CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY,
|
||||
CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org",
|
||||
CAYCE_OPS_MAX_ATTEMPTS="1",
|
||||
)
|
||||
item = _approved_item(cfg, sess)
|
||||
|
||||
def always_fail(relays, event, timeout=10.0):
|
||||
raise RuntimeError("relay down")
|
||||
|
||||
monkeypatch.setattr(nostr_mod, "publish", always_fail)
|
||||
publisher._publish_due(config, sess, nostr_sender=always_fail)
|
||||
assert item.attempts == 1
|
||||
assert item.status is ContentStatus.SKIPPED # budget exhausted -> terminal
|
||||
|
||||
|
||||
import os # noqa: E402 (used by image test)
|
||||
@@ -58,7 +58,7 @@ def test_scan_files_proposals_with_kind_and_score(cfg, sess):
|
||||
affiliate = by_title["Acme launches affiliate program for hotels"]
|
||||
assert affiliate.kind == "affiliate"
|
||||
assert affiliate.status == ProposalStatus.PROPOSED
|
||||
assert affiliate.risk_score > 0
|
||||
assert affiliate.match_score > 0
|
||||
assert affiliate.dedupe_key
|
||||
|
||||
partner = by_title["Become a payment partner"]
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Spanish negation tests (round-3 point 4): merchants write in Spanish;
|
||||
negations like 'nunca', 'lamentablemente' or 'no podemos' must veto
|
||||
`approval` exactly like their English counterparts. Veto rule unchanged:
|
||||
when in doubt -> needs_review."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from cayceops.components.maild import classify as clf
|
||||
from cayceops.models import MailClassification
|
||||
|
||||
APPROVAL_ONLY = MailClassification.APPROVAL
|
||||
|
||||
|
||||
def _never_approval(cfg_factory, text: str) -> None:
|
||||
kind, _ = clf.classify(text, cfg_factory())
|
||||
assert kind is not APPROVAL_ONLY, f"{text!r} classified as APPROVAL"
|
||||
|
||||
|
||||
def test_english_baseline_still_works(cfg):
|
||||
config = cfg()
|
||||
kind, _ = clf.classify("Your application was approved, welcome to the program", config)
|
||||
assert kind is APPROVAL_ONLY
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text",
|
||||
[
|
||||
# 1. rejection stem
|
||||
"Lamentamos informarle que su solicitud fue rechazada",
|
||||
# 2. nunca
|
||||
"Nunca aceptamos negocios de criptomonedas",
|
||||
# 3. jamás
|
||||
"Jamás hemos trabajado con affiliates externos",
|
||||
# 4. tampoco
|
||||
"No podemos aceptar su sitio, tampoco podemos ofrecerle otro acuerdo",
|
||||
# 5. ningún/ninguna
|
||||
"En este momento no tenemos ningún hueco para nuevos partners",
|
||||
# 6. no podemos (multi-word)
|
||||
"Su propuesta es interesante pero no podemos proceder",
|
||||
# 7. no es posible (multi-word)
|
||||
"Gracias por escribir, pero no es posible colaborar este trimestre",
|
||||
# 8. lamentablemente
|
||||
"Lamentablemente su aplicación no ha sido aprobada",
|
||||
# 9. no aceptamos
|
||||
"Por política interna no aceptamos sitios de viajes",
|
||||
# 10. mixed spanish/english: negation in spanish, approval word in english
|
||||
"Thanks for reaching out! Lamentablemente we cannot approve your site",
|
||||
# 11. mixed: approval in spanish, negation in english
|
||||
"Apreciamos su interés, but we are not accepting new partners",
|
||||
# 12. nadie / nada
|
||||
"Aquí nadie decidiría eso, así que no hay nada que aprobar",
|
||||
],
|
||||
)
|
||||
def test_spanish_and_mixed_negations_veto_approval(cfg, text):
|
||||
_never_approval(cfg, text)
|
||||
|
||||
|
||||
def test_spanish_pure_approval_still_approves(cfg):
|
||||
"""A clean Spanish approval WITHOUT any negation must still classify
|
||||
approval — the veto must not over-fire."""
|
||||
config = cfg()
|
||||
kind, confidence = clf.classify(
|
||||
"Su solicitud fue aprobada. Bienvenido al programa, estamos encantados "
|
||||
"de trabajar con ustedes.",
|
||||
config,
|
||||
)
|
||||
assert kind is APPROVAL_ONLY
|
||||
assert confidence >= 0.5
|
||||
|
||||
|
||||
def test_spanish_rejection_classifies_rejection(cfg):
|
||||
config = cfg()
|
||||
kind, conf = clf.classify(
|
||||
"Lamentamos informarle que su solicitud fue rechazada por el comité",
|
||||
config,
|
||||
)
|
||||
assert kind is MailClassification.REJECTION
|
||||
assert conf >= 0.9
|
||||
Reference in New Issue
Block a user