medfunnels 1.0.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.
- medfunnels-1.0.0/LICENSE +21 -0
- medfunnels-1.0.0/PKG-INFO +51 -0
- medfunnels-1.0.0/README.md +31 -0
- medfunnels-1.0.0/medfunnels/__init__.py +28 -0
- medfunnels-1.0.0/medfunnels/canonical.py +28 -0
- medfunnels-1.0.0/medfunnels/client.py +123 -0
- medfunnels-1.0.0/medfunnels/contract.py +236 -0
- medfunnels-1.0.0/medfunnels/frame.py +71 -0
- medfunnels-1.0.0/medfunnels/verify.py +120 -0
- medfunnels-1.0.0/medfunnels.egg-info/PKG-INFO +51 -0
- medfunnels-1.0.0/medfunnels.egg-info/SOURCES.txt +14 -0
- medfunnels-1.0.0/medfunnels.egg-info/dependency_links.txt +1 -0
- medfunnels-1.0.0/medfunnels.egg-info/requires.txt +3 -0
- medfunnels-1.0.0/medfunnels.egg-info/top_level.txt +1 -0
- medfunnels-1.0.0/pyproject.toml +31 -0
- medfunnels-1.0.0/setup.cfg +4 -0
medfunnels-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 medFunnels
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: medfunnels
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Run medFunnels patient questionnaires on your own platform: fetch, verify, host and validate them server-side.
|
|
5
|
+
Author: medFunnels
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://www.medfunnels.app
|
|
8
|
+
Keywords: medfunnels,telehealth,questionnaire,intake,hipaa
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Provides-Extra: verify
|
|
18
|
+
Requires-Dist: cryptography>=42; extra == "verify"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# medfunnels
|
|
22
|
+
|
|
23
|
+
Run [medFunnels](https://www.medfunnels.app) patient questionnaires on your own platform from Python: an
|
|
24
|
+
Integration API client, release verification, the sandboxed frame, and authoritative answer validation.
|
|
25
|
+
**Patient data never goes to medFunnels.**
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install "medfunnels[verify]"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from medfunnels.client import MedFunnelsClient
|
|
33
|
+
from medfunnels.contract import validate_response
|
|
34
|
+
from medfunnels.frame import build_frame
|
|
35
|
+
from medfunnels.verify import verify_release
|
|
36
|
+
|
|
37
|
+
client = MedFunnelsClient("https://www.medfunnels.app", CLIENT_ID, CLIENT_SECRET) # server-side only
|
|
38
|
+
page = client.release_updates(cursor=cursor)
|
|
39
|
+
release_id = page["updates"][0]["id"]
|
|
40
|
+
envelope = client.get_manifest(release_id)
|
|
41
|
+
artifacts = {a["name"]: client.download_artifact(release_id, a["name"]) for a in envelope["manifest"]["artifacts"]}
|
|
42
|
+
release = verify_release(envelope, artifacts, trusted_keys=PINNED_KEYS, supported_kinds=SUPPORTED_KINDS,
|
|
43
|
+
expected_organization_id=ORGANIZATION_ID, expected_origin="https://intake.example.com")
|
|
44
|
+
|
|
45
|
+
html, headers = build_frame(release.bundle, title=release.manifest["questionnaire"]["name"]) # serve both unchanged
|
|
46
|
+
answers = validate_response(release.definition, submitted, questionnaire_id=release.questionnaire_id, release_id=release.release_id)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
No required dependencies; the `verify` extra adds `cryptography` for signature checks. Python 3.10 or later.
|
|
50
|
+
The full guide, with your workspace's values filled in, is in medFunnels under **Integrations → Developer guide**.
|
|
51
|
+
Using Next.js? See [`@medfunnels/next`](https://www.npmjs.com/package/@medfunnels/next).
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# medfunnels
|
|
2
|
+
|
|
3
|
+
Run [medFunnels](https://www.medfunnels.app) patient questionnaires on your own platform from Python: an
|
|
4
|
+
Integration API client, release verification, the sandboxed frame, and authoritative answer validation.
|
|
5
|
+
**Patient data never goes to medFunnels.**
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install "medfunnels[verify]"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from medfunnels.client import MedFunnelsClient
|
|
13
|
+
from medfunnels.contract import validate_response
|
|
14
|
+
from medfunnels.frame import build_frame
|
|
15
|
+
from medfunnels.verify import verify_release
|
|
16
|
+
|
|
17
|
+
client = MedFunnelsClient("https://www.medfunnels.app", CLIENT_ID, CLIENT_SECRET) # server-side only
|
|
18
|
+
page = client.release_updates(cursor=cursor)
|
|
19
|
+
release_id = page["updates"][0]["id"]
|
|
20
|
+
envelope = client.get_manifest(release_id)
|
|
21
|
+
artifacts = {a["name"]: client.download_artifact(release_id, a["name"]) for a in envelope["manifest"]["artifacts"]}
|
|
22
|
+
release = verify_release(envelope, artifacts, trusted_keys=PINNED_KEYS, supported_kinds=SUPPORTED_KINDS,
|
|
23
|
+
expected_organization_id=ORGANIZATION_ID, expected_origin="https://intake.example.com")
|
|
24
|
+
|
|
25
|
+
html, headers = build_frame(release.bundle, title=release.manifest["questionnaire"]["name"]) # serve both unchanged
|
|
26
|
+
answers = validate_response(release.definition, submitted, questionnaire_id=release.questionnaire_id, release_id=release.release_id)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
No required dependencies; the `verify` extra adds `cryptography` for signature checks. Python 3.10 or later.
|
|
30
|
+
The full guide, with your workspace's values filled in, is in medFunnels under **Integrations → Developer guide**.
|
|
31
|
+
Using Next.js? See [`@medfunnels/next`](https://www.npmjs.com/package/@medfunnels/next).
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""medFunnels SDK: fetch, verify, host, and validate published questionnaires on your own platform.
|
|
2
|
+
|
|
3
|
+
Everything here runs on the consumer's own infrastructure. Nothing in this package sends
|
|
4
|
+
patient data to medFunnels.
|
|
5
|
+
"""
|
|
6
|
+
from medfunnels.canonical import canonical_json, csp_hash, sha256_hex
|
|
7
|
+
from medfunnels.client import IntegrationError, MedFunnelsClient
|
|
8
|
+
from medfunnels.contract import ANSWER_CONTRACT, ContractError, NormalizedAnswer, evaluate_condition, validate_response
|
|
9
|
+
from medfunnels.frame import FRAME_SANDBOX, build_frame
|
|
10
|
+
from medfunnels.verify import VerificationError, VerifiedRelease, verify_release
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ANSWER_CONTRACT",
|
|
14
|
+
"ContractError",
|
|
15
|
+
"FRAME_SANDBOX",
|
|
16
|
+
"IntegrationError",
|
|
17
|
+
"MedFunnelsClient",
|
|
18
|
+
"NormalizedAnswer",
|
|
19
|
+
"VerificationError",
|
|
20
|
+
"VerifiedRelease",
|
|
21
|
+
"build_frame",
|
|
22
|
+
"canonical_json",
|
|
23
|
+
"csp_hash",
|
|
24
|
+
"evaluate_condition",
|
|
25
|
+
"sha256_hex",
|
|
26
|
+
"validate_response",
|
|
27
|
+
"verify_release",
|
|
28
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Canonical JSON used for manifest signing. Signer and verifier must share this exact function."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def canonical_json(value: Any) -> bytes:
|
|
11
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False).encode("utf-8")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def sha256_hex(data: bytes) -> str:
|
|
15
|
+
return hashlib.sha256(data).hexdigest()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def csp_hash(script: bytes) -> str:
|
|
19
|
+
"""Value for a CSP `script-src 'sha256-…'` source matching an inline script with exactly these bytes."""
|
|
20
|
+
return "sha256-" + base64.b64encode(hashlib.sha256(script).digest()).decode("ascii")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def b64url(data: bytes) -> str:
|
|
24
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def b64url_decode(value: str) -> bytes:
|
|
28
|
+
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Server-side client for the medicalforms.ai Integration API v1 (stdlib only).
|
|
2
|
+
|
|
3
|
+
Runs on the consumer's backend. The client secret never leaves the server. The client
|
|
4
|
+
exposes read operations only; there is deliberately no method that sends patient data.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import urllib.request
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
USER_AGENT = "medfunnels-python/1.0"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class IntegrationError(RuntimeError):
|
|
21
|
+
"""`code` is stable for diagnostics UIs: network | auth | plan_required | forbidden | not_found | rate_limited | server | protocol."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, code: str, message: str, *, status: int = 0, request_id: str = ""):
|
|
24
|
+
self.code = code
|
|
25
|
+
self.status = status
|
|
26
|
+
self.request_id = request_id
|
|
27
|
+
super().__init__(message)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class _Token:
|
|
32
|
+
value: str
|
|
33
|
+
expires_at: float
|
|
34
|
+
scope: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MedFunnelsClient:
|
|
38
|
+
def __init__(self, base_url: str, client_id: str, client_secret: str, *, timeout: float = 15.0):
|
|
39
|
+
parsed = urllib.parse.urlparse(base_url)
|
|
40
|
+
if parsed.scheme != "https" and parsed.hostname not in {"localhost", "127.0.0.1"}:
|
|
41
|
+
raise ValueError("medicalforms.ai API endpoint must use https.")
|
|
42
|
+
self.base_url = base_url.rstrip("/")
|
|
43
|
+
self.client_id = client_id
|
|
44
|
+
self._client_secret = client_secret
|
|
45
|
+
self.timeout = timeout
|
|
46
|
+
self._token: _Token | None = None
|
|
47
|
+
|
|
48
|
+
def __repr__(self) -> str: # never render the secret in logs or tracebacks
|
|
49
|
+
return f"MedFunnelsClient(base_url={self.base_url!r}, client_id={self.client_id!r})"
|
|
50
|
+
|
|
51
|
+
# -- auth ---------------------------------------------------------------
|
|
52
|
+
def _access_token(self) -> str:
|
|
53
|
+
if self._token and self._token.expires_at - 30 > time.time():
|
|
54
|
+
return self._token.value
|
|
55
|
+
basic = base64.b64encode(f"{self.client_id}:{self._client_secret}".encode()).decode()
|
|
56
|
+
body = urllib.parse.urlencode({"grant_type": "client_credentials"}).encode()
|
|
57
|
+
payload = self._send("POST", "/api/v1/oauth/token", body=body, headers={
|
|
58
|
+
"Authorization": f"Basic {basic}",
|
|
59
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
60
|
+
})
|
|
61
|
+
data = json.loads(payload)
|
|
62
|
+
self._token = _Token(data["access_token"], time.time() + int(data.get("expires_in", 300)), data.get("scope", ""))
|
|
63
|
+
return self._token.value
|
|
64
|
+
|
|
65
|
+
# -- transport ----------------------------------------------------------
|
|
66
|
+
def _send(self, method: str, path: str, *, body: bytes | None = None, headers: dict[str, str] | None = None) -> bytes:
|
|
67
|
+
request = urllib.request.Request(self.base_url + path, data=body, method=method, headers={"User-Agent": USER_AGENT, "Accept": "application/json", **(headers or {})})
|
|
68
|
+
try:
|
|
69
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
70
|
+
return response.read()
|
|
71
|
+
except urllib.error.HTTPError as exc:
|
|
72
|
+
request_id = exc.headers.get("X-Request-Id", "") if exc.headers else ""
|
|
73
|
+
detail = _error_detail(exc)
|
|
74
|
+
code = {400: "protocol", 401: "auth", 402: "plan_required", 403: "forbidden", 404: "not_found", 429: "rate_limited"}.get(exc.code, "server" if exc.code >= 500 else "protocol")
|
|
75
|
+
raise IntegrationError(code, detail or f"HTTP {exc.code}", status=exc.code, request_id=request_id) from None
|
|
76
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
77
|
+
raise IntegrationError("network", f"Could not reach medFunnels: {getattr(exc, 'reason', exc)}") from None
|
|
78
|
+
|
|
79
|
+
def _get(self, path: str, params: dict[str, Any] | None = None, *, accept: str = "application/json") -> bytes:
|
|
80
|
+
query = f"?{urllib.parse.urlencode({k: v for k, v in (params or {}).items() if v not in (None, '')})}" if params else ""
|
|
81
|
+
try:
|
|
82
|
+
return self._send("GET", path + query, headers={"Authorization": f"Bearer {self._access_token()}", "Accept": accept})
|
|
83
|
+
except IntegrationError as exc:
|
|
84
|
+
if exc.code == "auth" and self._token is not None:
|
|
85
|
+
self._token = None # token revoked or expired early: retry once with a fresh token
|
|
86
|
+
return self._send("GET", path + query, headers={"Authorization": f"Bearer {self._access_token()}", "Accept": accept})
|
|
87
|
+
raise
|
|
88
|
+
|
|
89
|
+
def _get_json(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
90
|
+
return json.loads(self._get(path, params))
|
|
91
|
+
|
|
92
|
+
# -- API ----------------------------------------------------------------
|
|
93
|
+
def test_connection(self) -> dict[str, Any]:
|
|
94
|
+
return self._get_json("/api/v1/integration/connection")
|
|
95
|
+
|
|
96
|
+
def list_questionnaires(self, *, query: str = "", cursor: str = "", limit: int = 50) -> dict[str, Any]:
|
|
97
|
+
return self._get_json("/api/v1/questionnaires", {"q": query, "cursor": cursor, "limit": limit})
|
|
98
|
+
|
|
99
|
+
def get_questionnaire(self, questionnaire_id: str) -> dict[str, Any]:
|
|
100
|
+
return self._get_json(f"/api/v1/questionnaires/{urllib.parse.quote(questionnaire_id)}")
|
|
101
|
+
|
|
102
|
+
def list_releases(self, questionnaire_id: str, *, after_version: int = 0) -> dict[str, Any]:
|
|
103
|
+
return self._get_json(f"/api/v1/questionnaires/{urllib.parse.quote(questionnaire_id)}/releases", {"after_version": after_version or None})
|
|
104
|
+
|
|
105
|
+
def release_updates(self, *, cursor: str = "") -> dict[str, Any]:
|
|
106
|
+
return self._get_json("/api/v1/releases/updates", {"cursor": cursor})
|
|
107
|
+
|
|
108
|
+
def get_manifest(self, release_id: str) -> dict[str, Any]:
|
|
109
|
+
return self._get_json(f"/api/v1/releases/{urllib.parse.quote(release_id)}/manifest")
|
|
110
|
+
|
|
111
|
+
def download_artifact(self, release_id: str, name: str) -> bytes:
|
|
112
|
+
return self._get(f"/api/v1/releases/{urllib.parse.quote(release_id)}/artifacts/{urllib.parse.quote(name)}", accept="*/*")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _error_detail(exc: urllib.error.HTTPError) -> str:
|
|
116
|
+
try:
|
|
117
|
+
data = json.loads(exc.read() or b"{}")
|
|
118
|
+
except (ValueError, OSError):
|
|
119
|
+
return ""
|
|
120
|
+
error = data.get("error")
|
|
121
|
+
if isinstance(error, dict):
|
|
122
|
+
return str(error.get("message") or "")
|
|
123
|
+
return str(data.get("detail") or data.get("error_description") or "")
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Server-side answer contract (`medicalforms.answers/v1`).
|
|
2
|
+
|
|
3
|
+
Consumers call `validate_response` on their own backend with the definition they
|
|
4
|
+
synchronized and verified. The browser runtime's validation is a convenience only;
|
|
5
|
+
this module is the authority. Python/JS parity is covered by tests/fixtures/conditions.json.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
ANSWER_CONTRACT = "medicalforms.answers/v1"
|
|
15
|
+
CHOICE_KINDS = {"single_choice", "multi_choice"}
|
|
16
|
+
ANSWERABLE_KINDS = {"text", "textarea", "single_choice", "multi_choice", "boolean", "number", "date"}
|
|
17
|
+
DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
18
|
+
DEFAULT_MAX_TEXT = 2000
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ContractError(ValueError):
|
|
22
|
+
"""Raised when a response cannot be accepted. `errors` maps question keys (or "_") to messages."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, errors: dict[str, str]):
|
|
25
|
+
self.errors = errors
|
|
26
|
+
super().__init__("; ".join(f"{key}: {message}" for key, message in errors.items()))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class NormalizedAnswer:
|
|
31
|
+
question_key: str
|
|
32
|
+
kind: str
|
|
33
|
+
value: Any
|
|
34
|
+
display_value: str
|
|
35
|
+
question: dict[str, Any] = field(repr=False)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_answered(value: Any) -> bool:
|
|
39
|
+
if value is None:
|
|
40
|
+
return False
|
|
41
|
+
if isinstance(value, bool):
|
|
42
|
+
return True
|
|
43
|
+
if isinstance(value, str):
|
|
44
|
+
return value.strip() != ""
|
|
45
|
+
if isinstance(value, list):
|
|
46
|
+
return len(value) > 0
|
|
47
|
+
if isinstance(value, (int, float)):
|
|
48
|
+
return math.isfinite(value)
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def evaluate_condition(condition: Any, answers: dict[str, Any]) -> bool:
|
|
53
|
+
if not isinstance(condition, dict):
|
|
54
|
+
return True
|
|
55
|
+
op = condition.get("op")
|
|
56
|
+
if op == "all":
|
|
57
|
+
return all(evaluate_condition(item, answers) for item in condition.get("conditions") or [])
|
|
58
|
+
if op == "any":
|
|
59
|
+
return any(evaluate_condition(item, answers) for item in condition.get("conditions") or [])
|
|
60
|
+
if op == "not":
|
|
61
|
+
return not evaluate_condition(condition.get("condition"), answers)
|
|
62
|
+
value = answers.get(condition.get("question"))
|
|
63
|
+
if op == "answered":
|
|
64
|
+
return is_answered(value)
|
|
65
|
+
if not is_answered(value):
|
|
66
|
+
return False
|
|
67
|
+
target = condition.get("value")
|
|
68
|
+
if op == "equals":
|
|
69
|
+
return _same(value, target)
|
|
70
|
+
if op == "not_equals":
|
|
71
|
+
return not _same(value, target)
|
|
72
|
+
if op == "in":
|
|
73
|
+
values = condition.get("values")
|
|
74
|
+
return isinstance(values, list) and any(_same(value, item) for item in values)
|
|
75
|
+
if op == "includes":
|
|
76
|
+
return isinstance(value, list) and any(_same(item, target) for item in value)
|
|
77
|
+
if not _is_number(value) or not _is_number(target):
|
|
78
|
+
return False
|
|
79
|
+
return {"gt": value > target, "gte": value >= target, "lt": value < target, "lte": value <= target}.get(op, False)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _is_number(value: Any) -> bool:
|
|
83
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _same(left: Any, right: Any) -> bool:
|
|
87
|
+
# Mirror JS strict equality: booleans never equal numbers.
|
|
88
|
+
if isinstance(left, bool) or isinstance(right, bool):
|
|
89
|
+
return isinstance(left, bool) and isinstance(right, bool) and left == right
|
|
90
|
+
return type(left) is type(right) and left == right or (_is_number(left) and _is_number(right) and left == right)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def prune_hidden(definition: dict[str, Any], answers: dict[str, Any]) -> dict[str, Any]:
|
|
94
|
+
current = dict(answers)
|
|
95
|
+
for _ in range(10):
|
|
96
|
+
nxt: dict[str, Any] = {}
|
|
97
|
+
for page in definition["pages"]:
|
|
98
|
+
page_visible = evaluate_condition(page.get("visibleWhen"), current)
|
|
99
|
+
for question in page["questions"]:
|
|
100
|
+
if page_visible and evaluate_condition(question.get("visibleWhen"), current) and question["key"] in current:
|
|
101
|
+
nxt[question["key"]] = current[question["key"]]
|
|
102
|
+
if len(nxt) == len(current):
|
|
103
|
+
return nxt
|
|
104
|
+
current = nxt
|
|
105
|
+
return current
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def visible_questions(definition: dict[str, Any], answers: dict[str, Any]) -> list[dict[str, Any]]:
|
|
109
|
+
pruned = prune_hidden(definition, answers)
|
|
110
|
+
return [
|
|
111
|
+
question
|
|
112
|
+
for page in definition["pages"]
|
|
113
|
+
if evaluate_condition(page.get("visibleWhen"), pruned)
|
|
114
|
+
for question in page["questions"]
|
|
115
|
+
if evaluate_condition(question.get("visibleWhen"), pruned)
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def answer_error(question: dict[str, Any], value: Any) -> str:
|
|
120
|
+
kind = question["kind"]
|
|
121
|
+
if kind in ("statement", "end"):
|
|
122
|
+
return ""
|
|
123
|
+
if not is_answered(value):
|
|
124
|
+
return "This question is required." if question.get("required", True) else ""
|
|
125
|
+
rules = question.get("validation") or {}
|
|
126
|
+
if kind in {"text", "textarea"}:
|
|
127
|
+
if not isinstance(value, str):
|
|
128
|
+
return "Enter text."
|
|
129
|
+
if len(value) > int(rules.get("maxLength") or DEFAULT_MAX_TEXT):
|
|
130
|
+
return "Answer is too long."
|
|
131
|
+
elif kind == "number":
|
|
132
|
+
if not _is_number(value):
|
|
133
|
+
return "Enter a number."
|
|
134
|
+
if _is_number(rules.get("min")) and value < rules["min"]:
|
|
135
|
+
return f"Enter {rules['min']} or more."
|
|
136
|
+
if _is_number(rules.get("max")) and value > rules["max"]:
|
|
137
|
+
return f"Enter {rules['max']} or less."
|
|
138
|
+
elif kind == "date":
|
|
139
|
+
if not isinstance(value, str) or not DATE_PATTERN.match(value):
|
|
140
|
+
return "Enter a date."
|
|
141
|
+
if rules.get("minDate") and value < rules["minDate"]:
|
|
142
|
+
return "Enter a later date."
|
|
143
|
+
if rules.get("maxDate") and value > rules["maxDate"]:
|
|
144
|
+
return "Enter an earlier date."
|
|
145
|
+
elif kind == "boolean":
|
|
146
|
+
if not isinstance(value, bool):
|
|
147
|
+
return "Choose yes or no."
|
|
148
|
+
elif kind in CHOICE_KINDS:
|
|
149
|
+
keys = [option["key"] for option in question.get("options") or []]
|
|
150
|
+
if kind == "single_choice" and value not in keys:
|
|
151
|
+
return "Choose an option."
|
|
152
|
+
if kind == "multi_choice" and (not isinstance(value, list) or any(item not in keys for item in value) or len(set(value)) != len(value)):
|
|
153
|
+
return "Choose from the options."
|
|
154
|
+
return ""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def display_value(question: dict[str, Any], value: Any) -> str:
|
|
158
|
+
if not is_answered(value):
|
|
159
|
+
return ""
|
|
160
|
+
if question["kind"] == "boolean":
|
|
161
|
+
return "Yes" if value else "No"
|
|
162
|
+
if question["kind"] in CHOICE_KINDS:
|
|
163
|
+
labels = {option["key"]: option["label"] for option in question.get("options") or []}
|
|
164
|
+
keys = value if isinstance(value, list) else [value]
|
|
165
|
+
return ", ".join(labels.get(key, str(key)) for key in keys)
|
|
166
|
+
if isinstance(value, float) and value.is_integer():
|
|
167
|
+
return str(int(value))
|
|
168
|
+
return str(value)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def validate_response(
|
|
172
|
+
definition: dict[str, Any],
|
|
173
|
+
response: Any,
|
|
174
|
+
*,
|
|
175
|
+
questionnaire_id: str,
|
|
176
|
+
release_id: str,
|
|
177
|
+
) -> list[NormalizedAnswer]:
|
|
178
|
+
"""Authoritatively validate a browser-submitted response against a verified definition.
|
|
179
|
+
|
|
180
|
+
Rejects: wrong contract/release, unknown or duplicate keys, answers to hidden
|
|
181
|
+
questions, values outside the declared options/ranges, missing required answers, and
|
|
182
|
+
answers that reach a stop screen (kind "end").
|
|
183
|
+
Returns answers in definition order, with display text for downstream reviewers.
|
|
184
|
+
"""
|
|
185
|
+
if not isinstance(response, dict):
|
|
186
|
+
raise ContractError({"_": "Response must be an object."})
|
|
187
|
+
if response.get("contract") != ANSWER_CONTRACT:
|
|
188
|
+
raise ContractError({"_": "Unsupported answer contract."})
|
|
189
|
+
if response.get("questionnaireId") != questionnaire_id or response.get("releaseId") != release_id:
|
|
190
|
+
raise ContractError({"_": "Response does not belong to the active questionnaire release."})
|
|
191
|
+
items = response.get("answers")
|
|
192
|
+
if not isinstance(items, list) or len(items) > 500:
|
|
193
|
+
raise ContractError({"_": "Answers must be a list."})
|
|
194
|
+
|
|
195
|
+
questions = {question["key"]: question for page in definition["pages"] for question in page["questions"]}
|
|
196
|
+
submitted: dict[str, Any] = {}
|
|
197
|
+
errors: dict[str, str] = {}
|
|
198
|
+
for item in items:
|
|
199
|
+
if not isinstance(item, dict) or not isinstance(item.get("questionKey"), str):
|
|
200
|
+
raise ContractError({"_": "Each answer needs a questionKey."})
|
|
201
|
+
key = item["questionKey"]
|
|
202
|
+
question = questions.get(key)
|
|
203
|
+
if question is None or question["kind"] not in ANSWERABLE_KINDS:
|
|
204
|
+
errors[key] = "Unknown question."
|
|
205
|
+
continue
|
|
206
|
+
if key in submitted:
|
|
207
|
+
errors[key] = "Answered more than once."
|
|
208
|
+
continue
|
|
209
|
+
if item.get("kind") != question["kind"]:
|
|
210
|
+
errors[key] = "Answer kind does not match the question."
|
|
211
|
+
continue
|
|
212
|
+
submitted[key] = item.get("value")
|
|
213
|
+
if errors:
|
|
214
|
+
raise ContractError(errors)
|
|
215
|
+
|
|
216
|
+
visible = visible_questions(definition, submitted)
|
|
217
|
+
# A visible stop screen means the patient was told they can't continue; never accept such a response.
|
|
218
|
+
stopped = [question["key"] for question in visible if question["kind"] == "end"]
|
|
219
|
+
if stopped:
|
|
220
|
+
raise ContractError({stopped[0]: "These answers end the questionnaire at a stop screen, so it cannot be submitted."})
|
|
221
|
+
visible_keys = {question["key"] for question in visible}
|
|
222
|
+
for key in submitted:
|
|
223
|
+
if key not in visible_keys:
|
|
224
|
+
errors[key] = "This question was not shown and cannot be answered."
|
|
225
|
+
for question in visible:
|
|
226
|
+
message = answer_error(question, submitted.get(question["key"]))
|
|
227
|
+
if message:
|
|
228
|
+
errors[question["key"]] = message
|
|
229
|
+
if errors:
|
|
230
|
+
raise ContractError(errors)
|
|
231
|
+
|
|
232
|
+
return [
|
|
233
|
+
NormalizedAnswer(question["key"], question["kind"], submitted[question["key"]], display_value(question, submitted[question["key"]]), question)
|
|
234
|
+
for question in visible
|
|
235
|
+
if question["kind"] in ANSWERABLE_KINDS and is_answered(submitted.get(question["key"]))
|
|
236
|
+
]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Customer-hosted sandbox frame for a verified questionnaire bundle.
|
|
2
|
+
|
|
3
|
+
The consumer serves this HTML from ITS OWN origin. Isolation comes from three layers:
|
|
4
|
+
|
|
5
|
+
1. The host page embeds it with `<iframe sandbox="allow-scripts allow-forms">` (never
|
|
6
|
+
`allow-same-origin`), giving the document an opaque origin: no access to the host's
|
|
7
|
+
cookies, storage, DOM, or authenticated APIs.
|
|
8
|
+
2. The CSP `sandbox` directive re-applies the same sandbox even if the frame URL is opened
|
|
9
|
+
directly as a top-level page.
|
|
10
|
+
3. `script-src` allows only the exact verified bundle bytes (hash source), and
|
|
11
|
+
`connect-src`/`img-src`/`font-src` are `'none'`, so the runtime has no network channel.
|
|
12
|
+
Answers leave only by postMessage to the host page.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import html
|
|
17
|
+
|
|
18
|
+
from medfunnels.canonical import csp_hash
|
|
19
|
+
|
|
20
|
+
FRAME_SANDBOX = "allow-scripts allow-forms"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def frame_csp(script_hash: str, *, frame_ancestors: str = "'self'") -> str:
|
|
24
|
+
return "; ".join(
|
|
25
|
+
[
|
|
26
|
+
"default-src 'none'",
|
|
27
|
+
f"script-src '{script_hash}'",
|
|
28
|
+
"style-src 'unsafe-inline'",
|
|
29
|
+
"img-src 'none'",
|
|
30
|
+
"font-src 'none'",
|
|
31
|
+
"connect-src 'none'",
|
|
32
|
+
"media-src 'none'",
|
|
33
|
+
"object-src 'none'",
|
|
34
|
+
"child-src 'none'",
|
|
35
|
+
"worker-src 'none'",
|
|
36
|
+
"manifest-src 'none'",
|
|
37
|
+
"form-action 'none'",
|
|
38
|
+
"base-uri 'none'",
|
|
39
|
+
f"frame-ancestors {frame_ancestors}",
|
|
40
|
+
f"sandbox {FRAME_SANDBOX}",
|
|
41
|
+
]
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def frame_headers(script_hash: str, *, frame_ancestors: str = "'self'") -> dict[str, str]:
|
|
46
|
+
return {
|
|
47
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
48
|
+
"Content-Security-Policy": frame_csp(script_hash, frame_ancestors=frame_ancestors),
|
|
49
|
+
"X-Content-Type-Options": "nosniff",
|
|
50
|
+
"Referrer-Policy": "no-referrer",
|
|
51
|
+
"Cross-Origin-Resource-Policy": "same-origin",
|
|
52
|
+
"Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=(), clipboard-read=(), clipboard-write=()",
|
|
53
|
+
"Cache-Control": "private, max-age=300",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def build_frame_html(bundle: bytes, *, title: str, lang: str = "en") -> bytes:
|
|
58
|
+
if b"</script" in bundle.lower():
|
|
59
|
+
raise ValueError("Bundle contains a closing script tag and cannot be inlined.")
|
|
60
|
+
head = (
|
|
61
|
+
"<!doctype html>"
|
|
62
|
+
f'<html lang="{html.escape(lang[:20], quote=True)}"><head><meta charset="utf-8">'
|
|
63
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
64
|
+
f"<title>{html.escape(title[:240])}</title></head><body><script>"
|
|
65
|
+
).encode("utf-8")
|
|
66
|
+
return head + bundle + b"</script></body></html>"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build_frame(bundle: bytes, *, title: str, lang: str = "en", frame_ancestors: str = "'self'") -> tuple[bytes, dict[str, str]]:
|
|
70
|
+
"""Return (html, headers). Serve both exactly; the CSP hash binds the page to these bundle bytes."""
|
|
71
|
+
return build_frame_html(bundle, title=title, lang=lang), frame_headers(csp_hash(bundle), frame_ancestors=frame_ancestors)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Release verification: authenticity (Ed25519 signature), integrity (SHA-256), compatibility.
|
|
2
|
+
|
|
3
|
+
Requires the `cryptography` package for signature verification. Consumers must pin the
|
|
4
|
+
medicalforms.ai signing public keys in their own configuration (not fetch-and-trust).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from medfunnels.canonical import b64url_decode, canonical_json, csp_hash, sha256_hex
|
|
13
|
+
from medfunnels.contract import ANSWER_CONTRACT
|
|
14
|
+
|
|
15
|
+
SUPPORTED_MANIFEST_VERSIONS = {1}
|
|
16
|
+
SUPPORTED_RUNTIME_MAJORS = {1}
|
|
17
|
+
BUNDLE_ARTIFACT = "questionnaire.js"
|
|
18
|
+
DEFINITION_ARTIFACT = "definition.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class VerificationError(ValueError):
|
|
22
|
+
def __init__(self, code: str, message: str):
|
|
23
|
+
self.code = code
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class VerifiedRelease:
|
|
29
|
+
manifest: dict[str, Any]
|
|
30
|
+
definition: dict[str, Any]
|
|
31
|
+
bundle: bytes
|
|
32
|
+
bundle_sha256: str
|
|
33
|
+
bundle_csp_hash: str
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def questionnaire_id(self) -> str:
|
|
37
|
+
return self.manifest["questionnaire"]["id"]
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def release_id(self) -> str:
|
|
41
|
+
return self.manifest["release"]["id"]
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def version(self) -> int:
|
|
45
|
+
return int(self.manifest["release"]["version"])
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def verify_signature(envelope: dict[str, Any], trusted_keys: dict[str, str]) -> dict[str, Any]:
|
|
49
|
+
"""Verify a `{manifest, signature}` envelope. `trusted_keys` maps key id → base64url raw Ed25519 public key."""
|
|
50
|
+
try:
|
|
51
|
+
from cryptography.exceptions import InvalidSignature
|
|
52
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
53
|
+
except ImportError as exc: # pragma: no cover - environment guard
|
|
54
|
+
raise VerificationError("crypto_unavailable", "Install `cryptography` to verify release signatures.") from exc
|
|
55
|
+
|
|
56
|
+
manifest = envelope.get("manifest")
|
|
57
|
+
signature = envelope.get("signature") or {}
|
|
58
|
+
if not isinstance(manifest, dict):
|
|
59
|
+
raise VerificationError("malformed", "Manifest envelope is missing its manifest.")
|
|
60
|
+
if signature.get("alg") != "Ed25519":
|
|
61
|
+
raise VerificationError("bad_signature", "Unsupported signature algorithm.")
|
|
62
|
+
key = trusted_keys.get(str(signature.get("keyId") or ""))
|
|
63
|
+
if not key:
|
|
64
|
+
raise VerificationError("untrusted_key", f"Signing key {signature.get('keyId')!r} is not trusted by this installation.")
|
|
65
|
+
try:
|
|
66
|
+
Ed25519PublicKey.from_public_bytes(b64url_decode(key)).verify(b64url_decode(str(signature.get("value") or "")), canonical_json(manifest))
|
|
67
|
+
except (InvalidSignature, ValueError) as exc:
|
|
68
|
+
raise VerificationError("bad_signature", "Release manifest signature is invalid.") from exc
|
|
69
|
+
return manifest
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def check_compatibility(manifest: dict[str, Any], *, supported_kinds: set[str]) -> None:
|
|
73
|
+
if manifest.get("manifestVersion") not in SUPPORTED_MANIFEST_VERSIONS:
|
|
74
|
+
raise VerificationError("incompatible", "Unsupported manifest version.")
|
|
75
|
+
compatibility = manifest.get("compatibility") or {}
|
|
76
|
+
if compatibility.get("answerContract") != ANSWER_CONTRACT:
|
|
77
|
+
raise VerificationError("incompatible", "Unsupported answer contract.")
|
|
78
|
+
runtime_version = str((manifest.get("runtime") or {}).get("version") or "0")
|
|
79
|
+
if int(runtime_version.split(".")[0] or 0) not in SUPPORTED_RUNTIME_MAJORS:
|
|
80
|
+
raise VerificationError("incompatible", f"Unsupported runtime version {runtime_version}.")
|
|
81
|
+
if compatibility.get("requiresNetwork") is not False:
|
|
82
|
+
raise VerificationError("incompatible", "Questionnaire runtime must not require network access.")
|
|
83
|
+
unsupported = set(compatibility.get("questionKinds") or []) - supported_kinds
|
|
84
|
+
if unsupported:
|
|
85
|
+
raise VerificationError("incompatible", f"Unsupported question kinds: {', '.join(sorted(unsupported))}.")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def verify_release(
|
|
89
|
+
envelope: dict[str, Any],
|
|
90
|
+
artifacts: dict[str, bytes],
|
|
91
|
+
*,
|
|
92
|
+
trusted_keys: dict[str, str],
|
|
93
|
+
supported_kinds: set[str],
|
|
94
|
+
expected_organization_id: str | None = None,
|
|
95
|
+
expected_origin: str | None = None,
|
|
96
|
+
) -> VerifiedRelease:
|
|
97
|
+
"""`expected_origin` (e.g. "https://intake.example.com") rejects releases locked to another site."""
|
|
98
|
+
manifest = verify_signature(envelope, trusted_keys)
|
|
99
|
+
if expected_organization_id and manifest.get("organizationId") != expected_organization_id:
|
|
100
|
+
raise VerificationError("wrong_tenant", "Release belongs to a different organization.")
|
|
101
|
+
allowed = (manifest.get("deployment") or {}).get("allowedOrigins") or []
|
|
102
|
+
if expected_origin and allowed and expected_origin.rstrip("/") not in allowed:
|
|
103
|
+
raise VerificationError("wrong_origin", f"Release is locked to {', '.join(allowed)}, not {expected_origin}.")
|
|
104
|
+
if (manifest.get("release") or {}).get("status") != "published":
|
|
105
|
+
raise VerificationError("not_published", "Release is not in published status.")
|
|
106
|
+
check_compatibility(manifest, supported_kinds=supported_kinds)
|
|
107
|
+
|
|
108
|
+
declared = {item["name"]: item for item in manifest.get("artifacts") or []}
|
|
109
|
+
for name in (BUNDLE_ARTIFACT, DEFINITION_ARTIFACT):
|
|
110
|
+
meta = declared.get(name)
|
|
111
|
+
data = artifacts.get(name)
|
|
112
|
+
if meta is None or data is None:
|
|
113
|
+
raise VerificationError("missing_artifact", f"Release artifact {name} is missing.")
|
|
114
|
+
if len(data) != int(meta.get("size", -1)) or sha256_hex(data) != meta.get("sha256"):
|
|
115
|
+
raise VerificationError("digest_mismatch", f"Artifact {name} does not match its signed digest.")
|
|
116
|
+
bundle = artifacts[BUNDLE_ARTIFACT]
|
|
117
|
+
if declared[BUNDLE_ARTIFACT].get("cspHash") != csp_hash(bundle):
|
|
118
|
+
raise VerificationError("digest_mismatch", "Bundle CSP hash does not match.")
|
|
119
|
+
definition = json.loads(artifacts[DEFINITION_ARTIFACT].decode("utf-8"))
|
|
120
|
+
return VerifiedRelease(manifest, definition, bundle, sha256_hex(bundle), csp_hash(bundle))
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: medfunnels
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Run medFunnels patient questionnaires on your own platform: fetch, verify, host and validate them server-side.
|
|
5
|
+
Author: medFunnels
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://www.medfunnels.app
|
|
8
|
+
Keywords: medfunnels,telehealth,questionnaire,intake,hipaa
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Provides-Extra: verify
|
|
18
|
+
Requires-Dist: cryptography>=42; extra == "verify"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# medfunnels
|
|
22
|
+
|
|
23
|
+
Run [medFunnels](https://www.medfunnels.app) patient questionnaires on your own platform from Python: an
|
|
24
|
+
Integration API client, release verification, the sandboxed frame, and authoritative answer validation.
|
|
25
|
+
**Patient data never goes to medFunnels.**
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install "medfunnels[verify]"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from medfunnels.client import MedFunnelsClient
|
|
33
|
+
from medfunnels.contract import validate_response
|
|
34
|
+
from medfunnels.frame import build_frame
|
|
35
|
+
from medfunnels.verify import verify_release
|
|
36
|
+
|
|
37
|
+
client = MedFunnelsClient("https://www.medfunnels.app", CLIENT_ID, CLIENT_SECRET) # server-side only
|
|
38
|
+
page = client.release_updates(cursor=cursor)
|
|
39
|
+
release_id = page["updates"][0]["id"]
|
|
40
|
+
envelope = client.get_manifest(release_id)
|
|
41
|
+
artifacts = {a["name"]: client.download_artifact(release_id, a["name"]) for a in envelope["manifest"]["artifacts"]}
|
|
42
|
+
release = verify_release(envelope, artifacts, trusted_keys=PINNED_KEYS, supported_kinds=SUPPORTED_KINDS,
|
|
43
|
+
expected_organization_id=ORGANIZATION_ID, expected_origin="https://intake.example.com")
|
|
44
|
+
|
|
45
|
+
html, headers = build_frame(release.bundle, title=release.manifest["questionnaire"]["name"]) # serve both unchanged
|
|
46
|
+
answers = validate_response(release.definition, submitted, questionnaire_id=release.questionnaire_id, release_id=release.release_id)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
No required dependencies; the `verify` extra adds `cryptography` for signature checks. Python 3.10 or later.
|
|
50
|
+
The full guide, with your workspace's values filled in, is in medFunnels under **Integrations → Developer guide**.
|
|
51
|
+
Using Next.js? See [`@medfunnels/next`](https://www.npmjs.com/package/@medfunnels/next).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
medfunnels/__init__.py
|
|
5
|
+
medfunnels/canonical.py
|
|
6
|
+
medfunnels/client.py
|
|
7
|
+
medfunnels/contract.py
|
|
8
|
+
medfunnels/frame.py
|
|
9
|
+
medfunnels/verify.py
|
|
10
|
+
medfunnels.egg-info/PKG-INFO
|
|
11
|
+
medfunnels.egg-info/SOURCES.txt
|
|
12
|
+
medfunnels.egg-info/dependency_links.txt
|
|
13
|
+
medfunnels.egg-info/requires.txt
|
|
14
|
+
medfunnels.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
medfunnels
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "medfunnels"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Run medFunnels patient questionnaires on your own platform: fetch, verify, host and validate them server-side."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "medFunnels" }]
|
|
13
|
+
keywords = ["medfunnels", "telehealth", "questionnaire", "intake", "hipaa"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Scientific/Engineering :: Medical Science Apps.",
|
|
20
|
+
]
|
|
21
|
+
requires-python = ">=3.10"
|
|
22
|
+
dependencies = []
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
verify = ["cryptography>=42"]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://www.medfunnels.app"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
include = ["medfunnels*"]
|