From 087d64f5794213a71b586761647ef15ad35a98db Mon Sep 17 00:00:00 2001 From: Skyler Blue Spillers <92972770+skylerblue333@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:13:44 -0500 Subject: [PATCH 01/17] feat: rebuild as durable Sky Notify service --- src/main.py | 240 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 208 insertions(+), 32 deletions(-) diff --git a/src/main.py b/src/main.py index 9b14b8b..58e9dec 100644 --- a/src/main.py +++ b/src/main.py @@ -1,34 +1,210 @@ -from fastapi import FastAPI -import asyncio +"""Sky Notify: durable provider-neutral notification routing service.""" +from __future__ import annotations + +import hmac +import json +import os +import sqlite3 import time +from pathlib import Path +from typing import Any, Literal +from urllib.parse import urlsplit +from uuid import uuid4 + +import httpx +from fastapi import Depends, FastAPI, HTTPException, Request +from pydantic import BaseModel, Field + +SERVICE = "sky-notify" +MAX_PAYLOAD_BYTES = 64 * 1024 + + +class SubmitRequest(BaseModel): + channel: Literal["webhook", "log"] + destination: str = Field(min_length=1, max_length=2048) + payload: dict[str, Any] = Field(default_factory=dict) + idempotencyKey: str = Field(min_length=1, max_length=128) + maxAttempts: int = Field(default=3, ge=1, le=10) + + +class Store: + def __init__(self, path: str) -> None: + self.path = str(Path(path).expanduser().resolve()) + Path(self.path).parent.mkdir(parents=True, exist_ok=True) + with self.connect() as db: + db.execute("PRAGMA journal_mode=WAL") + db.execute("CREATE TABLE IF NOT EXISTS notifications(id TEXT PRIMARY KEY,idempotency_key TEXT UNIQUE,channel TEXT NOT NULL,destination TEXT NOT NULL,payload_json TEXT NOT NULL,status TEXT NOT NULL,attempts INTEGER NOT NULL,max_attempts INTEGER NOT NULL,next_attempt REAL NOT NULL,last_error TEXT,created_at REAL NOT NULL,updated_at REAL NOT NULL)") + db.execute("CREATE INDEX IF NOT EXISTS idx_notify_due ON notifications(status,next_attempt)") + + def connect(self) -> sqlite3.Connection: + db = sqlite3.connect(self.path, timeout=5) + db.row_factory = sqlite3.Row + return db + + @staticmethod + def encode(value: Any) -> str: + try: + data = json.dumps(value, separators=(",", ":"), sort_keys=True, allow_nan=False) + except (TypeError, ValueError) as exc: + raise ValueError("payload must be JSON serializable") from exc + if len(data.encode("utf-8")) > MAX_PAYLOAD_BYTES: + raise ValueError("payload too large") + return data + + @staticmethod + def view(row: sqlite3.Row) -> dict[str, Any]: + return { + "id": row["id"], + "idempotencyKey": row["idempotency_key"], + "channel": row["channel"], + "destination": row["destination"], + "payload": json.loads(row["payload_json"]), + "status": row["status"], + "attempts": row["attempts"], + "maxAttempts": row["max_attempts"], + "nextAttempt": row["next_attempt"], + "lastError": row["last_error"], + "createdAt": row["created_at"], + "updatedAt": row["updated_at"], + } + + def submit(self, req: SubmitRequest, now: float) -> tuple[dict[str, Any], bool]: + payload = self.encode(req.payload) + with self.connect() as db: + existing = db.execute("SELECT * FROM notifications WHERE idempotency_key=?", (req.idempotencyKey,)).fetchone() + if existing: + return self.view(existing), False + ident = str(uuid4()) + try: + db.execute("INSERT INTO notifications VALUES(?,?,?,?,?,'pending',0,?,?,NULL,?,?)", (ident, req.idempotencyKey, req.channel, req.destination, payload, req.maxAttempts, now, now, now)) + db.commit() + except sqlite3.IntegrityError: + row = db.execute("SELECT * FROM notifications WHERE idempotency_key=?", (req.idempotencyKey,)).fetchone() + if row is None: + raise + return self.view(row), False + row = db.execute("SELECT * FROM notifications WHERE id=?", (ident,)).fetchone() + return self.view(row), True + + def get(self, ident: str) -> dict[str, Any] | None: + with self.connect() as db: + row = db.execute("SELECT * FROM notifications WHERE id=?", (ident,)).fetchone() + return self.view(row) if row else None + + def claim_due(self, now: float) -> sqlite3.Row | None: + with self.connect() as db: + db.execute("BEGIN IMMEDIATE") + row = db.execute("SELECT * FROM notifications WHERE status='pending' AND next_attempt<=? ORDER BY created_at LIMIT 1", (now,)).fetchone() + if row is None: + db.commit() + return None + db.execute("UPDATE notifications SET status='sending',attempts=attempts+1,updated_at=? WHERE id=?", (now, row["id"])) + db.commit() + with self.connect() as db: + return db.execute("SELECT * FROM notifications WHERE id=?", (row["id"],)).fetchone() + + def finish(self, ident: str, ok: bool, error: str | None, now: float) -> None: + with self.connect() as db: + row = db.execute("SELECT * FROM notifications WHERE id=?", (ident,)).fetchone() + if row is None: + return + if ok: + db.execute("UPDATE notifications SET status='delivered',last_error=NULL,updated_at=? WHERE id=?", (now, ident)) + elif row["attempts"] >= row["max_attempts"]: + db.execute("UPDATE notifications SET status='dead_letter',last_error=?,updated_at=? WHERE id=?", ((error or "delivery failed")[:1000], now, ident)) + else: + delay = min(2 ** row["attempts"], 300) + db.execute("UPDATE notifications SET status='pending',next_attempt=?,last_error=?,updated_at=? WHERE id=?", (now + delay, (error or "delivery failed")[:1000], now, ident)) + db.commit() + + def metrics(self) -> dict[str, int]: + with self.connect() as db: + rows = db.execute("SELECT status,COUNT(*) c FROM notifications GROUP BY status").fetchall() + return {row["status"]: row["c"] for row in rows} + + +DB_PATH = os.getenv("NOTIFY_DB_PATH", "data/notifications.db") +API_TOKEN = os.getenv("NOTIFY_API_TOKEN") or None +ALLOWED_WEBHOOK_HOSTS = {h.strip().lower() for h in os.getenv("NOTIFY_WEBHOOK_HOSTS", "").split(",") if h.strip()} +if API_TOKEN is not None and len(API_TOKEN) < 16: + raise RuntimeError("NOTIFY_API_TOKEN must contain at least 16 characters when configured") +store = Store(DB_PATH) +app = FastAPI(title="Sky Notify", version="1.0.0") + + +def auth(request: Request) -> None: + if API_TOKEN is None: + return + if not hmac.compare_digest(request.headers.get("authorization", ""), f"Bearer {API_TOKEN}"): + raise HTTPException(status_code=401, detail="unauthorized") + + +def validate_destination(req: SubmitRequest) -> None: + if req.channel == "log": + return + parsed = urlsplit(req.destination) + if parsed.scheme != "https" or not parsed.hostname: + raise HTTPException(status_code=400, detail="webhook destination must be HTTPS") + if not ALLOWED_WEBHOOK_HOSTS or parsed.hostname.lower() not in ALLOWED_WEBHOOK_HOSTS: + raise HTTPException(status_code=400, detail="webhook host is not allowlisted") + if parsed.username or parsed.password: + raise HTTPException(status_code=400, detail="credentials are not allowed in webhook URLs") + + +@app.get("/healthz") +def health() -> dict[str, str]: + return {"status": "healthy", "service": SERVICE} + + +@app.get("/readyz") +def ready() -> dict[str, str]: + store.metrics() + return {"status": "ready", "service": SERVICE} + + +@app.get("/metrics") +def metrics() -> dict[str, Any]: + return {"service": SERVICE, **store.metrics()} + + +@app.post("/api/v1/notifications", dependencies=[Depends(auth)], status_code=201) +def submit(req: SubmitRequest) -> dict[str, Any]: + validate_destination(req) + try: + item, created = store.submit(req, time.time()) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + item["replayed"] = not created + return item + + +@app.get("/api/v1/notifications/{ident}", dependencies=[Depends(auth)]) +def get_notification(ident: str) -> dict[str, Any]: + item = store.get(ident) + if item is None: + raise HTTPException(status_code=404, detail="notification not found") + return item + + +async def deliver(row: sqlite3.Row) -> tuple[bool, str | None]: + if row["channel"] == "log": + print(json.dumps({"notificationId": row["id"], "payload": json.loads(row["payload_json"])})) + return True, None + try: + async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client: + response = await client.post(row["destination"], json=json.loads(row["payload_json"]), headers={"X-Sky-Notification-Id": row["id"]}) + if 200 <= response.status_code < 300: + return True, None + return False, f"provider returned HTTP {response.status_code}" + except httpx.RequestError as exc: + return False, exc.__class__.__name__ + -app = FastAPI(title="Python-Notification-Router API", version="2.0.0") - -class Processor: - def __init__(self): - self.ready = False - self.items_processed = 0 - - async def initialize(self): - await asyncio.sleep(0.1) - self.ready = True - - def process(self, data: dict) -> dict: - if not self.ready: - raise RuntimeError("Not initialized") - self.items_processed += 1 - return {"status": "success", "processed": True, "domain": "router", "data": data} - -processor = Processor() - -@app.on_event("startup") -async def startup(): - await processor.initialize() - -@app.get("/health") -def health(): - return {"status": "ok", "ready": processor.ready, "processed": processor.items_processed} - -@app.post("/api/v1/process") -def process_data(payload: dict): - return processor.process(payload) +@app.post("/internal/run-once", dependencies=[Depends(auth)]) +async def run_once() -> dict[str, Any]: + row = store.claim_due(time.time()) + if row is None: + return {"status": "idle"} + ok, error = await deliver(row) + store.finish(row["id"], ok, error, time.time()) + return {"status": "delivered" if ok else "retry_scheduled", "id": row["id"]} From 01ead35b7c4ce820bfd56382e0ef8f99c85d292b Mon Sep 17 00:00:00 2001 From: Skyler Blue Spillers <92972770+skylerblue333@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:14:05 -0500 Subject: [PATCH 02/17] build: modernize Sky Notify runtime dependencies --- requirements.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index 2bf51fb..6bfaafb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -fastapi==0.103.1 -uvicorn==0.23.2 -pytest==7.4.2 -httpx==0.24.1 +fastapi>=0.115,<1 +uvicorn[standard]>=0.30,<1 +httpx>=0.27,<1 +pydantic>=2.8,<3 From eb79c5b8306399a79702481dd27dd5ee1e5d2667 Mon Sep 17 00:00:00 2001 From: Skyler Blue Spillers <92972770+skylerblue333@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:14:15 -0500 Subject: [PATCH 03/17] test: add Sky Notify verification toolchain --- requirements-dev.txt | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 requirements-dev.txt diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..822f837 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +-r requirements.txt +pytest>=8,<9 +pytest-asyncio>=0.24,<1 +ruff>=0.6,<1 +pip-audit>=2.7,<3 From ddea692e5524923aae36e1d62ad2fe2b035fefa7 Mon Sep 17 00:00:00 2001 From: Skyler Blue Spillers <92972770+skylerblue333@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:14:31 -0500 Subject: [PATCH 04/17] test: cover durable notification lifecycle --- tests/test_notify.py | 59 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/test_notify.py diff --git a/tests/test_notify.py b/tests/test_notify.py new file mode 100644 index 0000000..34c8a21 --- /dev/null +++ b/tests/test_notify.py @@ -0,0 +1,59 @@ +import importlib +import os +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + + +def load_app(tmp_path: Path): + os.environ["NOTIFY_DB_PATH"] = str(tmp_path / "notify.db") + os.environ.pop("NOTIFY_API_TOKEN", None) + os.environ["NOTIFY_WEBHOOK_HOSTS"] = "hooks.example.com" + import src.main as main + return importlib.reload(main) + + +def test_log_notification_is_idempotent_and_delivered(tmp_path): + main = load_app(tmp_path) + client = TestClient(main.app) + payload = {"channel": "log", "destination": "stdout", "payload": {"message": "hello"}, "idempotencyKey": "order-1", "maxAttempts": 3} + first = client.post("/api/v1/notifications", json=payload) + assert first.status_code == 201 + second = client.post("/api/v1/notifications", json=payload) + assert second.status_code == 201 + assert second.json()["id"] == first.json()["id"] + assert second.json()["replayed"] is True + run = client.post("/internal/run-once") + assert run.status_code == 200 + assert run.json()["status"] == "delivered" + item = client.get(f"/api/v1/notifications/{first.json()['id']}").json() + assert item["status"] == "delivered" + assert item["attempts"] == 1 + + +def test_webhook_requires_https_allowlist(tmp_path): + main = load_app(tmp_path) + client = TestClient(main.app) + request = {"channel": "webhook", "destination": "http://hooks.example.com/a", "payload": {}, "idempotencyKey": "bad-1"} + assert client.post("/api/v1/notifications", json=request).status_code == 400 + request["destination"] = "https://not-allowed.example/a" + request["idempotencyKey"] = "bad-2" + assert client.post("/api/v1/notifications", json=request).status_code == 400 + + +def test_dead_letter_after_retry_budget(tmp_path): + main = load_app(tmp_path) + request = main.SubmitRequest(channel="log", destination="stdout", payload={}, idempotencyKey="dead", maxAttempts=1) + item, _ = main.store.submit(request, 1.0) + row = main.store.claim_due(1.0) + assert row is not None + main.store.finish(item["id"], False, "provider failed", 2.0) + assert main.store.get(item["id"])["status"] == "dead_letter" + + +def test_payload_size_is_bounded(tmp_path): + main = load_app(tmp_path) + request = main.SubmitRequest(channel="log", destination="stdout", payload={"x": "a" * 70000}, idempotencyKey="huge") + with pytest.raises(ValueError): + main.store.submit(request, 1.0) From 1b7ad583650f19993580225b30c62b36d54478e8 Mon Sep 17 00:00:00 2001 From: Skyler Blue Spillers <92972770+skylerblue333@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:17:44 -0500 Subject: [PATCH 05/17] chore: package Sky Notify service --- src/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/__init__.py diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..9f97d2d --- /dev/null +++ b/src/__init__.py @@ -0,0 +1 @@ +"""Sky Notify package.""" From ae1bfd56114ab504da59855b6856cc607d9743a7 Mon Sep 17 00:00:00 2001 From: Skyler Blue Spillers <92972770+skylerblue333@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:18:17 -0500 Subject: [PATCH 06/17] build: harden Sky Notify container --- Dockerfile | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8fa2774..6007a82 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,12 @@ -FROM python:3.11-slim +FROM python:3.12-slim +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 +RUN groupadd --system sky && useradd --system --gid sky --home-dir /app sky WORKDIR /app COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt -COPY . . +RUN pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir -r requirements.txt +COPY src ./src +RUN mkdir -p /app/data && chown -R sky:sky /app +USER sky EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=3)" CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"] From b4bada2250f58a080be89f9c92bfbfea322fd1ed Mon Sep 17 00:00:00 2001 From: Skyler Blue Spillers <92972770+skylerblue333@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:18:28 -0500 Subject: [PATCH 07/17] ci: enforce Sky Notify release gates --- .github/workflows/ci.yml | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b22620..31cd74c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,31 @@ -name: CI -on: [push, pull_request] +name: notify-ci +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + jobs: - test: - runs-on: ubuntu-latest + verify: + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: - python-version: '3.11' - - run: pip install -r requirements.txt - - run: pytest tests/ -v + python-version: '3.12' + - name: Install dependencies + run: python -m pip install --upgrade pip && pip install -r requirements-dev.txt + - name: Compile + run: python -m compileall -q src tests + - name: Lint + run: ruff check src tests + - name: Test + run: pytest -q + - name: Audit runtime dependencies + run: pip-audit -r requirements.txt + - name: Build hardened container + run: docker build -t sky-notify:ci . + - name: Verify non-root image declaration + run: test "$(docker image inspect sky-notify:ci --format '{{.Config.User}}')" = "sky" From 76439ea55dcac37a34d52fbd28bc6add5a3a3001 Mon Sep 17 00:00:00 2001 From: Skyler Blue Spillers <92972770+skylerblue333@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:18:55 -0500 Subject: [PATCH 08/17] docs: document Sky Notify product boundary --- README.md | 70 +++++++++++++++++++++++++++++++++---------------------- 1 file changed, 42 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 2f79915..d029293 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,58 @@ - +# Sky Notify -## Project profile and code-audit snapshot +Sky Notify is a focused notification-delivery service for the SKYCOIN4444 engineering ecosystem. It provides durable submission, idempotency, retry/dead-letter state, operational endpoints, and explicit provider delivery results without claiming delivery before the configured provider actually succeeds. -**What this is:** **Python-Notification-Router** is a public repository described as: “Enterprise-grade notification router implementation in Python. #SkyCoin4444 #AI #Blockchain #DevOps #Innovation” Its dominant language signals are **Python (5 files)**. +## What is implemented -**Why it has value:** Its value is best understood through the implementation evidence currently present in the repository: **19 tracked files** were observed in the shallow audit, with the source structure and existing documentation providing the project’s specific context. This README does not treat a prototype, experiment, or archive as a production system without supporting evidence. +- FastAPI HTTP API on Python 3.12 +- SQLite-backed durable notification records +- idempotent submissions keyed by caller-provided idempotency keys +- `pending`, `sending`, `delivered`, and `dead_letter` lifecycle states +- bounded retry budgets with exponential delay +- `log` adapter for deterministic local/CI verification +- HTTPS webhook adapter with an explicit hostname allowlist +- redirect refusal for webhook calls +- optional constant-time bearer-token protection +- 64 KiB bounded JSON payloads +- `/healthz`, `/readyz`, and `/metrics` +- non-root container packaging and persistent `/app/data` state +- compile, Ruff, pytest, `pip-audit`, Docker-build, and non-root CI gates -**Implementation evidence:** 2 test-related file(s) detected; 2 dependency or package manifest(s) detected; 2 build/CI/infrastructure signal(s) detected; and 3 documentation or governance file(s) detected. Test filenames observed include `tests/test_main.py`, `tests/test_router.py`. Dependency or package files include `package.json`, `requirements.txt`. Build, CI, or infrastructure signals include `Dockerfile`, `.github/workflows/ci.yml`. +## Run locally -**Current status:** The repository is tracked on the `main` branch. The existing source tree, configuration, tests, workflows, and documentation remain authoritative for supported behavior and maturity. A code audit is not a production-readiness certification, and the presence of a test or workflow file does not establish that all checks pass. +```bash +python -m venv .venv +. .venv/bin/activate +pip install -r requirements.txt +uvicorn src.main:app --host 127.0.0.1 --port 8000 +``` -**Relationship to the wider portfolio:** This repository is one focused component of the broader Skyler Blue Spillers portfolio across AI, software engineering, cloud and DevOps, cybersecurity, blockchain, finance, education, social systems, and creative work. It may provide a service boundary, implementation pattern, experiment, archive, or reusable idea for related repositories. Treat repositories as technical dependencies only where documented interfaces and verified project requirements support that relationship. +To allow webhook delivery, configure exact destination hostnames: -**Quality and security note:** No obvious secret-like pattern was detected by the limited static scan; this is not a substitute for a security audit. No TODO/FIXME marker was detected in the scanned text files. +```bash +export NOTIFY_WEBHOOK_HOSTS="hooks.example.com,events.example.net" +``` ---- +Optionally protect mutation/read endpoints: -# Python Notification Router +```bash +export NOTIFY_API_TOKEN="replace-with-a-secret-at-least-16-characters" +``` -![GitHub stars](https://img.shields.io/github/stars/skylerblue333/Python-Notification-Router?style=flat-square) -![GitHub license](https://img.shields.io/github/license/skylerblue333/Python-Notification-Router?style=flat-square) +## Example submission -## 🌟 Overview -**Python-Notification-Router** is a professional-grade project within the **SkyCoin4444** ecosystem. It focuses on delivering high-value solutions in the domain of **Python**. +```bash +curl -X POST http://127.0.0.1:8000/api/v1/notifications \ + -H 'Content-Type: application/json' \ + -d '{"channel":"log","destination":"stdout","payload":{"event":"build.complete"},"idempotencyKey":"build-123","maxAttempts":3}' -## 🚀 Key Features -- **Scalable Architecture**: Designed for enterprise-level growth and performance. -- **Modern Standards**: Implements best practices for clean code and maintainability. -- **Robust Integration**: Built to work seamlessly within modern cloud-native environments. +curl -X POST http://127.0.0.1:8000/internal/run-once +``` -## 🛠️ Technology Stack -- **Primary Domain**: Python -- **Ecosystem**: SkyCoin4444 Digital Platform +## Deployment boundary -## 📂 Structure -The project is organized into a modular structure to ensure clarity and ease of development. +This release is a **single-node durable notification router**. It does not claim distributed queue semantics, exactly-once external delivery, multi-region failover, email/SMS/push provider integrations, tenant isolation, or external compliance certification. Webhook recipients must be explicitly allowlisted. Production operators remain responsible for TLS termination, network policy, secret management, database backup, monitoring, and provider credentials. -## 👨‍💻 Author -**Skyler Blue Spillers** -*Professional Chess Player & Software Engineer* +## Repository role ---- -*Powered by SkyCoin4444* +Sky Notify is product #10 in the standalone-product master plan. It remains independently buildable while exposing a clean notification boundary that can later be integrated into the unified SKYCOIN4444 platform. From c0e14b704ada0ad65cb2251598e7d37b63d67efd Mon Sep 17 00:00:00 2001 From: Skyler Blue Spillers <92972770+skylerblue333@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:19:07 -0500 Subject: [PATCH 09/17] docs: define Sky Notify commercial product scope --- PRODUCT.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 PRODUCT.md diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..ac5e2b3 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,13 @@ +# Sky Notify product scope + +**Product:** Sky Notify + +**Purpose:** durable single-node notification routing for applications that need idempotent submission, explicit delivery state, retry budgeting, and auditable provider outcomes. + +**Suitable uses:** internal platform notifications, webhook fan-out, local/CI delivery simulation, and service-boundary integration inside SKYCOIN4444. + +**Supported deployment:** one service instance with persistent SQLite storage. Horizontal multi-writer clustering is not part of this release. + +**Commercial packaging boundary:** this repository can be deployed as a standalone notification microservice, but operators supply infrastructure, TLS, secrets, backup, monitoring, and external provider contracts. + +See `README.md` and `SECURITY.md` for verified behavior and limitations. From 9471ed161b67d030015387c39176dea368baad9b Mon Sep 17 00:00:00 2001 From: Skyler Blue Spillers <92972770+skylerblue333@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:19:20 -0500 Subject: [PATCH 10/17] docs: define Sky Notify security model --- SECURITY.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..3be5899 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,23 @@ +# Security model + +Sky Notify treats notification destinations and payloads as untrusted input. + +## Controls in this release + +- webhook delivery requires HTTPS +- webhook hostname must be explicitly allowlisted through `NOTIFY_WEBHOOK_HOSTS` +- embedded URL credentials are rejected +- redirects are not followed +- JSON payloads are bounded to 64 KiB +- optional bearer authentication uses constant-time comparison +- durable idempotency keys reduce accidental duplicate submissions +- external delivery is marked successful only after a 2xx provider response +- retry budgets transition exhausted deliveries to `dead_letter` +- the container runs as an unprivileged `sky` user +- CI performs compile, lint, tests, dependency audit, and image checks + +## Explicit limitations + +This service does not provide SSRF-proof IP-range filtering beyond the exact hostname allowlist, tenant isolation, message encryption at rest, distributed consensus, exactly-once external delivery, or compliance certification. Operators should place it behind authenticated network boundaries, manage secrets outside the repository, back up the SQLite database, and monitor dead-letter growth. + +Do not include credentials or high-value secrets in notification payloads unless the deployment adds an appropriate encrypted storage and data-handling layer. From 7bca445e91921376907da3e42cc7ba64042341e5 Mon Sep 17 00:00:00 2001 From: Skyler Blue Spillers <92972770+skylerblue333@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:21:25 -0500 Subject: [PATCH 11/17] fix: modernize notification helper typing --- src/router.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/router.py b/src/router.py index 8e448aa..8f47caa 100644 --- a/src/router.py +++ b/src/router.py @@ -2,12 +2,14 @@ import asyncio import logging +from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import Any, Awaitable, Callable +from typing import Any logger = logging.getLogger(__name__) ChannelSender = Callable[[str, str], Awaitable[bool]] + @dataclass(frozen=True) class NotificationRequest: user: str @@ -15,7 +17,7 @@ class NotificationRequest: channels: tuple[str, ...] = ("email",) @classmethod - def from_payload(cls, payload: dict[str, Any]) -> "NotificationRequest": + def from_payload(cls, payload: dict[str, Any]) -> NotificationRequest: user = str(payload.get("user", "")).strip() message = str(payload.get("message", "")).strip() channels = tuple(str(c).strip().lower() for c in payload.get("channels", ["email"]) if str(c).strip()) @@ -23,6 +25,7 @@ def from_payload(cls, payload: dict[str, Any]) -> "NotificationRequest": raise ValueError("user, message, and at least one channel are required") return cls(user, message, channels) + class NotificationRouter: def __init__(self, providers: dict[str, ChannelSender] | None = None) -> None: self.providers = providers or {"email": self._send_email, "sms": self._send_sms, "push": self._send_push} From abc4641e1ac9fcdfb08d2d99da6c04c135dd2c7d Mon Sep 17 00:00:00 2001 From: Skyler Blue Spillers <92972770+skylerblue333@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:22:08 -0500 Subject: [PATCH 12/17] refactor: remove obsolete fake notification helper --- src/router.py | 53 --------------------------------------------------- 1 file changed, 53 deletions(-) delete mode 100644 src/router.py diff --git a/src/router.py b/src/router.py deleted file mode 100644 index 8f47caa..0000000 --- a/src/router.py +++ /dev/null @@ -1,53 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -from collections.abc import Awaitable, Callable -from dataclasses import dataclass -from typing import Any - -logger = logging.getLogger(__name__) -ChannelSender = Callable[[str, str], Awaitable[bool]] - - -@dataclass(frozen=True) -class NotificationRequest: - user: str - message: str - channels: tuple[str, ...] = ("email",) - - @classmethod - def from_payload(cls, payload: dict[str, Any]) -> NotificationRequest: - user = str(payload.get("user", "")).strip() - message = str(payload.get("message", "")).strip() - channels = tuple(str(c).strip().lower() for c in payload.get("channels", ["email"]) if str(c).strip()) - if not user or not message or not channels: - raise ValueError("user, message, and at least one channel are required") - return cls(user, message, channels) - - -class NotificationRouter: - def __init__(self, providers: dict[str, ChannelSender] | None = None) -> None: - self.providers = providers or {"email": self._send_email, "sms": self._send_sms, "push": self._send_push} - - async def _send_email(self, user: str, msg: str) -> bool: - logger.info("email notification prepared for %s", user) - return True - - async def _send_sms(self, user: str, msg: str) -> bool: - logger.info("sms notification prepared for %s", user) - return True - - async def _send_push(self, user: str, msg: str) -> bool: - logger.info("push notification prepared for %s", user) - return True - - async def route(self, payload: dict[str, Any]) -> dict[str, Any]: - request = NotificationRequest.from_payload(payload) - unknown = [c for c in request.channels if c not in self.providers] - if unknown: - return {"success": False, "sent": [], "failed": unknown} - results = await asyncio.gather(*(self.providers[c](request.user, request.message) for c in request.channels), return_exceptions=True) - sent = [c for c, result in zip(request.channels, results) if result is True] - failed = [c for c, result in zip(request.channels, results) if result is not True] - return {"success": not failed, "sent": sent, "failed": failed} From b7660dc9f1d11e4fa37b109ce9de68e88b3363e8 Mon Sep 17 00:00:00 2001 From: Skyler Blue Spillers <92972770+skylerblue333@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:22:22 -0500 Subject: [PATCH 13/17] test: remove obsolete placeholder API tests --- tests/test_main.py | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 tests/test_main.py diff --git a/tests/test_main.py b/tests/test_main.py deleted file mode 100644 index 44246c6..0000000 --- a/tests/test_main.py +++ /dev/null @@ -1,17 +0,0 @@ -from fastapi.testclient import TestClient -from src.main import app - -def test_health(): - with TestClient(app) as client: - response = client.get("/health") - assert response.status_code == 200 - assert response.json()["status"] == "ok" - assert response.json()["ready"] == True - -def test_process(): - with TestClient(app) as client: - response = client.post("/api/v1/process", json={"test": "data"}) - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - assert data["domain"] == "router" From a992ad85269bd5ebcec04b28e1bccf1ad28183ce Mon Sep 17 00:00:00 2001 From: Skyler Blue Spillers <92972770+skylerblue333@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:22:34 -0500 Subject: [PATCH 14/17] test: remove obsolete helper routing tests --- tests/test_router.py | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 tests/test_router.py diff --git a/tests/test_router.py b/tests/test_router.py deleted file mode 100644 index 4ae838b..0000000 --- a/tests/test_router.py +++ /dev/null @@ -1,13 +0,0 @@ -import pytest -from src.router import NotificationRouter - -@pytest.mark.asyncio -async def test_routing(): - router = NotificationRouter() - payload = { - "user": "skyler", - "message": "System alert!", - "channels": ["email", "push"] - } - success = await router.route(payload) - assert success is True From 00b202e2266ba2d6be3a26b067f1ce2101e53ad4 Mon Sep 17 00:00:00 2001 From: Skyler Blue Spillers <92972770+skylerblue333@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:22:45 -0500 Subject: [PATCH 15/17] refactor: remove placeholder root launcher --- main.py | 1 - 1 file changed, 1 deletion(-) delete mode 100644 main.py diff --git a/main.py b/main.py deleted file mode 100644 index c9c96b2..0000000 --- a/main.py +++ /dev/null @@ -1 +0,0 @@ -print('Starting Python-Notification-Router service...')\n \ No newline at end of file From 9ff7793462dfc4fafe7414a002baa312ba5c5173 Mon Sep 17 00:00:00 2001 From: Skyler Blue Spillers <92972770+skylerblue333@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:23:09 -0500 Subject: [PATCH 16/17] fix: satisfy Notify test lint --- tests/test_notify.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_notify.py b/tests/test_notify.py index 34c8a21..d1fc988 100644 --- a/tests/test_notify.py +++ b/tests/test_notify.py @@ -10,7 +10,8 @@ def load_app(tmp_path: Path): os.environ["NOTIFY_DB_PATH"] = str(tmp_path / "notify.db") os.environ.pop("NOTIFY_API_TOKEN", None) os.environ["NOTIFY_WEBHOOK_HOSTS"] = "hooks.example.com" - import src.main as main + from src import main + return importlib.reload(main) From 9fa957fe5f64ae255a793b28a382bc517ea22b9b Mon Sep 17 00:00:00 2001 From: Skyler Blue Spillers <92972770+skylerblue333@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:26:24 -0500 Subject: [PATCH 17/17] fix: define Sky Notify test import root --- pytest.ini | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 pytest.ini diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..c7b23ec --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +pythonpath = . +testpaths = tests