signedapproval 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ # deps
2
+ /node_modules
3
+
4
+ # Next.js
5
+ /.next/
6
+ /out/
7
+
8
+ # production build
9
+ /build
10
+
11
+ # env files — never commit
12
+ .env
13
+ .env.local
14
+ .env.production.local
15
+ .env.development.local
16
+ .env.claude
17
+
18
+ # Vercel
19
+ .vercel
20
+
21
+ # TypeScript
22
+ *.tsbuildinfo
23
+ next-env.d.ts
24
+
25
+ # OS
26
+ .DS_Store
27
+ *.pem
28
+
29
+ # Debug logs
30
+ npm-debug.log*
31
+ yarn-debug.log*
32
+ yarn-error.log*
33
+
34
+ # iOS secrets (contains Supabase anon key)
35
+ ios/Secrets.xcconfig
36
+ .env*.local
37
+
38
+ # claude code
39
+ CLAUDE.md
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: signedapproval
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for SignedApproval — cryptographic human-in-the-loop approvals
5
+ Project-URL: Homepage, https://signedapproval.net
6
+ Project-URL: Repository, https://github.com/AlexFloyd13/signedapproval
7
+ Author: Floyd Media LLC
8
+ License: MIT
9
+ Keywords: approval,ed25519,human-in-the-loop,passkey,signedapproval
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Requires-Python: >=3.9
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "signedapproval"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for SignedApproval — cryptographic human-in-the-loop approvals"
9
+ requires-python = ">=3.9"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "Floyd Media LLC" }]
12
+ keywords = ["signedapproval", "human-in-the-loop", "approval", "passkey", "ed25519"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.9",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://signedapproval.net"
26
+ Repository = "https://github.com/AlexFloyd13/signedapproval"
@@ -0,0 +1,16 @@
1
+ """
2
+ SignedApproval Python SDK
3
+ ~~~~~~~~~~~~~~~~~~~~~~~~~
4
+
5
+ from signedapproval import SignedApproval
6
+
7
+ sa = SignedApproval("sa_live_...")
8
+ req = sa.request_approval(action="Deploy to production")
9
+ decision = sa.wait_for_decision(req["request_id"])
10
+ proof = sa.verify(req["request_id"]) # verify the signed decision
11
+ """
12
+
13
+ from .client import SignedApproval, SignedApprovalError
14
+
15
+ __all__ = ["SignedApproval", "SignedApprovalError"]
16
+ __version__ = "0.1.0"
@@ -0,0 +1,147 @@
1
+ """SignedApproval Python client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any, Optional
7
+ import urllib.error
8
+ import urllib.request
9
+ import json
10
+
11
+
12
+ class SignedApprovalError(Exception):
13
+ def __init__(self, message: str, status_code: Optional[int] = None) -> None:
14
+ super().__init__(message)
15
+ self.status_code = status_code
16
+
17
+
18
+ class SignedApproval:
19
+ """
20
+ Client for the SignedApproval API.
21
+
22
+ Usage::
23
+
24
+ sa = SignedApproval("sa_live_...")
25
+ req = sa.request_approval(action="Deploy main to production")
26
+ decision = sa.wait_for_decision(req["request_id"], timeout=3600)
27
+ proof = sa.verify(decision["decision"]["signature"])
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ api_key: str,
33
+ base_url: str = "https://signedapproval.net",
34
+ ) -> None:
35
+ if not api_key.startswith("sa_live_"):
36
+ raise ValueError("API key must start with sa_live_")
37
+ self._api_key = api_key
38
+ self._base_url = base_url.rstrip("/")
39
+
40
+ # ─── Requests ─────────────────────────────────────────────────────────────
41
+
42
+ def request_approval(
43
+ self,
44
+ action: str,
45
+ context: Optional[dict[str, Any]] = None,
46
+ callback_url: Optional[str] = None,
47
+ require_passkey: bool = False,
48
+ require_totp: bool = False,
49
+ ttl_seconds: int = 3600,
50
+ ) -> dict[str, Any]:
51
+ """Request a human approval. Returns immediately with request_id."""
52
+ body: dict[str, Any] = {
53
+ "action": action,
54
+ "require_passkey": require_passkey,
55
+ "require_totp": require_totp,
56
+ "ttl_seconds": ttl_seconds,
57
+ }
58
+ if context is not None:
59
+ body["context"] = context
60
+ if callback_url is not None:
61
+ body["callback_url"] = callback_url
62
+ return self._post("/api/v1/approvals/request", body)
63
+
64
+ def get_status(self, request_id: str) -> dict[str, Any]:
65
+ """Poll the status of an approval request."""
66
+ return self._get(f"/api/v1/approvals/{request_id}")
67
+
68
+ def wait_for_decision(
69
+ self,
70
+ request_id: str,
71
+ timeout: float = 3600,
72
+ poll_interval: float = 5.0,
73
+ ) -> dict[str, Any]:
74
+ """
75
+ Wait for a human decision, polling every poll_interval seconds.
76
+ Raises SignedApprovalError on timeout or expiry.
77
+ """
78
+ deadline = time.monotonic() + timeout
79
+
80
+ while time.monotonic() < deadline:
81
+ status = self.get_status(request_id)
82
+ if status["status"] != "pending":
83
+ return status
84
+ expires_at = status.get("expires_at", "")
85
+ if expires_at:
86
+ from datetime import datetime, timezone
87
+ expiry = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
88
+ if expiry < datetime.now(timezone.utc):
89
+ raise SignedApprovalError(
90
+ f"Request {request_id} has expired", status_code=410
91
+ )
92
+ time.sleep(min(poll_interval, max(0, deadline - time.monotonic())))
93
+
94
+ raise SignedApprovalError(
95
+ f"Timed out waiting for decision on request {request_id}", status_code=408
96
+ )
97
+
98
+ # ─── Verification ──────────────────────────────────────────────────────────
99
+
100
+ def verify(self, decision_id: str) -> dict[str, Any]:
101
+ """
102
+ Verify a signed approval decision.
103
+ Public endpoint — no API key required.
104
+ Returns { "valid": True } when the Ed25519 signature is valid.
105
+ """
106
+ return self._get_public(f"/api/v1/approvals/{decision_id}/verify")
107
+
108
+ # ─── Private helpers ───────────────────────────────────────────────────────
109
+
110
+ def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
111
+ data = json.dumps(body).encode()
112
+ req = urllib.request.Request(
113
+ f"{self._base_url}{path}",
114
+ data=data,
115
+ headers={
116
+ "Content-Type": "application/json",
117
+ "Authorization": f"Bearer {self._api_key}",
118
+ },
119
+ method="POST",
120
+ )
121
+ return self._send(req)
122
+
123
+ def _get(self, path: str) -> dict[str, Any]:
124
+ req = urllib.request.Request(
125
+ f"{self._base_url}{path}",
126
+ headers={"Authorization": f"Bearer {self._api_key}"},
127
+ )
128
+ return self._send(req)
129
+
130
+ def _get_public(self, path: str) -> dict[str, Any]:
131
+ req = urllib.request.Request(f"{self._base_url}{path}")
132
+ return self._send(req)
133
+
134
+ def _send(self, req: urllib.request.Request) -> dict[str, Any]:
135
+ try:
136
+ with urllib.request.urlopen(req, timeout=30) as resp:
137
+ return json.loads(resp.read())
138
+ except urllib.error.HTTPError as e:
139
+ body = {}
140
+ try:
141
+ body = json.loads(e.read())
142
+ except Exception:
143
+ pass
144
+ message = body.get("error") or f"HTTP {e.code}"
145
+ raise SignedApprovalError(message, status_code=e.code) from e
146
+ except urllib.error.URLError as e:
147
+ raise SignedApprovalError(str(e)) from e