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
|