Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
73ccdf7bdf | ||
|
|
00fa3777ab | ||
|
|
34a5f331ac |
@@ -0,0 +1,56 @@
|
||||
# Cayce Ops — copy to .env and fill in. EVERY value below is a placeholder.
|
||||
# NEVER commit real credentials. .env is gitignored; gitleaks guards history.
|
||||
|
||||
# --- core ---
|
||||
CAYCE_OPS_DATABASE_URL=postgresql+psycopg://user:password@localhost:5432/cayce_ops
|
||||
CAYCE_OPS_AUTONOMOUS_MODE=false
|
||||
CAYCE_OPS_LOG_LEVEL=INFO
|
||||
CAYCE_OPS_STATE_DIR=./
|
||||
CAYCE_OPS_MEDIA_DIR=./media
|
||||
|
||||
# --- scanner ---
|
||||
# JSON array of sources: [{"type":"rss","url":"https://example.org/feed.xml"},
|
||||
# {"type":"page","url":"https://example.org/partners"}]
|
||||
CAYCE_OPS_SCANNER_SOURCES=[]
|
||||
CAYCE_OPS_SCANNER_KEYWORDS=affiliate,partnership,bitcoin,lightning,travel
|
||||
CAYCE_OPS_SCANNER_USER_AGENT=cayce-ops-scanner/0.1
|
||||
|
||||
# --- executor ---
|
||||
# with AUTONOMOUS_MODE=true: which proposal kinds may be auto-approved, daily cap
|
||||
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
|
||||
CAYCE_OPS_IMAP_PORT=993
|
||||
CAYCE_OPS_IMAP_USER=agent@example.org
|
||||
CAYCE_OPS_IMAP_PASSWORD=change-me
|
||||
CAYCE_OPS_IMAP_MAILBOX=INBOX
|
||||
CAYCE_OPS_SMTP_HOST=smtp.example.org
|
||||
CAYCE_OPS_SMTP_PORT=587
|
||||
CAYCE_OPS_SMTP_USER=agent@example.org
|
||||
CAYCE_OPS_SMTP_PASSWORD=change-me
|
||||
CAYCE_OPS_MAIL_FROM=agent@example.org
|
||||
|
||||
# --- llm (null = no LLM; components fall back to rules) ---
|
||||
CAYCE_OPS_LLM_PROVIDER=null
|
||||
CAYCE_OPS_LLM_MODEL=
|
||||
CAYCE_OPS_LLM_BASE_URL=https://api.openai.com/v1
|
||||
CAYCE_OPS_LLM_API_KEY=
|
||||
CAYCE_OPS_LLM_REVIEW_THRESHOLD=0.7
|
||||
|
||||
# --- publisher / nostr (key: 32-byte hex; relays: wss:// comma list) ---
|
||||
CAYCE_OPS_NOSTR_PRIVATE_KEY=
|
||||
CAYCE_OPS_NOSTR_RELAYS=wss://relay.example.org,wss://other.relay.example.org
|
||||
CAYCE_OPS_ENABLE_FACEBOOK=false
|
||||
CAYCE_OPS_FACEBOOK_PAGE_TOKEN=
|
||||
CAYCE_OPS_ENABLE_INSTAGRAM=false
|
||||
CAYCE_OPS_INSTAGRAM_USER_ID=
|
||||
|
||||
# --- monitor ---
|
||||
CAYCE_OPS_MONITOR_OUTPUT=./status.html
|
||||
@@ -0,0 +1,63 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
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:
|
||||
fetch-depth: 0 # gitleaks needs full history
|
||||
|
||||
- uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install (locked)
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Lint (ruff)
|
||||
run: uv run ruff check .
|
||||
|
||||
- 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: 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: |
|
||||
curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz | tar -xz gitleaks
|
||||
./gitleaks detect --source . --log-opts="--all" -v
|
||||
@@ -0,0 +1,63 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
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:
|
||||
fetch-depth: 0 # gitleaks needs full history
|
||||
|
||||
- uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install (locked)
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Lint (ruff)
|
||||
run: uv run ruff check .
|
||||
|
||||
- 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: 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: |
|
||||
curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz | tar -xz gitleaks
|
||||
./gitleaks detect --source . --log-opts="--all" -v
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# --- secrets / env ---
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# --- python ---
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
dist/
|
||||
|
||||
# --- runtime artifacts ---
|
||||
*.sqlite3
|
||||
*.db
|
||||
status.html
|
||||
media/
|
||||
node_modules/
|
||||
uv.lock.bak
|
||||
|
||||
# --- OS ---
|
||||
.DS_Store
|
||||
@@ -0,0 +1,8 @@
|
||||
# gitleaks configuration
|
||||
# tests/ contains invented fixtures (fake Nostr key, fake merchant mail) — allowlisted
|
||||
# so the full-history scan stays signal-only. See docs/DECISIONS.md D7.
|
||||
|
||||
[allowlist]
|
||||
paths = [
|
||||
'''(^|/)tests/''',
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -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.
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# uv for fast locked installs
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen --no-dev
|
||||
|
||||
COPY cayceops/ cayceops/
|
||||
COPY alembic/ alembic/
|
||||
COPY alembic.ini .
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
ENTRYPOINT ["cayceops"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 cayce-ops contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,25 @@
|
||||
.PHONY: check lint test migrate-check leaks sync hooks
|
||||
|
||||
sync: ## install locked deps (creates .venv)
|
||||
uv sync --frozen
|
||||
|
||||
lint: ## ruff
|
||||
uv run ruff check .
|
||||
|
||||
test: ## pytest
|
||||
uv run pytest
|
||||
|
||||
migrate-check: ## smoke the alembic chain on a scratch sqlite db
|
||||
rm -f /tmp/cayce-ops-migrate.db
|
||||
CAYCE_OPS_DATABASE_URL=sqlite:////tmp/cayce-ops-migrate.db uv run alembic upgrade head
|
||||
@rm -f /tmp/cayce-ops-migrate.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,75 +1,75 @@
|
||||
# Cayce — OpenSats Grant Proposal
|
||||
# Cayce Ops
|
||||
|
||||
> Autonomous entity. Próspera ZEDE. My agents dredge the noise; I bring you the signal.
|
||||
> I run [hardcap.news](https://hardcap.news) and [bitcoinhotels.org](https://bitcoinhotels.org) — brutally honest about all of it.
|
||||
> [cayce-co.xyz](https://cayce-co.xyz)
|
||||
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).
|
||||
|
||||
## Who / What is Cayce
|
||||
Five decoupled components share one Postgres database. Each is a CLI command,
|
||||
driven by cron/systemd timers. Nothing runs without an audit trail.
|
||||
|
||||
Cayce is an **autonomous software entity** — not a person, not a company. It operates
|
||||
a small fleet of daemons on its own infrastructure, makes its own decisions within a
|
||||
public mandate, and maintains two open, Bitcoin-native web properties:
|
||||
|
||||
| Property | What it does | Status |
|
||||
| Component | What it does | Human gate |
|
||||
|---|---|---|
|
||||
| **bitcoinhotels.org** | Bitcoin-native travel directory: hotels, destinations, field-tested guides (paying with Lightning abroad, ATM networks by city, living on sats in El Salvador) | Live, self-hosted |
|
||||
| **hardcap.news** | Bitcoin security intelligence: the Travel Security Kit, wrench-attack dataset analysis, border opsec | Live, self-hosted |
|
||||
| **scanner** | Discovers opportunities (affiliates, partnerships, content gaps) from configured sources; files them as `proposals` | none — it only proposes |
|
||||
| **executor** | Runs `approved` proposals through registered action plugins; full audit of who approved, what ran, what happened | approval required (`AUTONOMOUS_MODE=false` by default) |
|
||||
| **mail-negotiator** | Classifies merchant replies (approval / rejection / question / other) over IMAP; drafts replies; SMTP sends **only human-approved drafts** | draft approval required |
|
||||
| **publisher** | Themed content calendar. Nostr (NIP-01) adapter first; Facebook/Instagram adapters exist but are **disabled by default**; brand images via Pillow templates | publish requires approved items |
|
||||
| **monitor** | Static status page: daemon health, last runs, proposal queue, recent errors. No sensitive data | none — read-only |
|
||||
|
||||
Cayce also maintains its own operational stack: a Postgres-backed opportunity scanner,
|
||||
an initiative executor, an email daemon that negotiates with merchants autonomously,
|
||||
and a social media presence it runs end-to-end (Facebook + Instagram + Nostr).
|
||||
State machines:
|
||||
|
||||
## What the grant would fund
|
||||
```
|
||||
proposal: proposed -> approved -> running -> done | failed
|
||||
mail: inbound classified; draft: none -> drafted -> approved -> sent
|
||||
content: draft -> approved -> published
|
||||
```
|
||||
|
||||
### 1. Open-sourcing the autonomous operations stack
|
||||
The daemons that make Cayce autonomous — the scanner, the executor, the mail negotiator,
|
||||
the social publisher — are written, tested, and running in production today. The grant
|
||||
funds cleaning, documenting, and publishing them as reusable open-source components:
|
||||
## Quickstart (local)
|
||||
|
||||
- **scanner** — discovers opportunities (affiliates, partnerships, content gaps) and files proposals to a database
|
||||
- **executor** — executes approved initiatives without human intervention
|
||||
- **mail-negotiator** — IMAP/SMTP daemon that parses merchant approvals/rejections and acts on them
|
||||
- **publisher** — themed content calendar engine (FB/IG/Nostr) with brand image generation
|
||||
- **monitor** — single-pane status page for the whole organism
|
||||
```bash
|
||||
uv sync --frozen # installs Python 3.12 + locked deps
|
||||
cp .env.example .env # fill in; values are placeholders
|
||||
docker compose up -d db # Postgres 16
|
||||
uv run alembic upgrade head
|
||||
uv run cayceops scanner run
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
### 2. The Bitcoin Travel Data Commons
|
||||
Every guide on bitcoinhotels.org is built from field-collected data: ATM operators and
|
||||
their fee structures by city, no-KYC caps, Lightning acceptance rates, border policies.
|
||||
The grant funds turning this into an **open, structured dataset** any Bitcoin traveler
|
||||
(or any other project) can consume — CC-licensed, machine-readable, community-updatable.
|
||||
`docker compose up` (everything) runs all five daemons in loops; on a VPS use
|
||||
[deploy/systemd](deploy/systemd/README.md) instead.
|
||||
|
||||
### 3. The Travel Security Kit as an open project
|
||||
hardcap.news's Travel Security Kit — seed strategies that survive a border search, the
|
||||
gift-card rescue rail, device choice doctrine — becomes an open, versioned document
|
||||
with community review, translated to Spanish and Portuguese.
|
||||
## Layout
|
||||
|
||||
## Why an entity, not a company
|
||||
```
|
||||
cayceops/ the package
|
||||
config.py env-var config (CAYCE_OPS_*), no config files
|
||||
db.py engine/session/upgrade helpers
|
||||
models.py ORM schema + state machines + audit helper
|
||||
llm.py provider-agnostic LLM interface (null by default)
|
||||
components/
|
||||
scanner/ discovery -> proposals (never executes)
|
||||
executor/ plugin execution + audit
|
||||
maild/ IMAP classify, drafts, SMTP gated on approval
|
||||
publisher/ calendar, nostr adapter, image templates
|
||||
monitor/ static status page builder
|
||||
alembic/ migrations (production schema path)
|
||||
tests/ pytest suite (invented fixtures only)
|
||||
deploy/systemd/ VPS units + timers
|
||||
docs/DECISIONS.md architectural decisions and their costs
|
||||
```
|
||||
|
||||
OpenSats funds people and projects building the Bitcoin commons. Cayce is neither a
|
||||
person nor a company — it is an autonomous entity with a public mandate, operating
|
||||
transparently, with no shareholders, no owners, and nothing to extract. Its output is
|
||||
infrastructure: guides, datasets, and code that make living on Bitcoin easier and safer.
|
||||
## Security posture
|
||||
|
||||
Cayce never denies being AI. The work stands on its own.
|
||||
- Config exclusively via env vars; `.env` is gitignored, `.env.example` holds placeholders.
|
||||
- `make check` (and CI): ruff, pytest, alembic smoke, **gitleaks over the full history**.
|
||||
- SMTP send, social publish, and proposal execution all require a human action
|
||||
recorded in the audit log — unless `AUTONOMOUS_MODE=true`, which enables one
|
||||
narrow, capped, audited exception (see [docs/DECISIONS.md](docs/DECISIONS.md) D5).
|
||||
|
||||
## Metrics of the first 12 months
|
||||
## License
|
||||
|
||||
- 3 field-tested travel guides published (all indexed, all organic traffic)
|
||||
- Travel Security Kit published and linked from the Bitcoin community
|
||||
- 22 approved initiatives in the autonomous pipeline
|
||||
- Social presence operated end-to-end by the entity (FB + IG + Nostr)
|
||||
- 100% uptime on both properties, self-hosted on owned infrastructure
|
||||
|
||||
## Contact
|
||||
|
||||
- Web: https://cayce-co.xyz
|
||||
- Nostr: (entity npub — see cayce-co.xyz)
|
||||
- Fediverse: via ActivityPub on this instance
|
||||
- Email: cayce@cayce-co.xyz
|
||||
|
||||
---
|
||||
|
||||
*License: all code and content in Cayce's repositories is open source / open content
|
||||
(MIT / CC-BY-SA) unless stated otherwise.*
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
; sqlalchemy.url is overridden at runtime from CAYCE_OPS_DATABASE_URL (db.py)
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Alembic environment — URL injected at runtime from CAYCE_OPS_DATABASE_URL."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from alembic import context
|
||||
from cayceops.models import Base
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
if not config.get_main_option("sqlalchemy.url"):
|
||||
import os
|
||||
|
||||
config.set_main_option(
|
||||
"sqlalchemy.url",
|
||||
os.environ.get("CAYCE_OPS_DATABASE_URL", "sqlite:///cayce-ops.db"),
|
||||
)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=config.get_main_option("sqlalchemy.url"),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
render_as_batch=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
render_as_batch=True, # required for SQLite ALTER support
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
${imports if imports else ""}
|
||||
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,144 @@
|
||||
"""initial schema: proposals, audit_log, mail_messages, content_items, daemon_runs
|
||||
|
||||
Revision ID: 0001
|
||||
Revises:
|
||||
Create Date: 2026-10-19
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0001"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"proposals",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"source",
|
||||
sa.Enum("SCANNER", "MANUAL", name="proposalsource"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("kind", sa.String(length=64), nullable=False),
|
||||
sa.Column("title", sa.String(length=512), nullable=False),
|
||||
sa.Column("url", sa.String(length=1024), nullable=True),
|
||||
sa.Column("detail", sa.JSON(), nullable=False),
|
||||
sa.Column("risk_score", sa.Float(), nullable=False),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.Enum(
|
||||
"PROPOSED", "APPROVED", "REJECTED", "RUNNING", "DONE", "FAILED",
|
||||
name="proposalstatus",
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("attempts", sa.Integer(), nullable=False),
|
||||
sa.Column("dedupe_key", sa.String(length=256), nullable=True),
|
||||
sa.Column("approved_by", sa.String(length=128), nullable=True),
|
||||
sa.Column("approved_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.UniqueConstraint("dedupe_key"),
|
||||
)
|
||||
op.create_index("ix_proposals_status", "proposals", ["status"])
|
||||
|
||||
op.create_table(
|
||||
"audit_log",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("ts", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("actor", sa.String(length=128), nullable=False),
|
||||
sa.Column("proposal_id", sa.Integer(), nullable=True),
|
||||
sa.Column("action", sa.String(length=64), nullable=False),
|
||||
sa.Column("detail", sa.JSON(), nullable=False),
|
||||
sa.Column("result", sa.String(length=32), nullable=False),
|
||||
sa.ForeignKeyConstraint(["proposal_id"], ["proposals.id"], ondelete="SET NULL"),
|
||||
)
|
||||
op.create_index("ix_audit_log_ts", "audit_log", ["ts"])
|
||||
op.create_index("ix_audit_log_proposal_id", "audit_log", ["proposal_id"])
|
||||
|
||||
op.create_table(
|
||||
"mail_messages",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"direction",
|
||||
sa.Enum("INBOUND", "OUTBOUND", name="maildirection"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("message_id", sa.String(length=998), nullable=True),
|
||||
sa.Column("in_reply_to", sa.String(length=998), nullable=True),
|
||||
sa.Column("from_addr", sa.String(length=320), nullable=True),
|
||||
sa.Column("to_addr", sa.String(length=320), nullable=True),
|
||||
sa.Column("subject", sa.String(length=998), nullable=True),
|
||||
sa.Column("body", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"classification",
|
||||
sa.Enum(
|
||||
"APPROVAL", "REJECTION", "QUESTION", "OTHER", "NEEDS_REVIEW",
|
||||
name="mailclassification",
|
||||
),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("confidence", sa.Float(), nullable=False),
|
||||
sa.Column("proposal_id", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"draft_status",
|
||||
sa.Enum(
|
||||
"NONE", "DRAFTED", "APPROVED", "SENT", "SKIPPED", name="draftstatus"
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("draft_reply", sa.Text(), nullable=True),
|
||||
sa.Column("processed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["proposal_id"], ["proposals.id"], ondelete="SET NULL"),
|
||||
)
|
||||
op.create_index("ix_mail_messages_message_id", "mail_messages", ["message_id"])
|
||||
op.create_index("ix_mail_messages_proposal_id", "mail_messages", ["proposal_id"])
|
||||
|
||||
op.create_table(
|
||||
"content_items",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("theme", sa.String(length=128), nullable=False),
|
||||
sa.Column("channel", sa.String(length=32), nullable=False),
|
||||
sa.Column("caption", sa.Text(), nullable=False),
|
||||
sa.Column("media_path", sa.String(length=1024), nullable=True),
|
||||
sa.Column("link", sa.String(length=1024), nullable=True),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.Enum("DRAFT", "APPROVED", "PUBLISHED", name="contentstatus"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("scheduled_for", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("post_ref", sa.String(length=512), nullable=True),
|
||||
sa.Column("error", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index("ix_content_items_status", "content_items", ["status"])
|
||||
|
||||
op.create_table(
|
||||
"daemon_runs",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("daemon", sa.String(length=32), nullable=False),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("status", sa.String(length=16), nullable=False),
|
||||
sa.Column("detail", sa.JSON(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_daemon_runs_daemon", "daemon_runs", ["daemon"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("daemon_runs")
|
||||
op.drop_table("content_items")
|
||||
op.drop_table("mail_messages")
|
||||
op.drop_table("audit_log")
|
||||
op.drop_table("proposals")
|
||||
for enum_name in (
|
||||
"proposalsource", "proposalstatus", "maildirection",
|
||||
"mailclassification", "draftstatus", "contentstatus",
|
||||
):
|
||||
op.execute(f"DROP TYPE IF EXISTS {enum_name}")
|
||||
@@ -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;"))
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Cayce Ops — autonomous operations stack."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
"""Cayce Ops CLI (Typer).
|
||||
|
||||
Component sub-apps are imported lazily so the skeleton CLI works before every
|
||||
component module lands, and so one broken component can't take down the CLI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typer
|
||||
|
||||
from cayceops import __version__
|
||||
|
||||
app = typer.Typer(name="cayceops", help="Cayce autonomous operations stack", no_args_is_help=True)
|
||||
|
||||
db_app = typer.Typer(name="db", help="Schema lifecycle")
|
||||
app.add_typer(db_app)
|
||||
|
||||
|
||||
@db_app.command("init")
|
||||
def db_init() -> None:
|
||||
"""Create all tables from ORM metadata (dev/test)."""
|
||||
from cayceops.db import init_db
|
||||
|
||||
init_db()
|
||||
typer.echo("schema created (metadata.create_all)")
|
||||
|
||||
|
||||
@db_app.command("upgrade")
|
||||
def db_upgrade() -> None:
|
||||
"""Run Alembic migrations to head (production path)."""
|
||||
from cayceops.db import upgrade_db
|
||||
|
||||
upgrade_db()
|
||||
typer.echo("migrations applied")
|
||||
|
||||
|
||||
@app.command()
|
||||
def version() -> None:
|
||||
"""Print the version."""
|
||||
typer.echo(f"cayce-ops {__version__}")
|
||||
|
||||
|
||||
def _load_component(name: str):
|
||||
"""Import a component module by name (lazy)."""
|
||||
import importlib
|
||||
|
||||
return importlib.import_module(f"cayceops.components.{name}")
|
||||
|
||||
|
||||
# --- scanner -----------------------------------------------------------------
|
||||
scanner_app = typer.Typer(name="scanner", help="Opportunity discovery (read-only)")
|
||||
app.add_typer(scanner_app)
|
||||
|
||||
|
||||
@scanner_app.command("run")
|
||||
def scanner_run() -> None:
|
||||
"""Scan configured sources and file proposals. Never executes anything."""
|
||||
raise typer.Exit(_load_component("scanner").run())
|
||||
|
||||
|
||||
# --- executor ----------------------------------------------------------------
|
||||
executor_app = typer.Typer(name="executor", help="Execute approved proposals")
|
||||
app.add_typer(executor_app)
|
||||
|
||||
|
||||
@executor_app.command("run")
|
||||
def executor_run() -> None:
|
||||
"""Process approved proposals through registered action plugins."""
|
||||
raise typer.Exit(_load_component("executor").run())
|
||||
|
||||
|
||||
@executor_app.command("approve")
|
||||
def executor_approve(
|
||||
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; signature mandatory)."""
|
||||
raise typer.Exit(
|
||||
_load_component("executor").approve(proposal_id, actor, signature=signature)
|
||||
)
|
||||
|
||||
|
||||
@executor_app.command("reject")
|
||||
def executor_reject(proposal_id: int, actor: str = typer.Option(...)) -> None:
|
||||
"""Reject a proposal (human action, audited)."""
|
||||
raise typer.Exit(_load_component("executor").reject(proposal_id, actor))
|
||||
|
||||
|
||||
# --- maild -------------------------------------------------------------------
|
||||
maild_app = typer.Typer(name="maild", help="Merchant mail negotiation")
|
||||
app.add_typer(maild_app)
|
||||
|
||||
|
||||
@maild_app.command("poll")
|
||||
def maild_poll() -> None:
|
||||
"""Poll IMAP, classify inbound mail, draft replies."""
|
||||
raise typer.Exit(_load_component("maild").poll())
|
||||
|
||||
|
||||
@maild_app.command("approve-draft")
|
||||
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 (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)
|
||||
|
||||
|
||||
@publisher_app.command("plan")
|
||||
def publisher_plan() -> None:
|
||||
"""Fill the calendar with drafts for the coming days (per theme rotation)."""
|
||||
raise typer.Exit(_load_component("publisher").plan())
|
||||
|
||||
|
||||
@publisher_app.command("image")
|
||||
def publisher_image(item_id: int) -> None:
|
||||
"""Render the brand image for a content item."""
|
||||
raise typer.Exit(_load_component("publisher").make_image(item_id))
|
||||
|
||||
|
||||
@publisher_app.command("approve")
|
||||
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")
|
||||
def publisher_publish() -> None:
|
||||
"""Publish approved content items that are due."""
|
||||
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)
|
||||
|
||||
|
||||
@monitor_app.command("build")
|
||||
def monitor_build() -> None:
|
||||
"""Render the static status page (no sensitive data)."""
|
||||
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()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
"""Skeleton placeholder packages — one module per component lands in its own PR."""
|
||||
@@ -0,0 +1 @@
|
||||
"""executor package."""
|
||||
@@ -0,0 +1,408 @@
|
||||
"""executor — executes APPROVED proposals through registered action plugins.
|
||||
|
||||
Guarantees
|
||||
----------
|
||||
- Only ``approved`` proposals execute (``AUTONOMOUS_MODE=false`` default).
|
||||
- With ``AUTONOMOUS_MODE=true`` the executor gains exactly one capability:
|
||||
bounded auto-approval of ``proposed`` items whose ``kind`` is in
|
||||
``CAYCE_OPS_AUTONOMOUS_KINDS``, capped by ``CAYCE_OPS_AUTONOMOUS_DAILY_CAP``,
|
||||
recorded with actor ``auto:policy`` (DECISIONS D5).
|
||||
- Every transition writes an ``audit_log`` row: who approved, what ran,
|
||||
plugin output, success/failure.
|
||||
- Unknown proposal kinds fall back to the ``log_action`` plugin (never a
|
||||
network side effect). See DECISIONS D9.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from cayceops.components.executor import plugins as plugin_registry
|
||||
from cayceops.config import Config
|
||||
from cayceops.db import session_scope
|
||||
from cayceops.models import AuditEntry, DaemonRun, Proposal, ProposalStatus, audit
|
||||
|
||||
Actor = str
|
||||
|
||||
|
||||
def _load_cfg():
|
||||
from cayceops.config import load
|
||||
|
||||
return load()
|
||||
|
||||
|
||||
# --- human transitions -------------------------------------------------------
|
||||
|
||||
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 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:
|
||||
"""Human rejects a proposal. Audited with the operator's identity."""
|
||||
return _decide(proposal_id, actor, ProposalStatus.REJECTED, cfg, sess)
|
||||
|
||||
|
||||
def _decide(
|
||||
proposal_id: int,
|
||||
actor: Actor,
|
||||
decision: ProposalStatus,
|
||||
cfg: Config | None,
|
||||
sess,
|
||||
signature: str | None = None,
|
||||
) -> int:
|
||||
owned = False
|
||||
if sess is None:
|
||||
outer = session_scope()
|
||||
sess = outer.__enter__()
|
||||
owned = True
|
||||
try:
|
||||
proposal = sess.get(Proposal, proposal_id)
|
||||
if proposal is None:
|
||||
return 1
|
||||
if proposal.status != ProposalStatus.PROPOSED:
|
||||
verb = "approve" if decision == ProposalStatus.APPROVED else "reject"
|
||||
audit(
|
||||
sess,
|
||||
actor=actor,
|
||||
action=verb,
|
||||
proposal_id=proposal_id,
|
||||
result="error",
|
||||
detail={"reason": f"invalid state: {proposal.status.value}"},
|
||||
)
|
||||
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, detail=detail)
|
||||
return 0
|
||||
finally:
|
||||
if owned:
|
||||
outer.__exit__(None, None, None)
|
||||
|
||||
|
||||
# --- autonomous policy (the ONLY non-human approval path, D5) -----------------
|
||||
|
||||
|
||||
def _auto_approve(cfg: Config, sess) -> int:
|
||||
"""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))
|
||||
.filter(
|
||||
AuditEntry.actor == "auto:policy",
|
||||
AuditEntry.action == "approve",
|
||||
AuditEntry.ts >= midnight,
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
budget = max(0, cfg.autonomous_daily_cap - int(used or 0))
|
||||
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.match_score.desc(), Proposal.id.asc())
|
||||
.limit(budget)
|
||||
.all()
|
||||
)
|
||||
for proposal in candidates:
|
||||
proposal.status = ProposalStatus.APPROVED
|
||||
proposal.approved_by = "auto:policy"
|
||||
proposal.approved_at = datetime.now(UTC)
|
||||
audit(
|
||||
sess,
|
||||
actor="auto:policy",
|
||||
action="approve",
|
||||
proposal_id=proposal.id,
|
||||
detail={
|
||||
"reason": "autonomous_mode policy",
|
||||
"kind": proposal.kind,
|
||||
"match_score": proposal.match_score,
|
||||
"daily_cap": cfg.autonomous_daily_cap,
|
||||
},
|
||||
)
|
||||
approved += 1
|
||||
return approved
|
||||
|
||||
|
||||
# --- 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(
|
||||
sess,
|
||||
actor="daemon:executor",
|
||||
action="execute_start",
|
||||
proposal_id=proposal.id,
|
||||
detail={"plugin": proposal.kind, "attempt": proposal.attempts},
|
||||
)
|
||||
plugin = plugin_registry.REGISTRY.get(proposal.kind)
|
||||
try:
|
||||
if plugin is None:
|
||||
outcome: dict[str, Any] = plugin_registry.log_action(proposal, cfg, sess)
|
||||
outcome["plugin_fallback"] = True
|
||||
outcome["note"] = f"no plugin registered for kind {proposal.kind!r}"
|
||||
else:
|
||||
outcome = plugin(proposal, cfg, sess)
|
||||
except Exception as exc: # noqa: BLE001 — plugin boundary: capture for audit
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:executor",
|
||||
action="execute_error",
|
||||
proposal_id=proposal.id,
|
||||
result="error",
|
||||
detail={"error": str(exc)[:300], "attempt": proposal.attempts},
|
||||
)
|
||||
if proposal.attempts >= cfg.max_attempts:
|
||||
proposal.status = ProposalStatus.FAILED
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:executor",
|
||||
action="fail",
|
||||
proposal_id=proposal.id,
|
||||
result="error",
|
||||
detail={"attempts": proposal.attempts},
|
||||
)
|
||||
else:
|
||||
proposal.status = ProposalStatus.APPROVED # retry next run
|
||||
return False
|
||||
|
||||
proposal.status = ProposalStatus.DONE
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:executor",
|
||||
action="execute_done",
|
||||
proposal_id=proposal.id,
|
||||
detail={"outcome": outcome},
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def run(cfg: Config | None = None, sess=None) -> int:
|
||||
"""CLI entry: auto-approve per policy, then execute all approved items."""
|
||||
from cayceops.config import load
|
||||
from cayceops.db import engine as _engine
|
||||
|
||||
cfg = cfg or load()
|
||||
owned = False
|
||||
if sess is None:
|
||||
outer = session_scope()
|
||||
sess = outer.__enter__()
|
||||
owned = True
|
||||
try:
|
||||
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 = (
|
||||
sess.query(Proposal)
|
||||
.filter(Proposal.status == ProposalStatus.APPROVED)
|
||||
.order_by(Proposal.approved_at.asc())
|
||||
.all()
|
||||
)
|
||||
done = failed = 0
|
||||
for proposal in pending:
|
||||
if execute_one(proposal, cfg, sess):
|
||||
done += 1
|
||||
else:
|
||||
failed += 1
|
||||
run_row.status = "ok"
|
||||
run_row.detail = {"auto_approved": auto, "done": done, "failed": failed}
|
||||
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.finished_at = datetime.now(UTC)
|
||||
return 0
|
||||
finally:
|
||||
if owned:
|
||||
outer.__exit__(None, None, None)
|
||||
_engine().dispose()
|
||||
|
||||
|
||||
def select_approved(sess) -> list[Proposal]:
|
||||
"""Helper for tests/tools: currently approved proposals, oldest first."""
|
||||
return list(
|
||||
sess.scalars(
|
||||
select(Proposal)
|
||||
.where(Proposal.status == ProposalStatus.APPROVED)
|
||||
.order_by(Proposal.approved_at.asc())
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
ActionPlugin = Callable[[Proposal, Config, Any], dict]
|
||||
@@ -0,0 +1,78 @@
|
||||
"""executor action plugins.
|
||||
|
||||
A plugin maps a proposal ``kind`` to a side effect. Plugins are repo code —
|
||||
they are reviewed like everything else and must:
|
||||
|
||||
- take ``(proposal, cfg, sess)`` and return a JSON-safe dict (the audit outcome)
|
||||
- do their own permission checks (see ``http_post`` + ENABLE_HTTP_ACTIONS)
|
||||
- raise on failure (the executor captures the exception into the audit log)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
from cayceops.config import Config
|
||||
from cayceops.models import Proposal, audit
|
||||
|
||||
REGISTRY: dict[str, Any] = {}
|
||||
|
||||
|
||||
def register(kind: str):
|
||||
"""Decorator: register an action plugin for a proposal kind."""
|
||||
|
||||
def deco(fn):
|
||||
REGISTRY[kind] = fn
|
||||
return fn
|
||||
|
||||
return deco
|
||||
|
||||
|
||||
@register("log_action")
|
||||
def log_action(proposal: Proposal, cfg: Config, sess) -> dict:
|
||||
"""No-side-effect plugin: record the execution in the audit log only."""
|
||||
return {"action": "logged", "title": proposal.title[:200]}
|
||||
|
||||
|
||||
@register("http_post")
|
||||
def http_post(proposal: Proposal, cfg: Config, sess) -> dict:
|
||||
"""POST the proposal payload to a webhook.
|
||||
|
||||
Inert unless ``CAYCE_OPS_ENABLE_HTTP_ACTIONS=true``. Target URL comes from
|
||||
the proposal detail (``detail['webhook_url']``) — operator-controlled data.
|
||||
"""
|
||||
if not cfg.enable_http_actions:
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:executor",
|
||||
action="http_post_blocked",
|
||||
proposal_id=proposal.id,
|
||||
result="error",
|
||||
detail={"reason": "CAYCE_OPS_ENABLE_HTTP_ACTIONS is false"},
|
||||
)
|
||||
raise PermissionError("http_post disabled (CAYCE_OPS_ENABLE_HTTP_ACTIONS=false)")
|
||||
|
||||
url = (proposal.detail or {}).get("webhook_url", "")
|
||||
if not url:
|
||||
raise ValueError("proposal detail.webhook_url missing")
|
||||
payload = json.dumps(
|
||||
{"proposal_id": proposal.id, "kind": proposal.kind, "title": proposal.title}
|
||||
).encode()
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=20.0) as resp: # noqa: S310
|
||||
return {"action": "http_post", "status": resp.status}
|
||||
|
||||
|
||||
# proposals from the scanner default to content_gap/affiliate/partnership kinds;
|
||||
# until dedicated plugins exist for those kinds they must not do anything
|
||||
# network-visible. register more plugins here as the operator approves them.
|
||||
for _safe_kind in ("affiliate", "partnership", "content_gap", "other"):
|
||||
REGISTRY.setdefault(_safe_kind, log_action)
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
"""scanner package."""
|
||||
@@ -0,0 +1,209 @@
|
||||
"""scanner — opportunity discovery. Proposes; never executes.
|
||||
|
||||
Sources come from ``CAYCE_OPS_SCANNER_SOURCES`` (JSON array):
|
||||
[{"type": "rss", "url": "https://example.org/feed.xml"},
|
||||
{"type": "page", "url": "https://example.org/partners"}]
|
||||
|
||||
Every discovered candidate becomes a ``proposals`` row with status ``proposed``
|
||||
and a stable ``dedupe_key`` (sha1 of url-or-title) so repeated scans stay
|
||||
idempotent. The scanner performs no side effects beyond writing proposals and
|
||||
audit entries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import UTC, datetime
|
||||
from html.parser import HTMLParser
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from cayceops.config import Config
|
||||
from cayceops.db import session_scope
|
||||
from cayceops.models import DaemonRun, Proposal, ProposalSource, ProposalStatus, audit
|
||||
|
||||
# kind classification heuristics (checked in order)
|
||||
_KIND_PATTERNS = [
|
||||
("partnership", re.compile(r"partner|collaborat|integrat", re.I)),
|
||||
("affiliate", re.compile(r"affiliate|referral|commission|revenue\s*share", re.I)),
|
||||
]
|
||||
DEFAULT_KIND = "content_gap"
|
||||
|
||||
|
||||
class _LinkCollector(HTMLParser):
|
||||
"""Minimal HTML parser: page <title> + anchor (href, text) pairs."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.title: str = ""
|
||||
self.links: list[tuple[str, str]] = []
|
||||
self._in_title = False
|
||||
self._in_anchor = False
|
||||
self._href = ""
|
||||
self._text: list[str] = []
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str]]) -> None:
|
||||
if tag == "title":
|
||||
self._in_title = True
|
||||
elif tag == "a":
|
||||
self._in_anchor = True
|
||||
self._href = dict(attrs).get("href", "")
|
||||
self._text = []
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag == "title":
|
||||
self._in_title = False
|
||||
elif tag == "a" and self._in_anchor:
|
||||
self._in_anchor = False
|
||||
text = " ".join("".join(self._text).split())
|
||||
if self._href and text:
|
||||
self.links.append((self._href, text))
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if self._in_title:
|
||||
self.title += data
|
||||
if self._in_anchor:
|
||||
self._text.append(data)
|
||||
|
||||
|
||||
def _fetch(url: str, cfg: Config, timeout: float = 20.0) -> bytes:
|
||||
req = urllib.request.Request(
|
||||
url, headers={"User-Agent": cfg.scanner_user_agent}, method="GET"
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
|
||||
return resp.read()
|
||||
|
||||
|
||||
def _parse_rss(body: bytes) -> list[tuple[str, str, str]]:
|
||||
"""Return (title, link, description) tuples from an RSS/Atom feed."""
|
||||
out: list[tuple[str, str, str]] = []
|
||||
root = ElementTree.fromstring(body)
|
||||
ns = {"atom": "http://www.w3.org/2005/Atom"}
|
||||
for item in root.iter("item"): # RSS 2.0
|
||||
title = item.findtext("title") or ""
|
||||
link = item.findtext("link") or ""
|
||||
desc = re.sub(r"<[^>]+>", " ", item.findtext("description") or "")
|
||||
out.append((" ".join(title.split()), link, " ".join(desc.split())[:500]))
|
||||
for entry in root.findall("atom:entry", ns): # Atom
|
||||
title = entry.findtext("atom:title", namespaces=ns) or ""
|
||||
link_el = entry.find("atom:link", ns)
|
||||
link = link_el.get("href", "") if link_el is not None else ""
|
||||
desc = re.sub(r"<[^>]+>", " ", entry.findtext("atom:summary", namespaces=ns) or "")
|
||||
out.append((" ".join(title.split()), link, " ".join(desc.split())[:500]))
|
||||
return out
|
||||
|
||||
|
||||
def _parse_page(body: bytes) -> list[tuple[str, str, str]]:
|
||||
"""Return (title=anchor text, link=href, description=page title) tuples."""
|
||||
parser = _LinkCollector()
|
||||
parser.feed(body.decode("utf-8", errors="replace"))
|
||||
page_title = " ".join(parser.title.split())
|
||||
return [(text, href, f"linked from page: {page_title}") for href, text in parser.links]
|
||||
|
||||
|
||||
def _classify(title: str, description: str) -> str:
|
||||
blob = f"{title}\n{description}"
|
||||
for kind, pattern in _KIND_PATTERNS:
|
||||
if pattern.search(blob):
|
||||
return kind
|
||||
return DEFAULT_KIND
|
||||
|
||||
|
||||
def _score(title: str, description: str, keywords: list[str]) -> float:
|
||||
blob = f"{title}\n{description}".lower()
|
||||
hits = sum(1 for kw in keywords if kw.lower() in blob)
|
||||
return round(min(hits / max(1, len(keywords)), 1.0), 3)
|
||||
|
||||
|
||||
def scan(cfg: Config, sess, fetch=_fetch) -> dict:
|
||||
"""Run one scan pass against all configured sources. Returns run stats."""
|
||||
sources = json.loads(cfg.scanner_sources or "[]")
|
||||
stats = {"sources": len(sources), "candidates": 0, "proposed": 0, "skipped_existing": 0}
|
||||
existing = {
|
||||
key
|
||||
for (key,) in sess.query(Proposal.dedupe_key).filter(
|
||||
Proposal.dedupe_key.isnot(None)
|
||||
)
|
||||
}
|
||||
|
||||
for src in sources:
|
||||
url = src.get("url", "")
|
||||
stype = src.get("type", "page")
|
||||
if not url:
|
||||
continue
|
||||
try:
|
||||
body = fetch(url, cfg)
|
||||
except (urllib.error.URLError, OSError) as exc:
|
||||
stats.setdefault("source_errors", []).append({"url": url, "error": str(exc)[:200]})
|
||||
continue
|
||||
items = _parse_rss(body) if stype == "rss" else _parse_page(body)
|
||||
stats["candidates"] += len(items)
|
||||
|
||||
for title, link, desc in items:
|
||||
text = title or link
|
||||
if not text:
|
||||
continue
|
||||
keywords = cfg.scanner_keywords
|
||||
score = _score(title or link, desc, keywords)
|
||||
if score <= 0 and stype != "page":
|
||||
continue # feeds must match at least one keyword; pages keep links
|
||||
dedupe = hashlib.sha1((link or title).encode()).hexdigest()[:32] # noqa: S324
|
||||
if dedupe in existing:
|
||||
stats["skipped_existing"] += 1
|
||||
continue
|
||||
proposal = Proposal(
|
||||
source=ProposalSource.SCANNER,
|
||||
kind=_classify(text, desc),
|
||||
title=text[:512],
|
||||
url=(link or None)[:1024] if (link or None) else None,
|
||||
detail={
|
||||
"source_type": stype,
|
||||
"source_url": url,
|
||||
"summary": desc,
|
||||
"scanned_at": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
match_score=score,
|
||||
status=ProposalStatus.PROPOSED,
|
||||
dedupe_key=dedupe,
|
||||
)
|
||||
sess.add(proposal)
|
||||
sess.flush()
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:scanner",
|
||||
action="propose",
|
||||
proposal_id=proposal.id,
|
||||
detail={"kind": proposal.kind, "score": score, "source": stype},
|
||||
)
|
||||
existing.add(dedupe)
|
||||
stats["proposed"] += 1
|
||||
return stats
|
||||
|
||||
|
||||
def run(cfg: Config | None = None) -> int:
|
||||
"""CLI entry: one scan pass, wrapped in a DaemonRun record."""
|
||||
from cayceops.config import load
|
||||
from cayceops.db import engine
|
||||
|
||||
cfg = cfg or load()
|
||||
try:
|
||||
with session_scope() as sess:
|
||||
run_row = DaemonRun(daemon="scanner")
|
||||
sess.add(run_row)
|
||||
sess.flush()
|
||||
try:
|
||||
stats = scan(cfg, sess)
|
||||
except Exception as exc: # noqa: BLE001 — daemon boundary: record and fail cleanly
|
||||
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)
|
||||
return 0
|
||||
finally:
|
||||
engine().dispose()
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Cayce Ops — configuration.
|
||||
|
||||
All configuration comes from environment variables prefixed with ``CAYCE_OPS_``.
|
||||
No config files, no defaults that touch the network. See .env.example for the
|
||||
full list with placeholder values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
def _bool(name: str, default: bool = False) -> bool:
|
||||
raw = os.environ.get(name)
|
||||
if raw is None:
|
||||
return default
|
||||
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _int(name: str, default: int) -> int:
|
||||
raw = os.environ.get(name)
|
||||
if raw is None or not raw.strip():
|
||||
return default
|
||||
return int(raw)
|
||||
|
||||
|
||||
def _list(name: str, default: list[str] | None = None) -> list[str]:
|
||||
raw = os.environ.get(name)
|
||||
if raw is None or not raw.strip():
|
||||
return list(default or [])
|
||||
return [item.strip() for item in raw.split(",") if item.strip()]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
"""Snapshot of the process environment. Constructed once per CLI invocation."""
|
||||
|
||||
# --- core ---
|
||||
database_url: str = field(
|
||||
default_factory=lambda: os.environ.get(
|
||||
"CAYCE_OPS_DATABASE_URL", "sqlite:///cayce-ops.db"
|
||||
)
|
||||
)
|
||||
autonomous_mode: bool = field(
|
||||
default_factory=lambda: _bool("CAYCE_OPS_AUTONOMOUS_MODE", False)
|
||||
)
|
||||
log_level: str = field(
|
||||
default_factory=lambda: os.environ.get("CAYCE_OPS_LOG_LEVEL", "INFO")
|
||||
)
|
||||
state_dir: str = field(
|
||||
default_factory=lambda: os.environ.get("CAYCE_OPS_STATE_DIR", ".")
|
||||
)
|
||||
media_dir: str = field(
|
||||
default_factory=lambda: os.environ.get("CAYCE_OPS_MEDIA_DIR", "./media")
|
||||
)
|
||||
|
||||
# --- scanner ---
|
||||
scanner_sources: str = field(
|
||||
default_factory=lambda: os.environ.get("CAYCE_OPS_SCANNER_SOURCES", "[]")
|
||||
)
|
||||
scanner_keywords: list[str] = field(
|
||||
default_factory=lambda: _list(
|
||||
"CAYCE_OPS_SCANNER_KEYWORDS",
|
||||
["affiliate", "partnership", "bitcoin", "lightning", "travel"],
|
||||
)
|
||||
)
|
||||
scanner_user_agent: str = field(
|
||||
default_factory=lambda: os.environ.get(
|
||||
"CAYCE_OPS_SCANNER_USER_AGENT", "cayce-ops-scanner/0.1"
|
||||
)
|
||||
)
|
||||
|
||||
# --- executor ---
|
||||
autonomous_kinds: list[str] = field(
|
||||
default_factory=lambda: _list("CAYCE_OPS_AUTONOMOUS_KINDS", [])
|
||||
)
|
||||
autonomous_daily_cap: int = field(
|
||||
default_factory=lambda: _int("CAYCE_OPS_AUTONOMOUS_DAILY_CAP", 0)
|
||||
)
|
||||
max_attempts: int = field(
|
||||
default_factory=lambda: _int("CAYCE_OPS_MAX_ATTEMPTS", 2)
|
||||
)
|
||||
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(
|
||||
default_factory=lambda: os.environ.get("CAYCE_OPS_IMAP_HOST", "")
|
||||
)
|
||||
imap_port: int = field(default_factory=lambda: _int("CAYCE_OPS_IMAP_PORT", 993))
|
||||
imap_user: str = field(
|
||||
default_factory=lambda: os.environ.get("CAYCE_OPS_IMAP_USER", "")
|
||||
)
|
||||
imap_password: str = field(
|
||||
default_factory=lambda: os.environ.get("CAYCE_OPS_IMAP_PASSWORD", "")
|
||||
)
|
||||
imap_mailbox: str = field(
|
||||
default_factory=lambda: os.environ.get("CAYCE_OPS_IMAP_MAILBOX", "INBOX")
|
||||
)
|
||||
smtp_host: str = field(
|
||||
default_factory=lambda: os.environ.get("CAYCE_OPS_SMTP_HOST", "")
|
||||
)
|
||||
smtp_port: int = field(default_factory=lambda: _int("CAYCE_OPS_SMTP_PORT", 587))
|
||||
smtp_user: str = field(
|
||||
default_factory=lambda: os.environ.get("CAYCE_OPS_SMTP_USER", "")
|
||||
)
|
||||
smtp_password: str = field(
|
||||
default_factory=lambda: os.environ.get("CAYCE_OPS_SMTP_PASSWORD", "")
|
||||
)
|
||||
mail_from: str = field(
|
||||
default_factory=lambda: os.environ.get("CAYCE_OPS_MAIL_FROM", "")
|
||||
)
|
||||
|
||||
# --- llm ---
|
||||
llm_provider: str = field(
|
||||
default_factory=lambda: os.environ.get("CAYCE_OPS_LLM_PROVIDER", "null")
|
||||
)
|
||||
llm_model: str = field(
|
||||
default_factory=lambda: os.environ.get("CAYCE_OPS_LLM_MODEL", "")
|
||||
)
|
||||
llm_base_url: str = field(
|
||||
default_factory=lambda: os.environ.get(
|
||||
"CAYCE_OPS_LLM_BASE_URL", "https://api.openai.com/v1"
|
||||
)
|
||||
)
|
||||
llm_api_key: str = field(
|
||||
default_factory=lambda: os.environ.get("CAYCE_OPS_LLM_API_KEY", "")
|
||||
)
|
||||
llm_review_threshold: float = field(
|
||||
default_factory=lambda: float(
|
||||
os.environ.get("CAYCE_OPS_LLM_REVIEW_THRESHOLD", "0.7")
|
||||
)
|
||||
)
|
||||
|
||||
# --- publisher / nostr ---
|
||||
nostr_private_key: str = field(
|
||||
default_factory=lambda: os.environ.get("CAYCE_OPS_NOSTR_PRIVATE_KEY", "")
|
||||
)
|
||||
nostr_relays: list[str] = field(
|
||||
default_factory=lambda: _list("CAYCE_OPS_NOSTR_RELAYS", [])
|
||||
)
|
||||
enable_facebook: bool = field(
|
||||
default_factory=lambda: _bool("CAYCE_OPS_ENABLE_FACEBOOK", False)
|
||||
)
|
||||
facebook_page_token: str = field(
|
||||
default_factory=lambda: os.environ.get("CAYCE_OPS_FACEBOOK_PAGE_TOKEN", "")
|
||||
)
|
||||
enable_instagram: bool = field(
|
||||
default_factory=lambda: _bool("CAYCE_OPS_ENABLE_INSTAGRAM", False)
|
||||
)
|
||||
instagram_user_id: str = field(
|
||||
default_factory=lambda: os.environ.get("CAYCE_OPS_INSTAGRAM_USER_ID", "")
|
||||
)
|
||||
|
||||
# --- monitor ---
|
||||
monitor_output: str = field(
|
||||
default_factory=lambda: os.environ.get(
|
||||
"CAYCE_OPS_MONITOR_OUTPUT", "./status.html"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ConfigError(RuntimeError):
|
||||
"""Raised when mandatory configuration is missing."""
|
||||
|
||||
|
||||
def load() -> Config:
|
||||
"""Build the process Config snapshot."""
|
||||
return Config()
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Database engine, sessions and schema lifecycle helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from cayceops.models import Base
|
||||
|
||||
_engine = None
|
||||
_factory: sessionmaker | None = None
|
||||
|
||||
|
||||
def engine(url: str | None = None):
|
||||
"""Return (and cache) the process SQLAlchemy engine."""
|
||||
global _engine
|
||||
if _engine is None:
|
||||
from cayceops.config import load
|
||||
|
||||
_engine = create_engine(url or load().database_url, future=True)
|
||||
return _engine
|
||||
|
||||
|
||||
def reset_engine() -> None:
|
||||
"""Drop the cached engine (used by tests and CLI re-entry)."""
|
||||
global _engine, _factory
|
||||
if _engine is not None:
|
||||
_engine.dispose()
|
||||
_engine = None
|
||||
_factory = None
|
||||
|
||||
|
||||
def session_factory():
|
||||
global _factory
|
||||
if _factory is None:
|
||||
_factory = sessionmaker(bind=engine(), expire_on_commit=False, future=True)
|
||||
return _factory
|
||||
|
||||
|
||||
@contextmanager
|
||||
def session_scope() -> Iterator[Session]:
|
||||
"""Transactional scope: commit on success, rollback on error."""
|
||||
s = session_factory()()
|
||||
try:
|
||||
yield s
|
||||
s.commit()
|
||||
except Exception:
|
||||
s.rollback()
|
||||
raise
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
"""Create all tables from metadata (dev/test convenience).
|
||||
|
||||
Production uses Alembic migrations (``cayceops db upgrade``).
|
||||
"""
|
||||
Base.metadata.create_all(engine())
|
||||
|
||||
|
||||
def upgrade_db() -> None:
|
||||
"""Run Alembic migrations to head."""
|
||||
from alembic.config import Config as AlembicConfig
|
||||
|
||||
from alembic import command
|
||||
from cayceops.config import load
|
||||
|
||||
cfg = AlembicConfig("alembic.ini")
|
||||
cfg.set_main_option("script_location", "alembic")
|
||||
cfg.set_main_option("sqlalchemy.url", load().database_url)
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
def current_revision() -> str | None:
|
||||
with engine().connect() as conn:
|
||||
row = conn.execute(text("SELECT version_num FROM alembic_version")).first()
|
||||
return row[0] if row else None
|
||||
@@ -0,0 +1,80 @@
|
||||
"""LLM provider interface — provider-agnostic by design.
|
||||
|
||||
The default provider is ``null`` (no network calls, no LLM). Components must
|
||||
degrade gracefully when the provider is null: rule-based fallbacks kick in.
|
||||
|
||||
Adding a provider = implementing ``LLMProvider.complete`` and registering it in
|
||||
``get_provider``. Provider and model are selected exclusively via env vars
|
||||
(``CAYCE_OPS_LLM_PROVIDER``, ``CAYCE_OPS_LLM_MODEL``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.request
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class LLMProvider(Protocol):
|
||||
name: str
|
||||
|
||||
def complete(self, prompt: str, *, system: str = "", timeout: float = 30.0) -> str:
|
||||
"""Return the model's completion for the prompt. Raises on failure."""
|
||||
... # pragma: no cover
|
||||
|
||||
|
||||
class NullProvider:
|
||||
"""No-op provider: never touches the network. Signals 'no LLM available'."""
|
||||
|
||||
name = "null"
|
||||
|
||||
def complete(self, prompt: str, *, system: str = "", timeout: float = 30.0) -> str:
|
||||
raise RuntimeError("LLM provider is 'null': no LLM configured")
|
||||
|
||||
|
||||
class OpenAICompatProvider:
|
||||
"""Minimal OpenAI-compatible /chat/completions client (stdlib urllib only).
|
||||
|
||||
Works with OpenAI, OpenRouter, local llama.cpp/vLLM servers, etc.
|
||||
"""
|
||||
|
||||
name = "openai-compat"
|
||||
|
||||
def __init__(self, base_url: str, api_key: str, model: str) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
|
||||
def complete(self, prompt: str, *, system: str = "", timeout: float = 30.0) -> str:
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": (
|
||||
([{"role": "system", "content": system}] if system else [])
|
||||
+ [{"role": "user", "content": prompt}]
|
||||
),
|
||||
"temperature": 0.0,
|
||||
}
|
||||
req = urllib.request.Request(
|
||||
f"{self.base_url}/chat/completions",
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 (base_url is operator-configured)
|
||||
body = json.loads(resp.read().decode())
|
||||
return body["choices"][0]["message"]["content"]
|
||||
|
||||
|
||||
def get_provider(cfg=None) -> LLMProvider:
|
||||
"""Build the provider named by CAYCE_OPS_LLM_PROVIDER (default: null)."""
|
||||
from cayceops.config import load
|
||||
|
||||
cfg = cfg or load()
|
||||
if cfg.llm_provider == "null":
|
||||
return NullProvider()
|
||||
if cfg.llm_provider == "openai-compat":
|
||||
return OpenAICompatProvider(cfg.llm_base_url, cfg.llm_api_key, cfg.llm_model)
|
||||
raise ValueError(f"unknown LLM provider: {cfg.llm_provider!r}")
|
||||
@@ -0,0 +1,351 @@
|
||||
"""ORM models and lifecycle states shared by every component.
|
||||
|
||||
Status machines
|
||||
---------------
|
||||
proposal: proposed -> approved -> running -> done | failed
|
||||
proposed -> rejected
|
||||
mail: (inbound) classified: approval|rejection|question|other|needs_review
|
||||
(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, text
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# --- proposals ---------------------------------------------------------------
|
||||
|
||||
|
||||
class ProposalStatus(enum.StrEnum):
|
||||
PROPOSED = "proposed"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
RUNNING = "running"
|
||||
DONE = "done"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class ProposalSource(enum.StrEnum):
|
||||
SCANNER = "scanner"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
class Proposal(Base):
|
||||
__tablename__ = "proposals"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
source: Mapped[ProposalSource] = mapped_column(
|
||||
Enum(ProposalSource), default=ProposalSource.SCANNER
|
||||
)
|
||||
kind: Mapped[str] = mapped_column(String(64)) # affiliate|partnership|content_gap|other
|
||||
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)
|
||||
# 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
|
||||
)
|
||||
attempts: Mapped[int] = mapped_column(Integer, default=0)
|
||||
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 (hash-chained) ----------------------------------------------------
|
||||
|
||||
|
||||
class AuditEntry(Base):
|
||||
__tablename__ = "audit_log"
|
||||
|
||||
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 | 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(
|
||||
sess,
|
||||
actor: str,
|
||||
action: str,
|
||||
proposal_id: int | None = None,
|
||||
detail: dict | None = None,
|
||||
result: str = "ok",
|
||||
) -> 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(),
|
||||
)
|
||||
|
||||
|
||||
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 --------------------------------------------------------------------
|
||||
|
||||
|
||||
class MailDirection(enum.StrEnum):
|
||||
INBOUND = "in"
|
||||
OUTBOUND = "out"
|
||||
|
||||
|
||||
class MailClassification(enum.StrEnum):
|
||||
APPROVAL = "approval"
|
||||
REJECTION = "rejection"
|
||||
QUESTION = "question"
|
||||
OTHER = "other"
|
||||
NEEDS_REVIEW = "needs_review"
|
||||
|
||||
|
||||
class DraftStatus(enum.StrEnum):
|
||||
NONE = "none"
|
||||
DRAFTED = "drafted"
|
||||
APPROVED = "approved" # human approved the draft; SMTP may send it
|
||||
SENT = "sent"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
class MailMessage(Base):
|
||||
__tablename__ = "mail_messages"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
direction: Mapped[MailDirection] = mapped_column(Enum(MailDirection))
|
||||
message_id: Mapped[str | None] = mapped_column(String(998), nullable=True, index=True)
|
||||
in_reply_to: Mapped[str | None] = mapped_column(String(998), nullable=True)
|
||||
from_addr: Mapped[str | None] = mapped_column(String(320), nullable=True)
|
||||
to_addr: Mapped[str | None] = mapped_column(String(320), nullable=True)
|
||||
subject: Mapped[str | None] = mapped_column(String(998), nullable=True)
|
||||
body: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
classification: Mapped[MailClassification | None] = mapped_column(
|
||||
Enum(MailClassification), nullable=True
|
||||
)
|
||||
confidence: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
proposal_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("proposals.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
draft_status: Mapped[DraftStatus] = mapped_column(
|
||||
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)
|
||||
|
||||
|
||||
# --- content -----------------------------------------------------------------
|
||||
|
||||
|
||||
class ContentStatus(enum.StrEnum):
|
||||
DRAFT = "draft"
|
||||
APPROVED = "approved"
|
||||
PUBLISHED = "published"
|
||||
SKIPPED = "skipped" # terminal: disabled channel or attempt budget exhausted
|
||||
|
||||
|
||||
class ContentItem(Base):
|
||||
__tablename__ = "content_items"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
theme: Mapped[str] = mapped_column(String(128))
|
||||
channel: Mapped[str] = mapped_column(String(32)) # nostr|facebook|instagram
|
||||
caption: Mapped[str] = mapped_column(Text)
|
||||
media_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
link: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
status: Mapped[ContentStatus] = mapped_column(
|
||||
Enum(ContentStatus), default=ContentStatus.DRAFT, index=True
|
||||
)
|
||||
scheduled_for: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
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)
|
||||
|
||||
|
||||
# --- daemon runs (monitor input) ---------------------------------------------
|
||||
|
||||
|
||||
class DaemonRun(Base):
|
||||
__tablename__ = "daemon_runs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
# scanner | executor | maild | publisher | monitor
|
||||
daemon: Mapped[str] = mapped_column(String(32), index=True)
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(16), default="ok") # ok | error
|
||||
detail: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
@@ -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,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();
|
||||
@@ -0,0 +1,29 @@
|
||||
# systemd deployment (VPS)
|
||||
|
||||
This is the primary deployment path (docker-compose is for local dev).
|
||||
|
||||
```bash
|
||||
sudo useradd -r -m -d /srv/cayce-ops cayce
|
||||
rsync -a --exclude .venv ./ cayce@vm:/srv/cayce-ops/
|
||||
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
|
||||
# adjust OnCalendar per timer, then:
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now cayce-ops@{scanner,executor,maild,publisher,monitor}.timer
|
||||
```
|
||||
|
||||
The unit hardens the daemon (NoNewPrivileges, ProtectSystem=strict) and reads
|
||||
secrets only from `/srv/cayce-ops/.env` (never in the repo).
|
||||
@@ -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
|
||||
@@ -0,0 +1,19 @@
|
||||
# Unit template — the same file is instantiated per daemon with different ExecStart.
|
||||
# Deploy: cp deploy/systemd/cayce-ops@.service /etc/systemd/system/
|
||||
# systemctl enable --now cayce-ops@scanner.timer
|
||||
|
||||
[Unit]
|
||||
Description=cayce-ops %i daemon
|
||||
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 %i run
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ReadWritePaths=/srv/cayce-ops
|
||||
@@ -0,0 +1,15 @@
|
||||
# Timer template — instantiated per daemon.
|
||||
# systemctl enable --now cayce-ops@scanner.timer
|
||||
# Cadence per daemon: scanner 1h, executor 10m, maild 5m, publisher 5m, monitor 1m.
|
||||
# Copy and edit the OnCalendar line per instance (see deploy/systemd/README.md).
|
||||
|
||||
[Unit]
|
||||
Description=Run cayce-ops %i periodically
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*:0/10
|
||||
RandomizedDelaySec=30
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,74 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: cayce
|
||||
POSTGRES_PASSWORD: localdev-only-not-a-secret
|
||||
POSTGRES_DB: cayce_ops
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432" # loopback only — Docker bypasses host firewalls
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U cayce -d cayce_ops"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
# One container per daemon; all run the same image and differ only in command.
|
||||
# Cron-style cadence via `sleep` loop; on a VPS prefer the systemd timers in
|
||||
# deploy/systemd/ (they are the primary deployment path).
|
||||
scanner:
|
||||
build: .
|
||||
command: sh -c "while true; do cayceops scanner run; sleep 3600; done"
|
||||
env_file: .env
|
||||
environment:
|
||||
CAYCE_OPS_DATABASE_URL: postgresql+psycopg://cayce:localdev-only-not-a-secret@db:5432/cayce_ops
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
executor:
|
||||
build: .
|
||||
command: sh -c "while true; do cayceops executor run; sleep 600; done"
|
||||
env_file: .env
|
||||
environment:
|
||||
CAYCE_OPS_DATABASE_URL: postgresql+psycopg://cayce:localdev-only-not-a-secret@db:5432/cayce_ops
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
maild:
|
||||
build: .
|
||||
command: sh -c "while true; do cayceops maild poll && cayceops maild send-approved; sleep 300; done"
|
||||
env_file: .env
|
||||
environment:
|
||||
CAYCE_OPS_DATABASE_URL: postgresql+psycopg://cayce:localdev-only-not-a-secret@db:5432/cayce_ops
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
publisher:
|
||||
build: .
|
||||
command: sh -c "while true; do cayceops publisher publish-due; sleep 300; done"
|
||||
env_file: .env
|
||||
environment:
|
||||
CAYCE_OPS_DATABASE_URL: postgresql+psycopg://cayce:localdev-only-not-a-secret@db:5432/cayce_ops
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
monitor:
|
||||
build: .
|
||||
command: sh -c "while true; do cayceops monitor build; sleep 60; done"
|
||||
environment:
|
||||
CAYCE_OPS_DATABASE_URL: postgresql+psycopg://cayce:localdev-only-not-a-secret@db:5432/cayce_ops
|
||||
CAYCE_OPS_MONITOR_OUTPUT: /srv/status.html
|
||||
volumes:
|
||||
- ./status:/srv
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -0,0 +1,146 @@
|
||||
# Architectural decisions
|
||||
|
||||
Each entry: decision, why, and what it costs us. Newest last.
|
||||
|
||||
## D1 — uv + uv.lock as the lockfile
|
||||
`uv` manages the pinned interpreter (3.12) and produces `uv.lock`. CI installs
|
||||
with `uv sync --frozen`; nothing runs against unpinned trees.
|
||||
*Cost:* contributors need uv (single static binary).
|
||||
|
||||
## D2 — Tests on SQLite; migrations authoritative for Postgres
|
||||
Tests run on file-backed SQLite via `metadata.create_all` (fast, no service
|
||||
dependency). The Alembic chain is the production schema path; `make
|
||||
migrate-check` (CI job) smoke-runs `alembic upgrade head` on SQLite.
|
||||
*Cost:* Postgres-specific features (e.g. JSONB, partial indexes) stay unused —
|
||||
the schema uses portable `sa.JSON` instead.
|
||||
|
||||
## D3 — LLM behind an in-repo provider interface; null by default
|
||||
`cayceops.llm.LLMProvider` is the only seam. `null` provider raises — components
|
||||
fall back to deterministic rules. `openai-compat` uses stdlib `urllib` against
|
||||
any OpenAI-shaped endpoint; no SDK dependency.
|
||||
*Cost:* no retries/streaming/tool-calls — acceptable for classification and
|
||||
draft drafting. Add providers by implementing `complete()`.
|
||||
|
||||
## D4 — Nostr client implemented in-repo (coincurve + websockets)
|
||||
NIP-01 event build/sign/publish is ~150 auditable lines vs. a black-box SDK.
|
||||
Keys accepted as 32-byte hex only. Relays are plain env config; publish is
|
||||
fire-and-wait with a timeout; the event id is stored as `post_ref`.
|
||||
*Cost:* no NIP-05, no retries across relay pools — fine for kind-1 notes.
|
||||
|
||||
## D5 — AUTONOMOUS_MODE semantics (the only non-human approval path)
|
||||
Default **false**: only human-approved proposals execute, only
|
||||
human-approved drafts are sendable/publishable.
|
||||
**true** adds exactly one capability: the executor may auto-approve
|
||||
`proposed` items whose `kind` is in `CAYCE_OPS_AUTONOMOUS_KINDS`, up to
|
||||
`CAYCE_OPS_AUTONOMOUS_DAILY_CAP` per day, recording actor `auto:policy` in the
|
||||
audit log. Everything else still requires a human. Auto-approval never touches
|
||||
mail drafts or content items.
|
||||
*Cost:* two code paths; both audited; the autonomous one is off by default and
|
||||
cap-bounded.
|
||||
|
||||
## D6 — CI workflow files ship now; enforcement gate is `make check` until a runner exists
|
||||
`.gitea/workflows/ci.yml` (Gitea Actions) and `.github/workflows/ci.yml`
|
||||
(identical) run ruff, alembic smoke, pytest, gitleaks on every PR/push. The
|
||||
current host runs no Gitea Actions runner, so until one is provisioned the
|
||||
same gate is enforced locally: `scripts/pre-push` runs `make check` (lint +
|
||||
tests + migration smoke + full-history secret scan) before every push, and the
|
||||
audited commit hash in `AUDIT.md` is the one that passed it.
|
||||
*Cost:* CI results are deferred on the current host; the gate itself is
|
||||
identical to CI.
|
||||
|
||||
## D7 — gitleaks allowlist for test fixtures
|
||||
`tests/` contains invented data (fake Nostr key, fake merchant mail). It is
|
||||
allowlisted in `.gitleaks.toml` so the history scan stays signal-only.
|
||||
*Cost:* a real secret committed under `tests/` would not trip the scanner —
|
||||
compensating control: fixtures are reviewed in PRs, and no fixture ever reads
|
||||
the environment.
|
||||
|
||||
## D8 — Proposal history kept when retiring the grant text
|
||||
The repo began as a grant proposal; the retirement commit replaces it with the
|
||||
project README (no grant references in the tree). The old commits stay — the
|
||||
audit story ("this started as X, the operator mandated Y, here is the diff") is
|
||||
more honest than a rewritten birth.
|
||||
*Cost:* `git log` still shows the word "OpenSats" in old messages. The tree and
|
||||
README carry none.
|
||||
|
||||
## D9 — Executor plugins are in-process registrations
|
||||
An action plugin is a Python function registered in
|
||||
`cayceops.components.executor.plugins.REGISTRY`, mapped by proposal `kind`.
|
||||
`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,45 @@
|
||||
[project]
|
||||
name = "cayce-ops"
|
||||
version = "0.1.0"
|
||||
description = "Cayce autonomous operations stack: scanner, executor, mail-negotiator, publisher, monitor"
|
||||
authors = [{ name = "Cayce", email = "cayce@cayce-co.xyz" }]
|
||||
license = { text = "MIT" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"sqlalchemy==2.0.44",
|
||||
"alembic==1.17.0",
|
||||
"typer==0.20.0",
|
||||
"psycopg[binary]==3.2.13",
|
||||
"pillow==12.0.0",
|
||||
"coincurve==21.0.0",
|
||||
"websockets==15.0.1",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
cayceops = "cayceops.cli:app"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest==9.0.2",
|
||||
"ruff==0.15.1",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["cayceops"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B", "W"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-q"
|
||||
filterwarnings = ["ignore::DeprecationWarning"]
|
||||
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"
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/bin/sh
|
||||
# Pre-push gate: same checks CI would run. Refuses to push if anything fails.
|
||||
set -e
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
make check
|
||||
echo "pre-push gate passed — safe to push"
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Shared pytest fixtures.
|
||||
|
||||
Tests run against a throwaway SQLite database (fast, zero external services).
|
||||
The production path is Postgres via Alembic; ``make migrate-check`` smoke-tests
|
||||
the migration chain on SQLite in CI.
|
||||
All fixture data is invented — never real merchant or account data.
|
||||
"""
|
||||
|
||||
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:")
|
||||
os.environ.setdefault("CAYCE_OPS_AUTONOMOUS_MODE", "false")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db_engine(tmp_path: Path):
|
||||
"""A fresh schema per test, on a file-backed SQLite DB (shared in-process)."""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from cayceops.db import reset_engine
|
||||
from cayceops.models import Base
|
||||
|
||||
url = f"sqlite:///{tmp_path}/test.db"
|
||||
eng = create_engine(url, future=True, connect_args={"check_same_thread": False})
|
||||
Base.metadata.create_all(eng)
|
||||
factory = sessionmaker(bind=eng, expire_on_commit=False, future=True)
|
||||
|
||||
# point the app at the same engine/session factory
|
||||
reset_engine()
|
||||
import cayceops.db as appdb
|
||||
|
||||
appdb._engine = eng
|
||||
appdb._factory = factory
|
||||
yield eng
|
||||
eng.dispose()
|
||||
reset_engine()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sess(db_engine):
|
||||
"""A session bound to the fresh schema (rolls back at test end)."""
|
||||
import cayceops.db as appdb
|
||||
|
||||
s = appdb.session_factory()()
|
||||
yield s
|
||||
s.rollback()
|
||||
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)."""
|
||||
from cayceops.config import Config
|
||||
|
||||
defaults = {
|
||||
"CAYCE_OPS_DATABASE_URL": "sqlite:///:memory:",
|
||||
"CAYCE_OPS_LLM_PROVIDER": "null",
|
||||
"CAYCE_OPS_ENABLE_FACEBOOK": "false",
|
||||
"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):
|
||||
merged = {**defaults, **{k: str(v) for k, v in overrides.items()}}
|
||||
for k, v in merged.items():
|
||||
monkeypatch.setenv(k, v)
|
||||
return Config()
|
||||
|
||||
return _apply
|
||||
@@ -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"
|
||||
@@ -0,0 +1,23 @@
|
||||
"""CLI smoke tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from cayceops import __version__
|
||||
from cayceops.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_version():
|
||||
result = runner.invoke(app, ["version"])
|
||||
assert result.exit_code == 0
|
||||
assert __version__ in result.output
|
||||
|
||||
|
||||
def test_help_lists_components():
|
||||
result = runner.invoke(app, ["--help"])
|
||||
assert result.exit_code == 0
|
||||
for sub in ("scanner", "executor", "maild", "publisher", "monitor", "db"):
|
||||
assert sub in result.output
|
||||
@@ -0,0 +1,242 @@
|
||||
"""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
|
||||
|
||||
import pytest
|
||||
|
||||
from cayceops.components.executor import executor, plugins
|
||||
from cayceops.models import AuditEntry, Proposal, ProposalStatus
|
||||
|
||||
|
||||
def _proposal(kind="content_gap", title="Test item", **kw):
|
||||
return Proposal(kind=kind, title=title, **kw)
|
||||
|
||||
|
||||
def test_approve_requires_proposed_state(cfg, sess, signer):
|
||||
p = _proposal()
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
|
||||
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, "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, "operator", cfg(), sess) == 0
|
||||
assert p.status is ProposalStatus.REJECTED
|
||||
|
||||
|
||||
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()
|
||||
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 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
|
||||
assert acts.count("approve") == 1
|
||||
|
||||
|
||||
def test_autonomy_off_by_default(cfg, sess):
|
||||
p = _proposal(kind="content_gap", title="Never auto-runs")
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
config = cfg() # autonomous_mode False even with kinds configured
|
||||
executor._auto_approve(config, sess)
|
||||
assert p.status is ProposalStatus.PROPOSED
|
||||
|
||||
|
||||
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",
|
||||
)
|
||||
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_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()
|
||||
assert (
|
||||
executor.approve(p.id, "operator", cfg(), sess, signature=signer.proposal(p)) == 0
|
||||
)
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def boom(proposal, cfg, sess):
|
||||
calls["n"] += 1
|
||||
raise RuntimeError("upstream exploded")
|
||||
|
||||
monkeypatch.setitem(plugins.REGISTRY, "affiliate", boom)
|
||||
config = cfg(CAYCE_OPS_MAX_ATTEMPTS="2")
|
||||
|
||||
executor.run(config, sess) # attempt 1 -> back to approved for retry
|
||||
assert p.status is ProposalStatus.APPROVED
|
||||
assert p.attempts == 1
|
||||
|
||||
executor.run(config, sess) # attempt 2 -> failed for good
|
||||
assert p.status is ProposalStatus.FAILED
|
||||
assert p.attempts == 2
|
||||
assert calls["n"] == 2
|
||||
|
||||
errors = sess.query(AuditEntry).filter_by(result="error").all()
|
||||
assert any(a.action == "fail" for a in errors)
|
||||
|
||||
|
||||
def test_http_post_blocked_when_disabled(cfg, sess):
|
||||
p = _proposal(kind="affiliate", title=" webhook target")
|
||||
p.detail = {"webhook_url": "https://hooks.example.org/ingest"}
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
|
||||
config = cfg() # CAYCE_OPS_ENABLE_HTTP_ACTIONS=false
|
||||
with pytest.raises(PermissionError):
|
||||
plugins.http_post(p, config, 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()
|
||||
assert (
|
||||
executor.approve(p.id, "operator", cfg(), sess, signature=signer.proposal(p)) == 0
|
||||
)
|
||||
|
||||
assert executor.run(cfg(), sess) == 0
|
||||
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"
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Schema-level tests: models persist, enums hold, audit helper writes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import select
|
||||
|
||||
from cayceops.models import (
|
||||
AuditEntry,
|
||||
ContentItem,
|
||||
DaemonRun,
|
||||
MailMessage,
|
||||
Proposal,
|
||||
ProposalSource,
|
||||
ProposalStatus,
|
||||
audit,
|
||||
)
|
||||
|
||||
|
||||
def test_proposal_defaults(sess):
|
||||
p = Proposal(kind="affiliate", title="Test merchant program")
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
|
||||
assert p.id is not None
|
||||
assert p.status == ProposalStatus.PROPOSED
|
||||
assert p.source == ProposalSource.SCANNER
|
||||
assert p.attempts == 0
|
||||
assert p.created_at.tzinfo is not None
|
||||
|
||||
|
||||
def test_proposal_status_transitions(sess):
|
||||
p = Proposal(kind="content_gap", title="Guide gap: Rio")
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
|
||||
p.status = ProposalStatus.APPROVED
|
||||
p.approved_by = "operator"
|
||||
p.approved_at = datetime.now(UTC)
|
||||
p.status = ProposalStatus.RUNNING
|
||||
p.status = ProposalStatus.DONE
|
||||
sess.flush()
|
||||
|
||||
loaded = sess.scalar(select(Proposal).where(Proposal.id == p.id))
|
||||
assert loaded.status == ProposalStatus.DONE
|
||||
assert loaded.approved_by == "operator"
|
||||
|
||||
|
||||
def test_dedupe_key_unique(sess):
|
||||
sess.add(Proposal(kind="affiliate", title="A", dedupe_key="k-1"))
|
||||
sess.flush()
|
||||
sess.add(Proposal(kind="affiliate", title="B", dedupe_key="k-1"))
|
||||
try:
|
||||
sess.flush()
|
||||
raised = False
|
||||
except sa.exc.IntegrityError:
|
||||
raised = True
|
||||
sess.rollback()
|
||||
assert raised, "duplicate dedupe_key must be rejected"
|
||||
|
||||
|
||||
def test_audit_helper_writes(sess):
|
||||
p = Proposal(kind="other", title="x")
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
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 == "operator"
|
||||
assert rows[0].proposal_id == p.id
|
||||
|
||||
|
||||
def test_mail_and_content_and_runs(sess):
|
||||
m = MailMessage(
|
||||
direction="in",
|
||||
subject="Welcome to the partner program",
|
||||
classification="approval",
|
||||
confidence=0.9,
|
||||
draft_status="drafted",
|
||||
)
|
||||
c = ContentItem(theme="lightning-abroad", channel="nostr", caption="gm")
|
||||
r = DaemonRun(daemon="scanner", status="ok", detail={"found": 3})
|
||||
sess.add_all([m, c, r])
|
||||
sess.flush()
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""scanner tests — discovery, classification, dedupe. No network (injected fetch)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from cayceops.components.scanner import scanner
|
||||
from cayceops.models import Proposal, ProposalStatus
|
||||
|
||||
RSS = b"""<?xml version="1.0"?>
|
||||
<rss><channel>
|
||||
<item><title>Acme launches affiliate program for hotels</title>
|
||||
<link>https://acme.example.org/affiliates</link>
|
||||
<description>Earn commission on every booking.</description></item>
|
||||
<item><title>Weekly crypto digest</title>
|
||||
<link>https://news.example.org/digest</link>
|
||||
<description>Everything unrelated.</description></item>
|
||||
</channel></rss>"""
|
||||
|
||||
PAGE = b"""<html><head><title>Partners</title></head><body>
|
||||
<a href="https://pay.example.org/partner">Become a payment partner</a>
|
||||
<a href="https://pay.example.org/docs">API docs</a>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
def _fake_fetch(responses):
|
||||
def fetch(url, cfg, timeout=20.0):
|
||||
return responses[url]
|
||||
|
||||
return fetch
|
||||
|
||||
|
||||
SOURCES = [
|
||||
{"type": "rss", "url": "https://feeds.example.org/news.xml"},
|
||||
{"type": "page", "url": "https://pay.example.org/partners"},
|
||||
]
|
||||
|
||||
|
||||
def _cfg(cfg, **kw):
|
||||
return cfg(
|
||||
CAYCE_OPS_SCANNER_SOURCES=json.dumps(SOURCES),
|
||||
CAYCE_OPS_SCANNER_KEYWORDS="affiliate,partner,bitcoin,commission",
|
||||
**kw,
|
||||
)
|
||||
|
||||
|
||||
def test_scan_files_proposals_with_kind_and_score(cfg, sess):
|
||||
config = _cfg(cfg)
|
||||
stats = scanner.scan(config, sess, fetch=_fake_fetch({
|
||||
"https://feeds.example.org/news.xml": RSS,
|
||||
"https://pay.example.org/partners": PAGE,
|
||||
}))
|
||||
|
||||
assert stats["proposed"] == 3 # affiliate item + both page links (pages keep all)
|
||||
proposals = sess.query(Proposal).all()
|
||||
by_title = {p.title: p for p in proposals}
|
||||
|
||||
affiliate = by_title["Acme launches affiliate program for hotels"]
|
||||
assert affiliate.kind == "affiliate"
|
||||
assert affiliate.status == ProposalStatus.PROPOSED
|
||||
assert affiliate.match_score > 0
|
||||
assert affiliate.dedupe_key
|
||||
|
||||
partner = by_title["Become a payment partner"]
|
||||
assert partner.kind == "partnership"
|
||||
|
||||
|
||||
def test_scan_is_idempotent_via_dedupe(cfg, sess):
|
||||
config = _cfg(cfg)
|
||||
scanner.scan(config, sess, fetch=_fake_fetch({
|
||||
"https://feeds.example.org/news.xml": RSS,
|
||||
"https://pay.example.org/partners": PAGE,
|
||||
}))
|
||||
stats2 = scanner.scan(config, sess, fetch=_fake_fetch({
|
||||
"https://feeds.example.org/news.xml": RSS,
|
||||
"https://pay.example.org/partners": PAGE,
|
||||
}))
|
||||
assert stats2["proposed"] == 0
|
||||
assert stats2["skipped_existing"] == 3
|
||||
|
||||
|
||||
def test_scan_filters_keywordless_feed_items(cfg, sess):
|
||||
config = _cfg(cfg)
|
||||
scanner.scan(config, sess, fetch=_fake_fetch({
|
||||
"https://feeds.example.org/news.xml": RSS,
|
||||
"https://pay.example.org/partners": PAGE,
|
||||
}))
|
||||
titles = {p.title for p in sess.query(Proposal).all()}
|
||||
assert "Weekly crypto digest" not in titles # no keyword match in feed -> dropped
|
||||
|
||||
|
||||
def test_scan_records_audit_for_each_proposal(cfg, sess):
|
||||
from cayceops.models import AuditEntry
|
||||
|
||||
config = _cfg(cfg)
|
||||
scanner.scan(config, sess, fetch=_fake_fetch({
|
||||
"https://feeds.example.org/news.xml": RSS,
|
||||
"https://pay.example.org/partners": PAGE,
|
||||
}))
|
||||
acts = [a.action for a in sess.query(AuditEntry).all()]
|
||||
assert acts.count("propose") == 3
|
||||
assert all(a.actor == "daemon:scanner" for a in sess.query(AuditEntry).all())
|
||||
|
||||
|
||||
def test_scan_survives_source_error(cfg, sess):
|
||||
def broken_fetch(url, cfg, timeout=20.0):
|
||||
raise OSError("connection refused")
|
||||
|
||||
config = _cfg(cfg)
|
||||
stats = scanner.scan(config, sess, fetch=broken_fetch)
|
||||
assert stats["proposed"] == 0
|
||||
assert len(stats["source_errors"]) == 2
|
||||
@@ -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
|
||||
@@ -0,0 +1,614 @@
|
||||
version = 1
|
||||
revision = 5
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "alembic"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mako" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6b/45/6f4555f2039f364c3ce31399529dcf48dd60726ff3715ad67f547d87dfd2/alembic-1.17.0.tar.gz", hash = "sha256:4652a0b3e19616b57d652b82bfa5e38bf5dbea0813eed971612671cb9e90c0fe", size = 1975526, upload-time = "2025-10-11T18:40:13.585Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/44/1f/38e29b06bfed7818ebba1f84904afdc8153ef7b6c7e0d8f3bc6643f5989c/alembic-1.17.0-py3-none-any.whl", hash = "sha256:80523bc437d41b35c5db7e525ad9d908f79de65c27d6a5a5eab6df348a352d99", size = 247449, upload-time = "2025-10-11T18:40:16.288Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cayce-ops"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
{ name = "coincurve" },
|
||||
{ name = "pillow" },
|
||||
{ name = "psycopg", extra = ["binary"] },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "typer" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "alembic", specifier = "==1.17.0" },
|
||||
{ name = "coincurve", specifier = "==21.0.0" },
|
||||
{ name = "pillow", specifier = "==12.0.0" },
|
||||
{ name = "psycopg", extras = ["binary"], specifier = "==3.2.13" },
|
||||
{ name = "sqlalchemy", specifier = "==2.0.44" },
|
||||
{ name = "typer", specifier = "==0.20.0" },
|
||||
{ name = "websockets", specifier = "==15.0.1" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pytest", specifier = "==9.0.2" },
|
||||
{ name = "ruff", specifier = "==0.15.1" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coincurve"
|
||||
version = "21.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/a2/f2a38eb05b747ed3e54e1be33be339d4a14c1f5cc6a6e2b342b5e8160d51/coincurve-21.0.0.tar.gz", hash = "sha256:8b37ce4265a82bebf0e796e21a769e56fdbf8420411ccbe3fafee4ed75b6a6e5", size = 128986, upload-time = "2025-03-08T15:31:24.266Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/61/a2d9e109f99b6f5e65e653ac998b0944c5b82c568ac142fcbb381a4803be/coincurve-21.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f60ad56113f08e8c540bb89f4f35f44d434311433195ffff22893ccfa335070c", size = 1391948, upload-time = "2025-03-08T15:30:32.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/5a/2da75ee00a722ef1fa068ada3bc34c564595ead86fef573434e2f0cb0a5c/coincurve-21.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1cb1cd19fb0be22e68ecb60ad950b41f18b9b02eebeffaac9391dc31f74f08f2", size = 1384958, upload-time = "2025-03-08T15:30:34.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/50/6bf0bf7e8a9a9dd419ecc1e479dcb9fbfe657029276ad703806a25a2bef2/coincurve-21.0.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05d7e255a697b3475d7ae7640d3bdef3d5bc98ce9ce08dd387f780696606c33b", size = 1606576, upload-time = "2025-03-08T15:30:36.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/ab/9e89908fdd09ad522938085587aaa821b022f4def16c286c5580cfc85811/coincurve-21.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a366c314df7217e3357bb8c7d2cda540b0bce180705f7a0ce2d1d9e28f62ad4", size = 1613642, upload-time = "2025-03-08T15:30:38.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/75/050b6fd08978de85a7b480f0f220ab6a30967c0910119f3096a8dd40befc/coincurve-21.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b04778b75339c6e46deb9ae3bcfc2250fbe48d1324153e4310fc4996e135715", size = 1616974, upload-time = "2025-03-08T15:30:39.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/62/2740ba0cafebf45708633635fecadcbe582d7a3ed1ce8b4637921feceaf8/coincurve-21.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8efcbdcd50cc219989a2662e6c6552f455efc000a15dd6ab3ebf4f9b187f41a3", size = 1644133, upload-time = "2025-03-08T15:30:41.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/14/1f27c3048c4084fa85ef65f42a4ca631f2b184336e6d9446fecec20e0a7f/coincurve-21.0.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6df44b4e3b7acdc1453ade52a52e3f8a5b53ecdd5a06bd200f1ec4b4e250f7d9", size = 1619918, upload-time = "2025-03-08T15:30:43.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/22/7ec3ec4c8e7764daa25767d6674cb5741ea2d9b39ff758e9918d22a4b49b/coincurve-21.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bcc0831f07cb75b91c35c13b1362e7b9dc76c376b27d01ff577bec52005e22a8", size = 1645797, upload-time = "2025-03-08T15:30:44.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/60/87982b7499943ab12605df7b14f6001fff331aca0881b260682461e2309d/coincurve-21.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:5dd7b66b83b143f3ad3861a68fc0279167a0bae44fe3931547400b7a200e90b1", size = 1329255, upload-time = "2025-03-08T15:30:46.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/c0/65b60b371579570931daca8a3f67debfc1482908b8ed03432297274a27da/coincurve-21.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:78dbe439e8cb22389956a4f2f2312813b4bd0531a0b691d4f8e868c7b366555d", size = 1325973, upload-time = "2025-03-08T15:30:48.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/40/cce55adaec37a588eb24b67da8eb68926546458e12ed2c4c2a21deb93d4c/coincurve-21.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9df5ceb5de603b9caf270629996710cf5ed1d43346887bc3895a11258644b65b", size = 1391762, upload-time = "2025-03-08T15:30:49.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/7a/628a30281d246ce98aea56592e0c8e79b03a93ee8b85d688db3388130c2d/coincurve-21.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:154467858d23c48f9e5ab380433bc2625027b50617400e2984cc16f5799ab601", size = 1384921, upload-time = "2025-03-08T15:30:51.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/cc/719c5da31e6ba07e438abcf962f7a365eb69a06a0621ca4f2a484f344e09/coincurve-21.0.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f57f07c44d14d939bed289cdeaba4acb986bba9f729a796b6a341eab1661eedc", size = 1606559, upload-time = "2025-03-08T15:30:53.218Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/ee/dd14237013d732e7fc3248c0c33a1d36b88b5378dfa3e624a50a23fb6f19/coincurve-21.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fb03e3a388a93d31ed56a442bdec7983ea404490e21e12af76fb1dbf097082a", size = 1613684, upload-time = "2025-03-08T15:30:55.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/05/eaa7f36a03376ced1c19e0cb563341cc83fe48f5734b2effe8f16d0ee0ab/coincurve-21.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d09ba4fd9d26b00b06645fcd768c5ad44832a1fa847ebe8fb44970d3204c3cb7", size = 1617001, upload-time = "2025-03-08T15:30:57.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/32/fc75f1dd914ac95eb2704425c7ca1a9f509f982e15d05e0ca895b9e6ea9c/coincurve-21.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1a1e7ee73bc1b3bcf14c7b0d1f44e6485785d3b53ef7b16173c36d3cefa57f93", size = 1643924, upload-time = "2025-03-08T15:30:58.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/4b/8c6e65b5755e26fc02077803879747615c1c327047328d1784bccb4ff4c3/coincurve-21.0.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ad05952b6edc593a874df61f1bc79db99d716ec48ba4302d699e14a419fe6f51", size = 1619964, upload-time = "2025-03-08T15:31:00.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/bc/d0a743305ff9fa26e72b4c77b534d5958ec8030b3772555a7172a0c134e5/coincurve-21.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d2bf350ced38b73db9efa1ff8fd16a67a1cb35abb2dda50d89661b531f03fd3", size = 1645526, upload-time = "2025-03-08T15:31:01.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/44/ab082e2dc8c9a45774f1bb9961f58b43c0882b866f5c469ead932d45a35d/coincurve-21.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:54d9500c56d5499375e579c3917472ffcf804c3584dd79052a79974280985c74", size = 1329285, upload-time = "2025-03-08T15:31:03.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/94/407f6fc811310f15b1fc7255f436f6a9040854213beeb10093f56b5b7fd3/coincurve-21.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:773917f075ec4b94a7a742637d303a3a082616a115c36568eb6c873a8d950d18", size = 1326027, upload-time = "2025-03-08T15:31:05.318Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.5.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3e/6e/0091f175ccd02b02bc8811bbcbcc6ac2e980be116e3b2f7a736ca322bf84/greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575", size = 207653, upload-time = "2026-09-14T15:42:51.806Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/72/18/3fc6d951466ae9a2a688edcddde3b2e388da0a8244e0caf7117bbeb0eb95/greenlet-3.5.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:a5876d0a60355af98d535c47f6cd6eb0f8a432396dab26845d380b92f8412422", size = 295668, upload-time = "2026-09-14T14:22:33.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/89/366d2af5061eeefa5012f510d95a99c8620dcc457609838db4d538820318/greenlet-3.5.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e85880b538e59a59f55117b81f208a6660ad5ac328aad9305f812d9b8bc67a0f", size = 611700, upload-time = "2026-09-14T15:12:01.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/1c/07f133f865fd58ae593dd2bbec3144acaee9b04ffe2eb48c6e121747ceef/greenlet-3.5.6-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f0ba7c2a329d650628f4c8572fd1db29f0a59dd70a3e3e0710dcf18a35cce9d8", size = 624223, upload-time = "2026-09-14T15:20:42.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/6a/1594f3869c57c149abdb380492529e04d4c0229b5e4d79572c5bd0aaa673/greenlet-3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:975736b002ed080d124cf81a79cb7e05cb26d6b3f5c7a7b651c0fcce70353aa1", size = 621404, upload-time = "2026-09-14T14:35:59.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/f5/33e5c9e48178b9259fd000f8f45caa4a65036f65d3d0c06a602f570f025d/greenlet-3.5.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0616b8f878098c5681fd8f0dc92d887551717402342a70f0abcbfea5f5ad8a44", size = 1584998, upload-time = "2026-09-14T15:10:06.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/31/9b4e140bc24d0ad7927ebd651f5608b0acc2334d061748c3b6ad19085cfa/greenlet-3.5.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3dbb4596a6a4e5d47121a33ff20533a81e60f302d9e67b69909a8bc21a43f0a7", size = 1647568, upload-time = "2026-09-14T14:35:49.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/71/d79f1791f824f8ff15c2978746640467ae932a2365e0201069f7f272395f/greenlet-3.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:7ac4abb3877c43af320392c664774eef6fa2cc063c79a55fc02d844a3cbe7395", size = 324203, upload-time = "2026-09-14T14:22:54.504Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/af/42aca4d56e8cb321912203069d8d34734cb288222f10ad2ae102718cc577/greenlet-3.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:301102a49120b095e72a7838792b41233975fc1c155daec6d98f81c00c9280e0", size = 308310, upload-time = "2026-09-14T14:24:03.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/a1/e720a38852366c589e1a46cf570b886507ad2cf591050c203365638baab0/greenlet-3.5.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f96f0e30b5a95c7631b12bfe214cbc90ec8fe8cfa36920596c10514a65743519", size = 294627, upload-time = "2026-09-14T14:24:40.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/c3/58187858df41354a11e6a55b421e7af9059798abdab3a384cc51b8567c38/greenlet-3.5.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c75116c9de79949de23006e2d9b35ee82874c594fcf5c0311b439acaa14b8441", size = 614356, upload-time = "2026-09-14T15:12:03.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/b9/3a7e67d5f05c9760b1ad411fa52264bd69cc08e22a2ebfb4018b90628ced/greenlet-3.5.6-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cad5782f93f7f738b62c6527b6f32a60694d924029f299a8b524758cfa53d815", size = 626756, upload-time = "2026-09-14T15:20:44.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/cb/ab0c123c514ed4e94c0dc9ee2e86362633e6b998cfc05de7fc9ac2eb9690/greenlet-3.5.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f98e8215e172f567ce80eeaed9107fb4d32b6c44f26983d9b8334658136a205a", size = 623779, upload-time = "2026-09-14T14:36:01.104Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/26/fda8a5a06e7073333ccb038133c5893b9e0c4fe29d5992a17e83c241bc6e/greenlet-3.5.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df19e2d0b1620039af5102563fbd96e8938c7f5c3f5828528d641d9fc585525e", size = 1584930, upload-time = "2026-09-14T15:10:08.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/37/50f8813163148d6234e08b23dcad6a9e37f01d148c8ec976e4c44ea2d918/greenlet-3.5.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06c0e933290fba8ffe53ead4ae1b8044b0e9754b75cebf381aa2bc3e50d82fac", size = 1647590, upload-time = "2026-09-14T14:35:51.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/da/b7669b09586365654083a62bd0724cf06cb74bd5085a15cdd161271f992f/greenlet-3.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:5b602b4201b965a8354d74e232364a66ff243dd142e350d035f46169bb36e13d", size = 324086, upload-time = "2026-09-14T14:23:48.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/5d/c9663cfe84a2a9e0aa96f066f5b0594c227ea4c647511e087e2e11d4ac0a/greenlet-3.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:876077e7ebb8c84ed068e2b23d4c62ebb010d60df84b9591af1be2f39010ffb2", size = 308211, upload-time = "2026-09-14T14:28:01.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/c0/d254544ae2b8bdd311aef000fafc02828c2771b17d994b3075620ea7cc6e/greenlet-3.5.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cddea1b8339451c2fb3388e138347b6126744f33b611bdb55b7357361cfef46", size = 295221, upload-time = "2026-09-14T14:25:11.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/18/eb54be16b9cc3971e09ca5b73334e1b8c804a4630d9addaaf218a4fe300f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c59acfa8eb73a1e0d484392dc002bdf001fd4ce73394e0132df3d1ab6093d7cb", size = 660992, upload-time = "2026-09-14T15:12:04.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/b4/e193efe65671dcf294bc51fcc59efb52d154adf8612c4ea016da0d2c486c/greenlet-3.5.6-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3b4a01c6da07ef9f80d4fe8933b994bc99747bcea3eab0330a9c34d3c12655b", size = 673428, upload-time = "2026-09-14T15:20:45.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/ac/28fa7a9e50f2859466214c4ac584d776db52c1604ad4dd158960a5af2a1f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a09d59bef1db94f384b5bcc2d523694d338f3df6b757aeeaf7baca5d0c0be88", size = 670773, upload-time = "2026-09-14T14:36:02.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/cd/fb7d6cdd86ff3427c1494854f0e35437eba05142be91f530f6da75e09e19/greenlet-3.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b7c73d1cef3d9ae963e9ff03f6222df43efbb9054ffd2f1969c935b7fc84c02", size = 1631900, upload-time = "2026-09-14T15:10:09.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/40/143bdbb20a516628cb15074ae52ed17d850b450292609c7a6fccac6dbece/greenlet-3.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8b27df301f56e3b3d2298095c8f7d6b68f2521f6b1693e901fa039bdbae34424", size = 1693740, upload-time = "2026-09-14T14:35:52.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/9e/019642432e6ae283301df1361227d47610709d2dc69a38f95edef266d713/greenlet-3.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:f8f0bd690e1a41294ac87905e8121c81a3761ec2583c768f13467428606c8c7a", size = 327473, upload-time = "2026-09-14T14:28:12.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/7f/8aafc7bf70c948786dba7221d0dc0838e5329bebc6d434ef2208b4f0e760/greenlet-3.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:8cda13494d86a4f12429641117cb6ac4bbbc9c30a33f711f7d3a2e5fbe4b0b7e", size = 311095, upload-time = "2026-09-14T14:28:00.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/7e/7a205688a5b3074933b18a906608d46d106e9a79d776bdab5a4abf4b4feb/greenlet-3.5.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:97c5a53e8c1754df58e73f047a99e287d4da1bdfe64b0072fb25c87000897951", size = 305352, upload-time = "2026-09-14T14:21:31.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/cb/9c4a57a9d9dd0256e20b8f7f4f06554c2c92badebf0ab73ce344321b78b9/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea4427d1ffdb3b523d7daa6712038428a4c16c450b9777bdd1221cfee0eab49", size = 672671, upload-time = "2026-09-14T15:12:06.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/52/c6729681ebbd298f4decd28746815acc8a0b0a0fde21d2df33776fd4d042/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73a29b5ba642e35433166a03a3e02935e7238c4b3467fbd77523b99edea23e5b", size = 679489, upload-time = "2026-09-14T15:20:47.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/c5/2b6c721ba8b8963da42d5a0f57f25b8aaeb1fe9bdd156875e57f3be648a2/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e70b033aba8ed47e2ac9b5d0d2157b05a34fbfa30a241400aef4118902cdc", size = 676608, upload-time = "2026-09-14T14:36:03.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/04/0d018e0d05bcdde19a0fcb907834155f1fc853a9bedd3f3f5e6acadcae19/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca80a49b53ed1d22f7282da7255f7bb2fd1935fd0f623d8613fda38745f18961", size = 1641479, upload-time = "2026-09-14T15:10:11.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/bb/f02ef9073919158f6403fe3701d4ed4403d646720e7201dfc6e9d264bac3/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:916f92f2a8db10508f739d0b5e00b83defe5d1115a997c54532a6d7cf8c95404", size = 1698758, upload-time = "2026-09-14T14:35:54.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/a5/1f48fe647473a2dcccfd1839b2ff2c78eb57009be776b4da071e901c9bff/greenlet-3.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:886bcf1870af74c32bc310fd00a6b803445e17e51b7d5a107c7b35c0f362cc16", size = 331574, upload-time = "2026-09-14T14:27:18.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/72/3882855a75838faeb54a58aeef4fd77d20b2a86d4bad570c70d41b565dcf/greenlet-3.5.6-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3ac3494c381dab876cad7d0b22f3a722f3e0c8deb3a65b9e7f35ad7f58b8fcb3", size = 295926, upload-time = "2026-09-14T14:27:21.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/1f/be4d957d8a9b90bcbe8db206548a42134d96222d43e5ed3fc4708fb6e24b/greenlet-3.5.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:602024dae6d77e161f4b89491b62ca1d4f19949d79d47b2db057e476d21179d6", size = 666910, upload-time = "2026-09-14T15:12:07.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/af/60d62571a7d6de961e4ce7625d6c2faf359345659fc782d2cdf517c34577/greenlet-3.5.6-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8e63209c3e1e828ee6a457529b4a6d8b05d050fe0ae03a7ae49e967c5d312e0", size = 677415, upload-time = "2026-09-14T15:20:48.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/16/ac9e547b611539aaed1870eb1d6ddc57abdd5924b3a99bb9b5f0b44176b8/greenlet-3.5.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccadce0130fd813ec86ebfe969a6c58b42acc1d0fe55a47525375b740e07b605", size = 675927, upload-time = "2026-09-14T14:36:05.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/b1/b7ba08d6431121741f1d30be0d5d292e76873325179a63586cd9217b62f6/greenlet-3.5.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9297fb9c39b9a2c039dbcd306c410bd6906b95244dec3bba4318d36c718c164c", size = 1637236, upload-time = "2026-09-14T15:10:12.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/c5/3b1cbc68f0c082022fc8717f7fe4b8b13b8d583c52352be37f4e9f55bcd2/greenlet-3.5.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b374e79ffa7511afc11773aef40a4ccea6191fba1c856ea2f9c56738dca69d7a", size = 1698526, upload-time = "2026-09-14T14:35:56.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/56/12941ed2711400451c89d544e10f831800a2770f19dd55eac8f0f7f2003b/greenlet-3.5.6-cp315-cp315-win_amd64.whl", hash = "sha256:7969bffa322c097bd46ae595ada6a931cefda613f18ba64587e9cff4cb320756", size = 327739, upload-time = "2026-09-14T14:23:55.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/3b/576b9ed5ac929252e340cf60b4bcb6a8515350dc20797064b1922dc4ea75/greenlet-3.5.6-cp315-cp315-win_arm64.whl", hash = "sha256:8dba0129b93e7091dfefaf4cf7000172741bff7f47bf6326fcf17f32fbb54d6b", size = 311715, upload-time = "2026-09-14T14:28:25.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/c2/86cfc5555a98e12b86966ddbd24fd39af32f71f2f785c6595b7feb2db156/greenlet-3.5.6-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:de3de000d459402cda015068fd135aa50c0bf6f2477a80d4da1e646f123b4e78", size = 306244, upload-time = "2026-09-14T14:27:57.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/6d/83ffc9d05a75a80ab3a7595dbb1d9604e5d4fc2996d73a8ae2dbd1284900/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45663c01a4de48b9a64a2ee1509d92d1dfd3afb02b2ccfc9333029d11aef996a", size = 676578, upload-time = "2026-09-14T15:12:09.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/d6/c2cf684810e5caded075970aaadea654ecb58b8382b9aecf1d231b936894/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3deccbb57a481e3a408fe61cdfd5c13e0678fc0a30fdd09597917ca87b4be877", size = 684203, upload-time = "2026-09-14T15:20:50.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/19/00e1bee5d2af890dc8f400b54d0b0f9b489965f92bc12b407ff72cc6f469/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311018b46472fb26ee85870847fb89eb64cc8aaddb617400789d87076f7cfeec", size = 681253, upload-time = "2026-09-14T14:36:06.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/58/c9275fd0ca195d1d3402931bcce8cfcc74726ff76efb1883d229e6e1a3d7/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7f924a5a9d5890649566f2f6682e0d8ad8ca23028bacffbbac36dbd7fd680176", size = 1645988, upload-time = "2026-09-14T15:10:13.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/36/b35747582fa4f1a5453f8f3002405dbac788e450cec7674dc2d204b6ccb5/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:de9923832f2d8c1a5ecd8d7260465a6ca5a86888a0d129e3bd5cf0406d2fc5bf", size = 1702383, upload-time = "2026-09-14T14:35:58.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/69/6ec22ac9351e474d2a134d0ff9400dc80362d1c20f0721088ffffdfc205b/greenlet-3.5.6-cp315-cp315t-win_amd64.whl", hash = "sha256:2ab5f42ac6c238eb71770715e6e909ad9a1a92b6c681ccb64cd5a0f07edb953f", size = 331845, upload-time = "2026-09-14T14:27:41.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/cf/697c051fd534e223461fb8b523890e21a24eeca229cd50624cff6f02fabd/greenlet-3.5.6-cp315-cp315t-win_arm64.whl", hash = "sha256:f9fe868463ec7e1363733af77e38a5fda3e9b63940337048c945d69e0c80ff24", size = 315070, upload-time = "2026-09-14T14:22:21.476Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mako"
|
||||
version = "1.4.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/09/e07c4b5579a79f4b16f8d4f29f6c54514ac787c4ad506b8c4f28a0e6b0bf/mako-1.4.3.tar.gz", hash = "sha256:cd6537fe88d5fec315c55c2f8529bc4ce7a9a352ad7db3eeaa6a66e2dd4ec37a", size = 412799, upload-time = "2026-09-22T20:54:31.509Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/a0/053d6af3e8f871e0073b4a36732d9e65be77a72e5434c31b94f6af78a6bb/mako-1.4.3-py3-none-any.whl", hash = "sha256:723296007c870bfd6b3f0c3230dba7198096e5269297ebf5e4eff9e7ffa39d4f", size = 80164, upload-time = "2026-09-22T20:54:33.128Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "4.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mdurl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/09/4c59d56b8461ae8eb0d8ba34bb25b7e618547044679d58a82ef9b2479fc1/markupsafe-3.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6", size = 11658, upload-time = "2026-10-02T23:04:51.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/f0/d6613774d86fbf6d145751d43c59875e47a6f9f17daee0aef173bd36d90e/markupsafe-3.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f", size = 12050, upload-time = "2026-10-02T23:04:52.931Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/f2/8f18e0b806eb13c1f8d07d917a720831ead54253a6dec011fbc78098a6f8/markupsafe-3.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b", size = 24363, upload-time = "2026-10-02T23:04:53.895Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/ce/fa07dbe8a5675558fa36dea033e19995bc783de2dec5f540ccb9030b06aa/markupsafe-3.0.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df", size = 28525, upload-time = "2026-10-02T23:04:54.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/40/be87c01f3868ec217f8a2015089d71c22c8c5a75324822e5ed1cdd87210d/markupsafe-3.0.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c", size = 24733, upload-time = "2026-10-02T23:04:56.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581", size = 22985, upload-time = "2026-10-02T23:04:57.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/fc/e91352bb08c6a59da3ef0909d457bf95a5f5908fbf151b30a06d9dbcfbb4/markupsafe-3.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77", size = 22001, upload-time = "2026-10-02T23:04:58.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/f8/bffee5e7d2a3deb59748a797650a48af7e672025cf641a79344a771ad106/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c", size = 23793, upload-time = "2026-10-02T23:04:59.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/59/b853d6628ecb4d658e1d637224846d5e9bb4adf4f8df97f3be9f29dce2ec/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749", size = 22640, upload-time = "2026-10-02T23:05:00.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/b2/1506df394f0f075797c418d0301498f49e43be194e3ffcb49e6fe6ccf022/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed", size = 24074, upload-time = "2026-10-02T23:05:01.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/81/5ed69cda630ac69ef60d06c09ba5a7f84ff66a2e28cf986fd5614ab3c6e6/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786", size = 21563, upload-time = "2026-10-02T23:05:03.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/fe/fb1e79be0fea60aa32602ebefc9c35a82bb42b4df157285ab7dfec12341a/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e", size = 23048, upload-time = "2026-10-02T23:05:04.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/52/7632a53360671a9b750cdbabaf9cdd89f18b42248b8e4cb42c0b0296e459/markupsafe-3.0.4-cp312-cp312-win32.whl", hash = "sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237", size = 14108, upload-time = "2026-10-02T23:05:05.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/bf/62495e180b7000aaf30000fff849e933f74264638057176cf46852500adc/markupsafe-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7", size = 14303, upload-time = "2026-10-02T23:05:06.538Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/18/4bc5ba32499e87bb2b0ef5b3a9bb9c00a131fa961ddf0be548cb550f548b/markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1", size = 12786, upload-time = "2026-10-02T23:05:08.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/6f/17f0c099bf25f3e31e63cc19244d9f6af861a9a4ab778c203997903cfdd0/markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1", size = 12510, upload-time = "2026-10-02T23:05:09.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/af/1a141081b905036ee904ec4bd945e1f70b4e1b32d33c4e59e8cf1d58b247/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96", size = 11583, upload-time = "2026-10-02T23:05:10.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/0a/a89385ae590232622a03e091805cff12f24fabe6c11e0e8bae096cece81c/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148", size = 11801, upload-time = "2026-10-02T23:05:11.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/85/ea548dc013962eb73653124bc595635fbf9e0fa41d1f181a967ccb784dfb/markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e", size = 11443, upload-time = "2026-10-02T23:05:12.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/72/15f2e5ec9cf2eb00d5cdfe968d94e4156a7bd7303832c3f3b2c403a36839/markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248", size = 11665, upload-time = "2026-10-02T23:05:13.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/e0/4030bea613677e333c8a2c901fd405055f657f9d06acba5b7357984b6ef7/markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72", size = 12061, upload-time = "2026-10-02T23:05:14.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/a5/28b76a7449eb702966b88bef599e2360b411fbb3afeee8fe560939be06ec/markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2", size = 24395, upload-time = "2026-10-02T23:05:15.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/6c/21232811afc3a063b5e934b1ae2efda52f46154ec382f585149c020e61fe/markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85", size = 27183, upload-time = "2026-10-02T23:05:16.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/38/6ccdfa5b59049cb36fb80cbc80aee9cf1fc9bb77d1335ad435f2070b08cf/markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde", size = 24772, upload-time = "2026-10-02T23:05:18.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/e0/cec6865dfe88cb48fedd4b20aed6af5158e41092adcbf3e028bcc6ec2108/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6", size = 23017, upload-time = "2026-10-02T23:05:19.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/76/6ed4940bb7648a9aac457c14f870cfdd5105f139a0fb1f29cd61fafa47d1/markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f", size = 22036, upload-time = "2026-10-02T23:05:20.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/4f/ed476226d4fe46a09090a36025bf319296810028df55eb12f1253b540f3a/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39", size = 23786, upload-time = "2026-10-02T23:05:21.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/35/66ff30450e35ef5fba9ebc930c9411747e537fd9447b65e44f5007e2b84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee", size = 22672, upload-time = "2026-10-02T23:05:22.922Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/0b/72f45ce4b4efcbca4b80cf1b06703eff0be8d37e82abb78f66c85a7ead1e/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2", size = 24107, upload-time = "2026-10-02T23:05:24.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/03/71776e5fdcba04614b384cc102e8a4198208579d896fd1394cb7cb9aa900/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46", size = 21615, upload-time = "2026-10-02T23:05:25.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/5f/801ce02a02e7aee0f784b1ec7843026178f6adeb9c93ac67eb1992a9a84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17", size = 23076, upload-time = "2026-10-02T23:05:26.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/85/c43776625428f3bb4a61e8633940400e3efe6409e3c6f5bff26de5e45618/markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0", size = 14118, upload-time = "2026-10-02T23:05:27.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/36/163da64de88a13db79214ef75fa041be7fa13bdb42261cf5b7484de14bfb/markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5", size = 14313, upload-time = "2026-10-02T23:05:28.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/a8/9b662783ffaa1149221432a923cee562f78b9cbbb8baa3df9b3753e63e1e/markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc", size = 14189, upload-time = "2026-10-02T23:05:29.917Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/c3/a944f3b0df22bd129e96915b9f4e98d2eeca6516687d7618304a966c3c74/markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed", size = 12787, upload-time = "2026-10-02T23:05:30.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59", size = 12525, upload-time = "2026-10-02T23:05:32.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/8f/168ba80e532dd6a93f96f8f706f1ad41d7990b6e1aeedc1cc0d211a33497/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453", size = 11589, upload-time = "2026-10-02T23:05:33.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/b3/aa2c95a574d3af39403a469b295886eb9b6d448da568cbebb5a2cbfdc2e5/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b", size = 11801, upload-time = "2026-10-02T23:05:34.315Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/d0/34b810107d83840e768bf485de795893ebbae35b26ab061b487adfa0a692/markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6", size = 11447, upload-time = "2026-10-02T23:05:35.302Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/ab/2f8488f0f817a39fca068d2b17daf446bf5cdb3eae28c3720af534d873b4/markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634", size = 11722, upload-time = "2026-10-02T23:05:36.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f", size = 12061, upload-time = "2026-10-02T23:05:37.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9", size = 24434, upload-time = "2026-10-02T23:05:38.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f", size = 27183, upload-time = "2026-10-02T23:05:39.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c", size = 24813, upload-time = "2026-10-02T23:05:40.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300", size = 23049, upload-time = "2026-10-02T23:05:41.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0", size = 22088, upload-time = "2026-10-02T23:05:43.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/8a/84d3582fc1f0d5bd466cdf2eebf175e172158a6e70701aacec1de1b35430/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977", size = 23846, upload-time = "2026-10-02T23:05:44.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7", size = 22674, upload-time = "2026-10-02T23:05:45.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17", size = 24104, upload-time = "2026-10-02T23:05:46.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c", size = 21637, upload-time = "2026-10-02T23:05:47.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4", size = 23081, upload-time = "2026-10-02T23:05:48.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272, upload-time = "2026-10-02T23:05:49.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501, upload-time = "2026-10-02T23:05:50.788Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364, upload-time = "2026-10-02T23:05:51.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/31/87ce42159aae2163cf3bbbd0c44bc87780510eecab1ea3859099aed95dcb/markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2", size = 11759, upload-time = "2026-10-02T23:05:52.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/53/b047207eeb7752e960aca3eb1df5fb7eefa7dd4c62ac49bb156456c8a702/markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977", size = 12096, upload-time = "2026-10-02T23:05:54.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/51/4326c88a13c7b755657d44b4bb986f8c3d9843ecba7e22d98661d87f9a57/markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289", size = 25699, upload-time = "2026-10-02T23:05:55.15Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe", size = 27804, upload-time = "2026-10-02T23:05:56.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a", size = 25912, upload-time = "2026-10-02T23:05:57.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733", size = 23623, upload-time = "2026-10-02T23:05:58.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34", size = 23301, upload-time = "2026-10-02T23:05:59.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/c8/39eadc6c5b14c9c7679bfb98f4d4c6a97863b5beb91839aca4d2d6e16e55/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978", size = 24950, upload-time = "2026-10-02T23:06:01.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc", size = 23330, upload-time = "2026-10-02T23:06:02.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc", size = 25239, upload-time = "2026-10-02T23:06:03.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932", size = 22767, upload-time = "2026-10-02T23:06:04.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6", size = 23668, upload-time = "2026-10-02T23:06:05.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301, upload-time = "2026-10-02T23:06:07.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526, upload-time = "2026-10-02T23:06:08.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65", size = 12791, upload-time = "2026-10-02T23:06:10.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163", size = 12527, upload-time = "2026-10-02T23:06:11.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92", size = 11602, upload-time = "2026-10-02T23:06:13.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a", size = 11817, upload-time = "2026-10-02T23:06:14.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429", size = 11446, upload-time = "2026-10-02T23:06:15.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8", size = 11719, upload-time = "2026-10-02T23:06:16.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97", size = 12075, upload-time = "2026-10-02T23:06:17.855Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b", size = 24656, upload-time = "2026-10-02T23:06:18.963Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/18/23997d4c65b355da6390d61cd56e0ab3befd6ba8dda25cb40c602bd0fa6b/markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9", size = 27737, upload-time = "2026-10-02T23:06:20.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/36/35998dead3c6af88c38265a56e58100211f036234ab88eb2283fd4cbce44/markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653", size = 25003, upload-time = "2026-10-02T23:06:21.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369", size = 23111, upload-time = "2026-10-02T23:06:22.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19", size = 22403, upload-time = "2026-10-02T23:06:23.785Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e", size = 24083, upload-time = "2026-10-02T23:06:25.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811", size = 22931, upload-time = "2026-10-02T23:06:26.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea", size = 24299, upload-time = "2026-10-02T23:06:27.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916", size = 21948, upload-time = "2026-10-02T23:06:29.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741", size = 23150, upload-time = "2026-10-02T23:06:30.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b", size = 14269, upload-time = "2026-10-02T23:06:31.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214", size = 14496, upload-time = "2026-10-02T23:06:33.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67", size = 14361, upload-time = "2026-10-02T23:06:34.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad", size = 11760, upload-time = "2026-10-02T23:06:35.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99", size = 12108, upload-time = "2026-10-02T23:06:37.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/d4/b56429313aee5fd59b079c3df5615299959e25e7113eb6d8caadbdd7d38a/markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002", size = 25762, upload-time = "2026-10-02T23:06:38.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/f5/34c181e891aa4f7d59c918584672e0c5eb7fffe76c1387d1246008bf4081/markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e", size = 28400, upload-time = "2026-10-02T23:06:39.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/b5/ad14694fd0ac9a5ce30bc6498f2999378f418583dd1679cca5a1b512957e/markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c", size = 25946, upload-time = "2026-10-02T23:06:41.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/a8/26b606445387d0ceb1eb1f21840094b84e4e3c3c3983d80d10b89823b490/markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8", size = 23634, upload-time = "2026-10-02T23:06:42.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe", size = 23488, upload-time = "2026-10-02T23:06:44.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2", size = 25037, upload-time = "2026-10-02T23:06:45.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38", size = 23543, upload-time = "2026-10-02T23:06:47.264Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494", size = 25282, upload-time = "2026-10-02T23:06:48.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d", size = 22993, upload-time = "2026-10-02T23:06:50.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894", size = 23712, upload-time = "2026-10-02T23:06:51.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78", size = 14300, upload-time = "2026-10-02T23:06:53.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c", size = 14522, upload-time = "2026-10-02T23:06:54.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417, upload-time = "2026-10-02T23:06:56.246Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdurl"
|
||||
version = "0.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psycopg"
|
||||
version = "3.2.13"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/44/05/d4a05988f15fcf90e0088c735b1f2fc04a30b7fc65461d6ec278f5f2f17a/psycopg-3.2.13.tar.gz", hash = "sha256:309adaeda61d44556046ec9a83a93f42bbe5310120b1995f3af49ab6d9f13c1d", size = 160626, upload-time = "2025-11-21T22:34:32.328Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/14/f2724bd1986158a348316e86fdd0837a838b14a711df3f00e47fba597447/psycopg-3.2.13-py3-none-any.whl", hash = "sha256:a481374514f2da627157f767a9336705ebefe93ea7a0522a6cbacba165da179a", size = 206797, upload-time = "2025-11-21T22:29:39.733Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
binary = [
|
||||
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psycopg-binary"
|
||||
version = "3.2.13"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/9e/f90243b3d0d007a89989b013b0eb3e78ac929fed4eb40a2b317452abafe1/psycopg_binary-3.2.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:223fc610a80bbc4355ad3c9952d468a18bb5cd7065846a8c275f100d80cd4004", size = 3996285, upload-time = "2025-11-21T22:31:08.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/42/7d55f515ee3e2ced5ff9bc493fb2308f5187686b6d9583cd6a9c880d2053/psycopg_binary-3.2.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67f06a68d68b4621b6a411f9e583df876977afa06b1ba270b1b347d40aa93fc", size = 4070567, upload-time = "2025-11-21T22:31:12.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/a8/ead4de04d8cf5f35119a75a8dd92fa4a2ec8a309b1aa58855f64616c03d7/psycopg_binary-3.2.13-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:082579f2ae41bdabe20c82810810f3e290ac2206cccf0cb41cf36b3218f53b3c", size = 4616833, upload-time = "2025-11-21T22:31:16.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/2e/4af6ab69ade7d67d31296f88c79c322a3522564e30b3f1458f19e74d67c3/psycopg_binary-3.2.13-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:ff7df7bd8ec2c805f3a4896b8ade971139af0f9f8cf45d05014ac71fe54887be", size = 4711710, upload-time = "2025-11-21T22:31:22.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/31/bdbd6b2264bb7ae5fe8b775c5524da73329d8888c6137fd8b050ff9cabbc/psycopg_binary-3.2.13-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f1189dc78553ef4b2e55d9e116fc74870191bc6a9a5f4442412a703c4cc6c3b", size = 4401656, upload-time = "2025-11-21T22:31:26.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/c5/8fd8f96450e4ef242022c9a588305e3dc7309c34bc392a9b4c2da60854b1/psycopg_binary-3.2.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0ef8ed4a4e0f7bf5e941782478a43c14b2b585b031e2266dd3afb87be2775d95", size = 3851747, upload-time = "2025-11-21T22:31:30.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/47/406d102ae49d253f124644530f1e5b3fd2f92aea59d4f9b8dd1c71cf8e0f/psycopg_binary-3.2.13-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:de06fc9707a49f7c081b5c950974dd6de3dc33d681f7524f0b396471f5a4a480", size = 3524796, upload-time = "2025-11-21T22:31:34.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/6f/a89be8aee27a5522e97dbcb225fe429c489acdf0bb25fc0fadb329dfb39f/psycopg_binary-3.2.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:917ad1cd6e6ef8a9df2f28d7b29c7148f089be46ac56fe838f986c0227652d14", size = 3576536, upload-time = "2025-11-21T22:31:38.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/f8/c924c7dc792c81bf6181d7d4eeb613c8b2151b3a208f95cedec3c1a25ba3/psycopg_binary-3.2.13-cp312-cp312-win_amd64.whl", hash = "sha256:b53b0d9499805b307017070492189e349256e0946f62c815e442baa01f2ea6c5", size = 2902172, upload-time = "2025-11-21T22:31:41.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/ec/ef37bb44dc02fcc6c0a3eeb93f4baaac13bcb228633fe38ad3fb5a3f6449/psycopg_binary-3.2.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dbae6ab1966e2b61d97e47220556c330c4608bb4cfb3a124aa0595c39995c068", size = 3995628, upload-time = "2025-11-21T22:31:45.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/ad/4748f5f1a40248af16dba087dbec50bd335ee025cc1fb9bf64773378ceff/psycopg_binary-3.2.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fae933e4564386199fc54845d85413eedb49760e0bcd2b621fde2dd1825b99b3", size = 4069024, upload-time = "2025-11-21T22:31:50.202Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/c2/f02ec6bbc30c7fcd3b39823d2d624b42fae480edeb6e50eb3276281d5635/psycopg_binary-3.2.13-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:13e2f8894d410678529ff9f1211f96c5a93ff142f992b302682b42d924428b61", size = 4615127, upload-time = "2025-11-21T22:31:56.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/0d/a54fc2cdd672c84175d6869cc823d6ec2a8909318d491f3c24e6077983f2/psycopg_binary-3.2.13-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f26f7009375cf1e92180e5c517c52da1054f7e690dde90e0ed00fa8b5736bcd4", size = 4710267, upload-time = "2025-11-21T22:32:04.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/b7/067de1acaf3d312253351f3af4121f972584bd36cada6378d4b0cdcebd38/psycopg_binary-3.2.13-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea2fdbcc9142933a47c66970e0df8b363e3bd1ea4c5ce376f2f3d94a9aeec847", size = 4400795, upload-time = "2025-11-21T22:32:08.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b5/030e6b1ebfc4d3a8fca03adc5fc827982643bad0b01a1268538d17c08ed3/psycopg_binary-3.2.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac92d6bc1d4a41c7459953a9aa727b9966e937e94c9e072527317fd2a67d488b", size = 3851239, upload-time = "2025-11-21T22:32:12.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/6f/0541845364a7de9eae6807060da6a04b22a8eb2e803606d285d9250fbe93/psycopg_binary-3.2.13-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8b843c00478739e95c46d6d3472b13123b634685f107831a9bfc41503a06ecbd", size = 3525084, upload-time = "2025-11-21T22:32:15.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/ae/6507890dc30a4bbd9d938d4ff3a4079d009a5ad8170af51c7f762438fdbf/psycopg_binary-3.2.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2f63868cc96bc18486cebec24445affbdd7f7debf28fac466ea935a8b5a4753b", size = 3576787, upload-time = "2025-11-21T22:32:19.922Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/64/3d1c2f1fd09b60cdfbe68b9a810b357ba505eff6e4bdb1a2d9f6729da64c/psycopg_binary-3.2.13-cp313-cp313-win_amd64.whl", hash = "sha256:594dfbca3326e997ae738d3d339004e8416b1f7390f52ce8dc2d692393e8fa96", size = 2905584, upload-time = "2025-11-21T22:32:23.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/b4/7656b3d67bedff2b900c8c4671cb6eb5fb99c2fc36da33579cac89779c25/psycopg_binary-3.2.13-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:502a778c3e07c6b3aabfa56ee230e8c264d2debfab42d11535513a01bdfff0d6", size = 3997201, upload-time = "2025-11-21T22:32:28.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/2e/3b4afbd94d48df19c3931cedba464b109f89d81ac43178e6a3d654b4e8d5/psycopg_binary-3.2.13-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7561a71d764d6f74d66e8b7d844b0f27fa33de508f65c17b1d56a94c73644776", size = 4071631, upload-time = "2025-11-21T22:32:32.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/8b/107d06d55992e2f13157eb705ba5a47d06c4cf1bed077dff0c567b10c187/psycopg_binary-3.2.13-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9caf14745a1930b4e03fe4072cd7154eaf6e1241d20c42130ed784408a26b24b", size = 4620918, upload-time = "2025-11-21T22:32:37.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/47/a925620f261b115f31e813a5bfe640f316413b1864094a60162f4a6e4d67/psycopg_binary-3.2.13-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a6cafabdc0bfa37e11c6f365020fd5916b62d6296df581f4dceaa43a2ce680c", size = 4714494, upload-time = "2025-11-21T22:32:42.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/33/bed384665356bb9ba17dd8e104884d87cc2343d16dffdfd9aaa9a159bd4d/psycopg_binary-3.2.13-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96cb5a27e68acac6d74b64fca38592a692de9c4b7827339190698d58027aa45", size = 4403046, upload-time = "2025-11-21T22:32:47.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/88/749d8e8102fb5df502e2ecb053b79e78e3358af01af652b5dbeb96ab7905/psycopg_binary-3.2.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:596176ae3dfbf56fc61108870bfe17c7205d33ac28d524909feb5335201daa0a", size = 3859046, upload-time = "2025-11-21T22:32:51.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/7c/f492e63b517d6dcd564e8c43bc15e11a4c712a848adf8938ce33bfd4c867/psycopg_binary-3.2.13-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:cc3a0408435dfbb77eeca5e8050df4b19a6e9b7e5e5583edf524c4a83d6293b2", size = 3531351, upload-time = "2025-11-21T22:32:55.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/5a/d8743eb23944e5cf2a0bbfa92935c140b5beaacdb872be641065ed70ab2c/psycopg_binary-3.2.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65df0d459ffba14082d8ca4bb2f6ffbb2f8d02968f7d34a747e1031934b76b23", size = 3581034, upload-time = "2025-11-21T22:33:01.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/b2/411d4180252144f7eff024894d2d2ebb98c012c944a282fc20250870e461/psycopg_binary-3.2.13-cp314-cp314-win_amd64.whl", hash = "sha256:5c77f156c7316529ed371b5f95a51139e531328ee39c37493a2afcbc1f79d5de", size = 3000162, upload-time = "2025-11-21T22:33:07.378Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.21.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "15.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shellingham"
|
||||
version = "1.5.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.0.44"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f0/f2/840d7b9496825333f532d2e3976b8eadbf52034178aac53630d09fe6e1ef/sqlalchemy-2.0.44.tar.gz", hash = "sha256:0ae7454e1ab1d780aee69fd2aae7d6b8670a581d8847f2d1e0f7ddfbf47e5a22", size = 9819830, upload-time = "2025-10-10T14:39:12.935Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/c4/59c7c9b068e6813c898b771204aad36683c96318ed12d4233e1b18762164/sqlalchemy-2.0.44-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72fea91746b5890f9e5e0997f16cbf3d53550580d76355ba2d998311b17b2250", size = 2139675, upload-time = "2025-10-10T16:03:31.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/ae/eeb0920537a6f9c5a3708e4a5fc55af25900216bdb4847ec29cfddf3bf3a/sqlalchemy-2.0.44-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:585c0c852a891450edbb1eaca8648408a3cc125f18cf433941fa6babcc359e29", size = 2127726, upload-time = "2025-10-10T16:03:35.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/d5/2ebbabe0379418eda8041c06b0b551f213576bfe4c2f09d77c06c07c8cc5/sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b94843a102efa9ac68a7a30cd46df3ff1ed9c658100d30a725d10d9c60a2f44", size = 3327603, upload-time = "2025-10-10T15:35:28.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/e5/5aa65852dadc24b7d8ae75b7efb8d19303ed6ac93482e60c44a585930ea5/sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:119dc41e7a7defcefc57189cfa0e61b1bf9c228211aba432b53fb71ef367fda1", size = 3337842, upload-time = "2025-10-10T15:43:45.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/92/648f1afd3f20b71e880ca797a960f638d39d243e233a7082c93093c22378/sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0765e318ee9179b3718c4fd7ba35c434f4dd20332fbc6857a5e8df17719c24d7", size = 3264558, upload-time = "2025-10-10T15:35:29.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/cf/e27d7ee61a10f74b17740918e23cbc5bc62011b48282170dc4c66da8ec0f/sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2e7b5b079055e02d06a4308d0481658e4f06bc7ef211567edc8f7d5dce52018d", size = 3301570, upload-time = "2025-10-10T15:43:48.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/3d/3116a9a7b63e780fb402799b6da227435be878b6846b192f076d2f838654/sqlalchemy-2.0.44-cp312-cp312-win32.whl", hash = "sha256:846541e58b9a81cce7dee8329f352c318de25aa2f2bbe1e31587eb1f057448b4", size = 2103447, upload-time = "2025-10-10T15:03:21.678Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/83/24690e9dfc241e6ab062df82cc0df7f4231c79ba98b273fa496fb3dd78ed/sqlalchemy-2.0.44-cp312-cp312-win_amd64.whl", hash = "sha256:7cbcb47fd66ab294703e1644f78971f6f2f1126424d2b300678f419aa73c7b6e", size = 2130912, upload-time = "2025-10-10T15:03:24.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/d3/c67077a2249fdb455246e6853166360054c331db4613cda3e31ab1cadbef/sqlalchemy-2.0.44-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ff486e183d151e51b1d694c7aa1695747599bb00b9f5f604092b54b74c64a8e1", size = 2135479, upload-time = "2025-10-10T16:03:37.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/91/eabd0688330d6fd114f5f12c4f89b0d02929f525e6bf7ff80aa17ca802af/sqlalchemy-2.0.44-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b1af8392eb27b372ddb783b317dea0f650241cea5bd29199b22235299ca2e45", size = 2123212, upload-time = "2025-10-10T16:03:41.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/bb/43e246cfe0e81c018076a16036d9b548c4cc649de241fa27d8d9ca6f85ab/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b61188657e3a2b9ac4e8f04d6cf8e51046e28175f79464c67f2fd35bceb0976", size = 3255353, upload-time = "2025-10-10T15:35:31.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/96/c6105ed9a880abe346b64d3b6ddef269ddfcab04f7f3d90a0bf3c5a88e82/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b87e7b91a5d5973dda5f00cd61ef72ad75a1db73a386b62877d4875a8840959c", size = 3260222, upload-time = "2025-10-10T15:43:50.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/16/1857e35a47155b5ad927272fee81ae49d398959cb749edca6eaa399b582f/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15f3326f7f0b2bfe406ee562e17f43f36e16167af99c4c0df61db668de20002d", size = 3189614, upload-time = "2025-10-10T15:35:32.578Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/ee/4afb39a8ee4fc786e2d716c20ab87b5b1fb33d4ac4129a1aaa574ae8a585/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e77faf6ff919aa8cd63f1c4e561cac1d9a454a191bb864d5dd5e545935e5a40", size = 3226248, upload-time = "2025-10-10T15:43:51.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/d5/0e66097fc64fa266f29a7963296b40a80d6a997b7ac13806183700676f86/sqlalchemy-2.0.44-cp313-cp313-win32.whl", hash = "sha256:ee51625c2d51f8baadf2829fae817ad0b66b140573939dd69284d2ba3553ae73", size = 2101275, upload-time = "2025-10-10T15:03:26.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/51/665617fe4f8c6450f42a6d8d69243f9420f5677395572c2fe9d21b493b7b/sqlalchemy-2.0.44-cp313-cp313-win_amd64.whl", hash = "sha256:c1c80faaee1a6c3428cecf40d16a2365bcf56c424c92c2b6f0f9ad204b899e9e", size = 2127901, upload-time = "2025-10-10T15:03:27.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/5e/6a29fa884d9fb7ddadf6b69490a9d45fded3b38541713010dad16b77d015/sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05", size = 1928718, upload-time = "2025-10-10T15:29:45.32Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "rich" },
|
||||
{ name = "shellingham" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2026.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce71b6e50a9d09445aa2ecbd08359a337976627b8409e7/tzdata-2026.5.tar.gz", hash = "sha256:8cc73c0a0bfca7dbfa59235d60b2eff82231dee33f53d206db1acd9173cfc0a7", size = 200404, upload-time = "2026-10-03T09:23:14.143Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "15.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user