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.
@@ -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,3 @@
1
+
2
+ [verify]
3
+ cryptography>=42
@@ -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*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+