@medfunnels/sdk 0.0.0-stage → 1.0.0
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.
- package/LICENSE +21 -0
- package/README.md +33 -2
- package/dist/canonical.d.ts +5 -0
- package/dist/canonical.js +53 -0
- package/dist/client.d.ts +42 -0
- package/dist/client.js +118 -0
- package/dist/contract.d.ts +51 -0
- package/dist/contract.js +239 -0
- package/dist/frame.d.ts +16 -0
- package/dist/frame.js +55 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +7 -0
- package/dist/verify.d.ts +78 -0
- package/dist/verify.js +98 -0
- package/package.json +19 -4
package/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.
|
package/README.md
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @medfunnels/sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Server-side building blocks for running [medFunnels](https://www.medfunnels.app) questionnaires on your own
|
|
4
|
+
platform from Node.js: an Integration API client, release verification, the sandboxed frame, and authoritative
|
|
5
|
+
answer validation. **Using Next.js? Install [`@medfunnels/next`](https://www.npmjs.com/package/@medfunnels/next) instead.**
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @medfunnels/sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { MedFunnelsClient, buildFrame, validateResponse, verifyRelease } from "@medfunnels/sdk";
|
|
13
|
+
|
|
14
|
+
const client = new MedFunnelsClient({ baseUrl: "https://www.medfunnels.app", clientId, clientSecret });
|
|
15
|
+
const { updates } = await client.releaseUpdates({ cursor });
|
|
16
|
+
const envelope = await client.getManifest(updates[0].id);
|
|
17
|
+
const artifacts = {
|
|
18
|
+
"questionnaire.js": await client.downloadArtifact(updates[0].id, "questionnaire.js"),
|
|
19
|
+
"definition.json": await client.downloadArtifact(updates[0].id, "definition.json"),
|
|
20
|
+
};
|
|
21
|
+
const release = verifyRelease(envelope, artifacts, {
|
|
22
|
+
trustedKeys: PINNED_KEYS, // from your developer guide; never fetched at runtime
|
|
23
|
+
supportedKinds: SUPPORTED_KINDS,
|
|
24
|
+
expectedOrganizationId: ORGANIZATION_ID,
|
|
25
|
+
expectedOrigin: "https://intake.example.com",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const { html, headers } = buildFrame(release.bundle, { title: release.manifest.questionnaire.name }); // serve both unchanged
|
|
29
|
+
const answers = validateResponse(release.definition, submitted, { questionnaireId: release.questionnaireId, releaseId: release.releaseId });
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The client is read-only: there is no method that sends patient data. It mirrors the Python SDK
|
|
33
|
+
([`medfunnels`](https://pypi.org/project/medfunnels/)) function for function, and both pass the same conformance
|
|
34
|
+
cases. Requires Node.js 18.17 or later. The full guide is in medFunnels under **Integrations → Developer guide**.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function canonicalJson(value: unknown): Uint8Array;
|
|
2
|
+
export declare function sha256Hex(data: Uint8Array): string;
|
|
3
|
+
/** Value for a CSP `script-src 'sha256-…'` source matching an inline script with exactly these bytes. */
|
|
4
|
+
export declare function cspHash(script: Uint8Array): string;
|
|
5
|
+
export declare function b64urlDecode(value: string): Uint8Array;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Canonical JSON used for manifest signatures. Byte-identical to the Python SDK's
|
|
2
|
+
// json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
3
|
+
// for the values manifests contain. Signed manifests never contain decimal numbers (JavaScript can't
|
|
4
|
+
// tell 1.0 from 1), so non-integers are rejected rather than silently encoded differently.
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
const encoder = new TextEncoder();
|
|
7
|
+
/** Python sorts dict keys by code point; JavaScript's default sort compares UTF-16 units. */
|
|
8
|
+
function compareCodePoints(a, b) {
|
|
9
|
+
const left = Array.from(a);
|
|
10
|
+
const right = Array.from(b);
|
|
11
|
+
for (let i = 0; i < Math.min(left.length, right.length); i += 1) {
|
|
12
|
+
const diff = (left[i].codePointAt(0) ?? 0) - (right[i].codePointAt(0) ?? 0);
|
|
13
|
+
if (diff !== 0)
|
|
14
|
+
return diff;
|
|
15
|
+
}
|
|
16
|
+
return left.length - right.length;
|
|
17
|
+
}
|
|
18
|
+
function serialize(value) {
|
|
19
|
+
if (value === null)
|
|
20
|
+
return "null";
|
|
21
|
+
if (typeof value === "boolean")
|
|
22
|
+
return value ? "true" : "false";
|
|
23
|
+
if (typeof value === "number") {
|
|
24
|
+
if (!Number.isSafeInteger(value))
|
|
25
|
+
throw new TypeError("Canonical JSON supports integers only.");
|
|
26
|
+
return String(value === 0 ? 0 : value);
|
|
27
|
+
}
|
|
28
|
+
if (typeof value === "string")
|
|
29
|
+
return JSON.stringify(value);
|
|
30
|
+
if (Array.isArray(value))
|
|
31
|
+
return `[${value.map(serialize).join(",")}]`;
|
|
32
|
+
if (typeof value === "object") {
|
|
33
|
+
const entries = Object.keys(value)
|
|
34
|
+
.filter((key) => value[key] !== undefined)
|
|
35
|
+
.sort(compareCodePoints)
|
|
36
|
+
.map((key) => `${JSON.stringify(key)}:${serialize(value[key])}`);
|
|
37
|
+
return `{${entries.join(",")}}`;
|
|
38
|
+
}
|
|
39
|
+
throw new TypeError(`Canonical JSON can't encode ${typeof value}.`);
|
|
40
|
+
}
|
|
41
|
+
export function canonicalJson(value) {
|
|
42
|
+
return encoder.encode(serialize(value));
|
|
43
|
+
}
|
|
44
|
+
export function sha256Hex(data) {
|
|
45
|
+
return createHash("sha256").update(data).digest("hex");
|
|
46
|
+
}
|
|
47
|
+
/** Value for a CSP `script-src 'sha256-…'` source matching an inline script with exactly these bytes. */
|
|
48
|
+
export function cspHash(script) {
|
|
49
|
+
return `sha256-${createHash("sha256").update(script).digest("base64")}`;
|
|
50
|
+
}
|
|
51
|
+
export function b64urlDecode(value) {
|
|
52
|
+
return new Uint8Array(Buffer.from(value, "base64url"));
|
|
53
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type IntegrationErrorCode = "network" | "auth" | "forbidden" | "not_found" | "rate_limited" | "server" | "protocol" | "plan_required";
|
|
2
|
+
export declare class IntegrationError extends Error {
|
|
3
|
+
readonly code: IntegrationErrorCode;
|
|
4
|
+
readonly status: number;
|
|
5
|
+
readonly requestId: string;
|
|
6
|
+
constructor(code: IntegrationErrorCode, message: string, options?: {
|
|
7
|
+
status?: number;
|
|
8
|
+
requestId?: string;
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
export interface ClientOptions {
|
|
12
|
+
baseUrl: string;
|
|
13
|
+
clientId: string;
|
|
14
|
+
clientSecret: string;
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
}
|
|
17
|
+
export declare class MedFunnelsClient {
|
|
18
|
+
#private;
|
|
19
|
+
readonly baseUrl: string;
|
|
20
|
+
readonly clientId: string;
|
|
21
|
+
constructor(options: ClientOptions);
|
|
22
|
+
/** Never print the secret in logs. */
|
|
23
|
+
toJSON(): {
|
|
24
|
+
baseUrl: string;
|
|
25
|
+
clientId: string;
|
|
26
|
+
};
|
|
27
|
+
testConnection(): Promise<Record<string, any>>;
|
|
28
|
+
listQuestionnaires(options?: {
|
|
29
|
+
query?: string;
|
|
30
|
+
cursor?: string;
|
|
31
|
+
limit?: number;
|
|
32
|
+
}): Promise<Record<string, any>>;
|
|
33
|
+
getQuestionnaire(questionnaireId: string): Promise<Record<string, any>>;
|
|
34
|
+
listReleases(questionnaireId: string, options?: {
|
|
35
|
+
afterVersion?: number;
|
|
36
|
+
}): Promise<Record<string, any>>;
|
|
37
|
+
releaseUpdates(options?: {
|
|
38
|
+
cursor?: string;
|
|
39
|
+
}): Promise<Record<string, any>>;
|
|
40
|
+
getManifest(releaseId: string): Promise<Record<string, any>>;
|
|
41
|
+
downloadArtifact(releaseId: string, name: string): Promise<Uint8Array>;
|
|
42
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Server-side client for the medFunnels Integration API v1. Runs on your backend only: the client secret must
|
|
2
|
+
// never reach a browser. Read operations only; there is deliberately no method that sends patient data.
|
|
3
|
+
const USER_AGENT = "medfunnels-node/1.0";
|
|
4
|
+
export class IntegrationError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
status;
|
|
7
|
+
requestId;
|
|
8
|
+
constructor(code, message, options = {}) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "IntegrationError";
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.status = options.status ?? 0;
|
|
13
|
+
this.requestId = options.requestId ?? "";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const STATUS_CODES = { 400: "protocol", 401: "auth", 402: "plan_required", 403: "forbidden", 404: "not_found", 429: "rate_limited" };
|
|
17
|
+
export class MedFunnelsClient {
|
|
18
|
+
baseUrl;
|
|
19
|
+
clientId;
|
|
20
|
+
#clientSecret;
|
|
21
|
+
#timeoutMs;
|
|
22
|
+
#token = null;
|
|
23
|
+
constructor(options) {
|
|
24
|
+
const url = new URL(options.baseUrl);
|
|
25
|
+
if (url.protocol !== "https:" && !["localhost", "127.0.0.1"].includes(url.hostname)) {
|
|
26
|
+
throw new Error("The medFunnels API endpoint must use https.");
|
|
27
|
+
}
|
|
28
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
29
|
+
this.clientId = options.clientId;
|
|
30
|
+
this.#clientSecret = options.clientSecret;
|
|
31
|
+
this.#timeoutMs = options.timeoutMs ?? 15_000;
|
|
32
|
+
}
|
|
33
|
+
/** Never print the secret in logs. */
|
|
34
|
+
toJSON() {
|
|
35
|
+
return { baseUrl: this.baseUrl, clientId: this.clientId };
|
|
36
|
+
}
|
|
37
|
+
async #send(method, path, init = {}) {
|
|
38
|
+
let response;
|
|
39
|
+
try {
|
|
40
|
+
response = await fetch(this.baseUrl + path, {
|
|
41
|
+
method,
|
|
42
|
+
body: init.body,
|
|
43
|
+
headers: { "User-Agent": USER_AGENT, Accept: "application/json", ...init.headers },
|
|
44
|
+
signal: AbortSignal.timeout(this.#timeoutMs),
|
|
45
|
+
redirect: "error",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
throw new IntegrationError("network", `Could not reach medFunnels: ${error instanceof Error ? error.message : String(error)}`);
|
|
50
|
+
}
|
|
51
|
+
if (response.ok)
|
|
52
|
+
return response;
|
|
53
|
+
const requestId = response.headers.get("X-Request-Id") ?? "";
|
|
54
|
+
let detail = "";
|
|
55
|
+
try {
|
|
56
|
+
const data = (await response.json());
|
|
57
|
+
const error = data.error;
|
|
58
|
+
detail = typeof error === "object" && error !== null ? String(error.message ?? "") : String(data.detail ?? data.error_description ?? "");
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
detail = "";
|
|
62
|
+
}
|
|
63
|
+
const code = STATUS_CODES[response.status] ?? (response.status >= 500 ? "server" : "protocol");
|
|
64
|
+
throw new IntegrationError(code, detail || `HTTP ${response.status}`, { status: response.status, requestId });
|
|
65
|
+
}
|
|
66
|
+
async #accessToken() {
|
|
67
|
+
if (this.#token && this.#token.expiresAt - 30_000 > Date.now())
|
|
68
|
+
return this.#token.value;
|
|
69
|
+
const basic = Buffer.from(`${this.clientId}:${this.#clientSecret}`).toString("base64");
|
|
70
|
+
const response = await this.#send("POST", "/api/v1/oauth/token", {
|
|
71
|
+
body: new URLSearchParams({ grant_type: "client_credentials" }).toString(),
|
|
72
|
+
headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/x-www-form-urlencoded" },
|
|
73
|
+
});
|
|
74
|
+
const data = (await response.json());
|
|
75
|
+
this.#token = { value: data.access_token, expiresAt: Date.now() + Number(data.expires_in ?? 300) * 1000 };
|
|
76
|
+
return this.#token.value;
|
|
77
|
+
}
|
|
78
|
+
async #get(path, params = {}, accept = "application/json") {
|
|
79
|
+
const query = new URLSearchParams(Object.entries(params).filter(([, v]) => v !== undefined && v !== "").map(([k, v]) => [k, String(v)])).toString();
|
|
80
|
+
const target = query ? `${path}?${query}` : path;
|
|
81
|
+
const request = async () => this.#send("GET", target, { headers: { Authorization: `Bearer ${await this.#accessToken()}`, Accept: accept } });
|
|
82
|
+
try {
|
|
83
|
+
return await request();
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (error instanceof IntegrationError && error.code === "auth" && this.#token) {
|
|
87
|
+
this.#token = null; // revoked or expired early: retry once with a fresh token
|
|
88
|
+
return request();
|
|
89
|
+
}
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async #json(path, params) {
|
|
94
|
+
return (await (await this.#get(path, params)).json());
|
|
95
|
+
}
|
|
96
|
+
testConnection() {
|
|
97
|
+
return this.#json("/api/v1/integration/connection");
|
|
98
|
+
}
|
|
99
|
+
listQuestionnaires(options = {}) {
|
|
100
|
+
return this.#json("/api/v1/questionnaires", { q: options.query, cursor: options.cursor, limit: options.limit ?? 50 });
|
|
101
|
+
}
|
|
102
|
+
getQuestionnaire(questionnaireId) {
|
|
103
|
+
return this.#json(`/api/v1/questionnaires/${encodeURIComponent(questionnaireId)}`);
|
|
104
|
+
}
|
|
105
|
+
listReleases(questionnaireId, options = {}) {
|
|
106
|
+
return this.#json(`/api/v1/questionnaires/${encodeURIComponent(questionnaireId)}/releases`, { after_version: options.afterVersion || undefined });
|
|
107
|
+
}
|
|
108
|
+
releaseUpdates(options = {}) {
|
|
109
|
+
return this.#json("/api/v1/releases/updates", { cursor: options.cursor });
|
|
110
|
+
}
|
|
111
|
+
getManifest(releaseId) {
|
|
112
|
+
return this.#json(`/api/v1/releases/${encodeURIComponent(releaseId)}/manifest`);
|
|
113
|
+
}
|
|
114
|
+
async downloadArtifact(releaseId, name) {
|
|
115
|
+
const response = await this.#get(`/api/v1/releases/${encodeURIComponent(releaseId)}/artifacts/${encodeURIComponent(name)}`, {}, "*/*");
|
|
116
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export declare const ANSWER_CONTRACT = "medicalforms.answers/v1";
|
|
2
|
+
export type Answers = Record<string, unknown>;
|
|
3
|
+
export interface Question {
|
|
4
|
+
key: string;
|
|
5
|
+
kind: string;
|
|
6
|
+
required?: boolean;
|
|
7
|
+
validation?: Record<string, unknown>;
|
|
8
|
+
options?: Array<{
|
|
9
|
+
key: string;
|
|
10
|
+
label: string;
|
|
11
|
+
}>;
|
|
12
|
+
visibleWhen?: unknown;
|
|
13
|
+
[field: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface Definition {
|
|
16
|
+
pages: Array<{
|
|
17
|
+
key?: string;
|
|
18
|
+
visibleWhen?: unknown;
|
|
19
|
+
questions: Question[];
|
|
20
|
+
[field: string]: unknown;
|
|
21
|
+
}>;
|
|
22
|
+
[field: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
export interface NormalizedAnswer {
|
|
25
|
+
questionKey: string;
|
|
26
|
+
kind: string;
|
|
27
|
+
value: unknown;
|
|
28
|
+
/** Readable text for reviewers, e.g. option labels or "Yes". */
|
|
29
|
+
displayValue: string;
|
|
30
|
+
question: Question;
|
|
31
|
+
}
|
|
32
|
+
/** `errors` maps question keys (or "_" for the whole response) to messages to show the patient. */
|
|
33
|
+
export declare class ContractError extends Error {
|
|
34
|
+
readonly errors: Record<string, string>;
|
|
35
|
+
constructor(errors: Record<string, string>);
|
|
36
|
+
}
|
|
37
|
+
export declare function isAnswered(value: unknown): boolean;
|
|
38
|
+
export declare function evaluateCondition(condition: unknown, answers: Answers | Map<string, unknown>): boolean;
|
|
39
|
+
export declare function visibleQuestions(definition: Definition, answers: Answers | Map<string, unknown>): Question[];
|
|
40
|
+
export declare function answerError(question: Question, value: unknown): string;
|
|
41
|
+
export declare function displayValue(question: Question, value: unknown): string;
|
|
42
|
+
/**
|
|
43
|
+
* Validate a browser-submitted response against a verified definition. Rejects the wrong contract or release,
|
|
44
|
+
* unknown or duplicate questions, answers to questions the patient never saw, values outside the allowed
|
|
45
|
+
* options or ranges, missing required answers, and answers that reach a stop screen. Returns answers in
|
|
46
|
+
* definition order with display text for reviewers. Throws ContractError otherwise.
|
|
47
|
+
*/
|
|
48
|
+
export declare function validateResponse(definition: Definition, response: unknown, options: {
|
|
49
|
+
questionnaireId: string;
|
|
50
|
+
releaseId: string;
|
|
51
|
+
}): NormalizedAnswer[];
|
package/dist/contract.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// Server-side answer contract (`medicalforms.answers/v1`), a line-by-line port of the Python SDK's contract.py.
|
|
2
|
+
// Call validateResponse on your own server with the definition you synced and verified. The browser's checks
|
|
3
|
+
// are a convenience only; this is the authority. Both SDKs pass the same cases in sdk/conformance.
|
|
4
|
+
export const ANSWER_CONTRACT = "medicalforms.answers/v1";
|
|
5
|
+
const CHOICE_KINDS = new Set(["single_choice", "multi_choice"]);
|
|
6
|
+
const ANSWERABLE_KINDS = new Set(["text", "textarea", "single_choice", "multi_choice", "boolean", "number", "date"]);
|
|
7
|
+
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
8
|
+
const DEFAULT_MAX_TEXT = 2000;
|
|
9
|
+
/** `errors` maps question keys (or "_" for the whole response) to messages to show the patient. */
|
|
10
|
+
export class ContractError extends Error {
|
|
11
|
+
errors;
|
|
12
|
+
constructor(errors) {
|
|
13
|
+
super(Object.entries(errors).map(([key, message]) => `${key}: ${message}`).join("; "));
|
|
14
|
+
this.name = "ContractError";
|
|
15
|
+
this.errors = errors;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const isNumber = (value) => typeof value === "number" && Number.isFinite(value);
|
|
19
|
+
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
20
|
+
const length = (text) => Array.from(text).length; // code points, like Python's len()
|
|
21
|
+
/** Python's str() for numbers (e.g. 1e-05), so messages and display text match the Python SDK. */
|
|
22
|
+
function numberText(value) {
|
|
23
|
+
if (Number.isInteger(value) || Math.abs(value) >= 1e-4)
|
|
24
|
+
return String(value);
|
|
25
|
+
const [mantissa, exponent] = value.toExponential().split("e");
|
|
26
|
+
const sign = exponent.startsWith("-") ? "-" : "+";
|
|
27
|
+
return `${mantissa}e${sign}${exponent.replace(/^[+-]/, "").padStart(2, "0")}`;
|
|
28
|
+
}
|
|
29
|
+
export function isAnswered(value) {
|
|
30
|
+
if (value === null || value === undefined)
|
|
31
|
+
return false;
|
|
32
|
+
if (typeof value === "boolean")
|
|
33
|
+
return true;
|
|
34
|
+
if (typeof value === "string")
|
|
35
|
+
return value.trim() !== "";
|
|
36
|
+
if (Array.isArray(value))
|
|
37
|
+
return value.length > 0;
|
|
38
|
+
if (typeof value === "number")
|
|
39
|
+
return Number.isFinite(value);
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
/** Python equality, except booleans never equal numbers (mirrors the browser runtime). */
|
|
43
|
+
function same(left, right) {
|
|
44
|
+
if (typeof left === "boolean" || typeof right === "boolean")
|
|
45
|
+
return left === right;
|
|
46
|
+
if (isNumber(left) && isNumber(right))
|
|
47
|
+
return left === right;
|
|
48
|
+
if (Array.isArray(left) && Array.isArray(right))
|
|
49
|
+
return left.length === right.length && left.every((item, i) => same(item, right[i]));
|
|
50
|
+
if (isObject(left) && isObject(right)) {
|
|
51
|
+
const keys = Object.keys(left);
|
|
52
|
+
return keys.length === Object.keys(right).length && keys.every((key) => key in right && same(left[key], right[key]));
|
|
53
|
+
}
|
|
54
|
+
return left === right;
|
|
55
|
+
}
|
|
56
|
+
function lookup(answers, key) {
|
|
57
|
+
if (typeof key !== "string")
|
|
58
|
+
return undefined;
|
|
59
|
+
return answers instanceof Map ? answers.get(key) : Object.prototype.hasOwnProperty.call(answers, key) ? answers[key] : undefined;
|
|
60
|
+
}
|
|
61
|
+
export function evaluateCondition(condition, answers) {
|
|
62
|
+
if (!isObject(condition))
|
|
63
|
+
return true;
|
|
64
|
+
const op = condition.op;
|
|
65
|
+
if (op === "all")
|
|
66
|
+
return (Array.isArray(condition.conditions) ? condition.conditions : []).every((item) => evaluateCondition(item, answers));
|
|
67
|
+
if (op === "any")
|
|
68
|
+
return (Array.isArray(condition.conditions) ? condition.conditions : []).some((item) => evaluateCondition(item, answers));
|
|
69
|
+
if (op === "not")
|
|
70
|
+
return !evaluateCondition(condition.condition, answers);
|
|
71
|
+
const value = lookup(answers, condition.question);
|
|
72
|
+
if (op === "answered")
|
|
73
|
+
return isAnswered(value);
|
|
74
|
+
if (!isAnswered(value))
|
|
75
|
+
return false;
|
|
76
|
+
const target = condition.value;
|
|
77
|
+
if (op === "equals")
|
|
78
|
+
return same(value, target);
|
|
79
|
+
if (op === "not_equals")
|
|
80
|
+
return !same(value, target);
|
|
81
|
+
if (op === "in")
|
|
82
|
+
return Array.isArray(condition.values) && condition.values.some((item) => same(value, item));
|
|
83
|
+
if (op === "includes")
|
|
84
|
+
return Array.isArray(value) && value.some((item) => same(item, target));
|
|
85
|
+
if (!isNumber(value) || !isNumber(target))
|
|
86
|
+
return false;
|
|
87
|
+
if (op === "gt")
|
|
88
|
+
return value > target;
|
|
89
|
+
if (op === "gte")
|
|
90
|
+
return value >= target;
|
|
91
|
+
if (op === "lt")
|
|
92
|
+
return value < target;
|
|
93
|
+
if (op === "lte")
|
|
94
|
+
return value <= target;
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
function pruneHidden(definition, answers) {
|
|
98
|
+
let current = new Map(answers);
|
|
99
|
+
for (let round = 0; round < 10; round += 1) {
|
|
100
|
+
const next = new Map();
|
|
101
|
+
for (const page of definition.pages) {
|
|
102
|
+
const pageVisible = evaluateCondition(page.visibleWhen, current);
|
|
103
|
+
for (const question of page.questions) {
|
|
104
|
+
if (pageVisible && evaluateCondition(question.visibleWhen, current) && current.has(question.key))
|
|
105
|
+
next.set(question.key, current.get(question.key));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (next.size === current.size)
|
|
109
|
+
return next;
|
|
110
|
+
current = next;
|
|
111
|
+
}
|
|
112
|
+
return current;
|
|
113
|
+
}
|
|
114
|
+
export function visibleQuestions(definition, answers) {
|
|
115
|
+
const pruned = pruneHidden(definition, answers instanceof Map ? answers : new Map(Object.entries(answers)));
|
|
116
|
+
return definition.pages
|
|
117
|
+
.filter((page) => evaluateCondition(page.visibleWhen, pruned))
|
|
118
|
+
.flatMap((page) => page.questions.filter((question) => evaluateCondition(question.visibleWhen, pruned)));
|
|
119
|
+
}
|
|
120
|
+
export function answerError(question, value) {
|
|
121
|
+
const kind = question.kind;
|
|
122
|
+
if (kind === "statement" || kind === "end")
|
|
123
|
+
return "";
|
|
124
|
+
// Like Python's question.get("required", True): absent means required, an explicit null means optional.
|
|
125
|
+
const required = "required" in question ? Boolean(question.required) : true;
|
|
126
|
+
if (!isAnswered(value))
|
|
127
|
+
return required ? "This question is required." : "";
|
|
128
|
+
const rules = isObject(question.validation) ? question.validation : {};
|
|
129
|
+
if (kind === "text" || kind === "textarea") {
|
|
130
|
+
if (typeof value !== "string")
|
|
131
|
+
return "Enter text.";
|
|
132
|
+
if (length(value) > Math.trunc(Number(rules.maxLength) || DEFAULT_MAX_TEXT))
|
|
133
|
+
return "Answer is too long.";
|
|
134
|
+
}
|
|
135
|
+
else if (kind === "number") {
|
|
136
|
+
if (!isNumber(value))
|
|
137
|
+
return "Enter a number.";
|
|
138
|
+
if (isNumber(rules.min) && value < rules.min)
|
|
139
|
+
return `Enter ${numberText(rules.min)} or more.`;
|
|
140
|
+
if (isNumber(rules.max) && value > rules.max)
|
|
141
|
+
return `Enter ${numberText(rules.max)} or less.`;
|
|
142
|
+
}
|
|
143
|
+
else if (kind === "date") {
|
|
144
|
+
if (typeof value !== "string" || !DATE_PATTERN.test(value))
|
|
145
|
+
return "Enter a date.";
|
|
146
|
+
if (rules.minDate && value < String(rules.minDate))
|
|
147
|
+
return "Enter a later date.";
|
|
148
|
+
if (rules.maxDate && value > String(rules.maxDate))
|
|
149
|
+
return "Enter an earlier date.";
|
|
150
|
+
}
|
|
151
|
+
else if (kind === "boolean") {
|
|
152
|
+
if (typeof value !== "boolean")
|
|
153
|
+
return "Choose yes or no.";
|
|
154
|
+
}
|
|
155
|
+
else if (CHOICE_KINDS.has(kind)) {
|
|
156
|
+
const keys = (question.options ?? []).map((option) => option.key);
|
|
157
|
+
if (kind === "single_choice" && !keys.includes(value))
|
|
158
|
+
return "Choose an option.";
|
|
159
|
+
if (kind === "multi_choice" && (!Array.isArray(value) || value.some((item) => !keys.includes(item)) || new Set(value).size !== value.length))
|
|
160
|
+
return "Choose from the options.";
|
|
161
|
+
}
|
|
162
|
+
return "";
|
|
163
|
+
}
|
|
164
|
+
export function displayValue(question, value) {
|
|
165
|
+
if (!isAnswered(value))
|
|
166
|
+
return "";
|
|
167
|
+
if (question.kind === "boolean")
|
|
168
|
+
return value ? "Yes" : "No";
|
|
169
|
+
if (CHOICE_KINDS.has(question.kind)) {
|
|
170
|
+
const labels = new Map((question.options ?? []).map((option) => [option.key, option.label]));
|
|
171
|
+
return (Array.isArray(value) ? value : [value]).map((key) => labels.get(key) ?? String(key)).join(", ");
|
|
172
|
+
}
|
|
173
|
+
return typeof value === "number" ? numberText(value) : String(value);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Validate a browser-submitted response against a verified definition. Rejects the wrong contract or release,
|
|
177
|
+
* unknown or duplicate questions, answers to questions the patient never saw, values outside the allowed
|
|
178
|
+
* options or ranges, missing required answers, and answers that reach a stop screen. Returns answers in
|
|
179
|
+
* definition order with display text for reviewers. Throws ContractError otherwise.
|
|
180
|
+
*/
|
|
181
|
+
export function validateResponse(definition, response, options) {
|
|
182
|
+
if (!isObject(response))
|
|
183
|
+
throw new ContractError({ _: "Response must be an object." });
|
|
184
|
+
if (response.contract !== ANSWER_CONTRACT)
|
|
185
|
+
throw new ContractError({ _: "Unsupported answer contract." });
|
|
186
|
+
if (response.questionnaireId !== options.questionnaireId || response.releaseId !== options.releaseId) {
|
|
187
|
+
throw new ContractError({ _: "Response does not belong to the active questionnaire release." });
|
|
188
|
+
}
|
|
189
|
+
const items = response.answers;
|
|
190
|
+
if (!Array.isArray(items) || items.length > 500)
|
|
191
|
+
throw new ContractError({ _: "Answers must be a list." });
|
|
192
|
+
const questions = new Map(definition.pages.flatMap((page) => page.questions.map((question) => [question.key, question])));
|
|
193
|
+
const submitted = new Map();
|
|
194
|
+
const errors = {};
|
|
195
|
+
for (const item of items) {
|
|
196
|
+
if (!isObject(item) || typeof item.questionKey !== "string")
|
|
197
|
+
throw new ContractError({ _: "Each answer needs a questionKey." });
|
|
198
|
+
const key = item.questionKey;
|
|
199
|
+
const question = questions.get(key);
|
|
200
|
+
if (!question || !ANSWERABLE_KINDS.has(question.kind)) {
|
|
201
|
+
errors[key] = "Unknown question.";
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (submitted.has(key)) {
|
|
205
|
+
errors[key] = "Answered more than once.";
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (item.kind !== question.kind) {
|
|
209
|
+
errors[key] = "Answer kind does not match the question.";
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
submitted.set(key, item.value);
|
|
213
|
+
}
|
|
214
|
+
if (Object.keys(errors).length)
|
|
215
|
+
throw new ContractError(errors);
|
|
216
|
+
const visible = visibleQuestions(definition, submitted);
|
|
217
|
+
// A visible stop screen means the patient was told they can't continue; never accept such a response.
|
|
218
|
+
const stopped = visible.filter((question) => question.kind === "end").map((question) => question.key);
|
|
219
|
+
if (stopped.length)
|
|
220
|
+
throw new ContractError({ [stopped[0]]: "These answers end the questionnaire at a stop screen, so it cannot be submitted." });
|
|
221
|
+
const visibleKeys = new Set(visible.map((question) => question.key));
|
|
222
|
+
for (const key of submitted.keys()) {
|
|
223
|
+
if (!visibleKeys.has(key))
|
|
224
|
+
errors[key] = "This question was not shown and cannot be answered.";
|
|
225
|
+
}
|
|
226
|
+
for (const question of visible) {
|
|
227
|
+
const message = answerError(question, submitted.get(question.key));
|
|
228
|
+
if (message)
|
|
229
|
+
errors[question.key] = message;
|
|
230
|
+
}
|
|
231
|
+
if (Object.keys(errors).length)
|
|
232
|
+
throw new ContractError(errors);
|
|
233
|
+
return visible
|
|
234
|
+
.filter((question) => ANSWERABLE_KINDS.has(question.kind) && isAnswered(submitted.get(question.key)))
|
|
235
|
+
.map((question) => {
|
|
236
|
+
const value = submitted.get(question.key);
|
|
237
|
+
return { questionKey: question.key, kind: question.kind, value, displayValue: displayValue(question, value), question };
|
|
238
|
+
});
|
|
239
|
+
}
|
package/dist/frame.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare const FRAME_SANDBOX = "allow-scripts allow-forms";
|
|
2
|
+
export declare function frameCsp(scriptHash: string, frameAncestors?: string): string;
|
|
3
|
+
export declare function frameHeaders(scriptHash: string, frameAncestors?: string): Record<string, string>;
|
|
4
|
+
export declare function buildFrameHtml(bundle: Uint8Array, options: {
|
|
5
|
+
title: string;
|
|
6
|
+
lang?: string;
|
|
7
|
+
}): Uint8Array;
|
|
8
|
+
/** Returns the page and the exact headers to send with it; the CSP hash binds the page to these bundle bytes. */
|
|
9
|
+
export declare function buildFrame(bundle: Uint8Array, options: {
|
|
10
|
+
title: string;
|
|
11
|
+
lang?: string;
|
|
12
|
+
frameAncestors?: string;
|
|
13
|
+
}): {
|
|
14
|
+
html: Uint8Array;
|
|
15
|
+
headers: Record<string, string>;
|
|
16
|
+
};
|
package/dist/frame.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// The sandboxed frame your server serves for a verified bundle (port of the Python SDK's frame.py).
|
|
2
|
+
// Isolation: the host embeds it with sandbox="allow-scripts allow-forms" (never allow-same-origin), the CSP
|
|
3
|
+
// re-applies that sandbox if the URL is opened directly, script-src allows only the verified bytes, and every
|
|
4
|
+
// network directive is 'none', so answers leave only by postMessage to your page.
|
|
5
|
+
import { cspHash } from "./canonical.js";
|
|
6
|
+
export const FRAME_SANDBOX = "allow-scripts allow-forms";
|
|
7
|
+
export function frameCsp(scriptHash, frameAncestors = "'self'") {
|
|
8
|
+
return [
|
|
9
|
+
"default-src 'none'",
|
|
10
|
+
`script-src '${scriptHash}'`,
|
|
11
|
+
"style-src 'unsafe-inline'",
|
|
12
|
+
"img-src 'none'",
|
|
13
|
+
"font-src 'none'",
|
|
14
|
+
"connect-src 'none'",
|
|
15
|
+
"media-src 'none'",
|
|
16
|
+
"object-src 'none'",
|
|
17
|
+
"child-src 'none'",
|
|
18
|
+
"worker-src 'none'",
|
|
19
|
+
"manifest-src 'none'",
|
|
20
|
+
"form-action 'none'",
|
|
21
|
+
"base-uri 'none'",
|
|
22
|
+
`frame-ancestors ${frameAncestors}`,
|
|
23
|
+
`sandbox ${FRAME_SANDBOX}`,
|
|
24
|
+
].join("; ");
|
|
25
|
+
}
|
|
26
|
+
export function frameHeaders(scriptHash, frameAncestors = "'self'") {
|
|
27
|
+
return {
|
|
28
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
29
|
+
"Content-Security-Policy": frameCsp(scriptHash, frameAncestors),
|
|
30
|
+
"X-Content-Type-Options": "nosniff",
|
|
31
|
+
"Referrer-Policy": "no-referrer",
|
|
32
|
+
"Cross-Origin-Resource-Policy": "same-origin",
|
|
33
|
+
"Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=(), clipboard-read=(), clipboard-write=()",
|
|
34
|
+
"Cache-Control": "private, max-age=300",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** Python's html.escape(text, quote=True). */
|
|
38
|
+
function escapeHtml(text) {
|
|
39
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
40
|
+
}
|
|
41
|
+
const truncate = (text, max) => Array.from(text).slice(0, max).join("");
|
|
42
|
+
export function buildFrameHtml(bundle, options) {
|
|
43
|
+
if (Buffer.from(bundle).toString("latin1").toLowerCase().includes("</script")) {
|
|
44
|
+
throw new Error("Bundle contains a closing script tag and cannot be inlined.");
|
|
45
|
+
}
|
|
46
|
+
const head = Buffer.from("<!doctype html>"
|
|
47
|
+
+ `<html lang="${escapeHtml(truncate(options.lang ?? "en", 20))}"><head><meta charset="utf-8">`
|
|
48
|
+
+ '<meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
49
|
+
+ `<title>${escapeHtml(truncate(options.title, 240))}</title></head><body><script>`, "utf8");
|
|
50
|
+
return new Uint8Array(Buffer.concat([head, Buffer.from(bundle), Buffer.from("</script></body></html>", "utf8")]));
|
|
51
|
+
}
|
|
52
|
+
/** Returns the page and the exact headers to send with it; the CSP hash binds the page to these bundle bytes. */
|
|
53
|
+
export function buildFrame(bundle, options) {
|
|
54
|
+
return { html: buildFrameHtml(bundle, options), headers: frameHeaders(cspHash(bundle), options.frameAncestors) };
|
|
55
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { b64urlDecode, canonicalJson, cspHash, sha256Hex } from "./canonical.js";
|
|
2
|
+
export { IntegrationError, MedFunnelsClient, type ClientOptions, type IntegrationErrorCode } from "./client.js";
|
|
3
|
+
export { ANSWER_CONTRACT, ContractError, answerError, displayValue, evaluateCondition, isAnswered, validateResponse, visibleQuestions, type Answers, type Definition, type NormalizedAnswer, type Question, } from "./contract.js";
|
|
4
|
+
export { FRAME_SANDBOX, buildFrame, buildFrameHtml, frameCsp, frameHeaders } from "./frame.js";
|
|
5
|
+
export { BUNDLE_ARTIFACT, DEFINITION_ARTIFACT, VerificationError, checkCompatibility, verifyRelease, verifySignature, type Envelope, type Manifest, type VerificationErrorCode, type VerifiedRelease, type VerifyOptions, } from "./verify.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// @medfunnels/sdk: fetch, verify, host and validate medFunnels questionnaire releases on your own server.
|
|
2
|
+
// Nothing in this package sends patient data to medFunnels.
|
|
3
|
+
export { b64urlDecode, canonicalJson, cspHash, sha256Hex } from "./canonical.js";
|
|
4
|
+
export { IntegrationError, MedFunnelsClient } from "./client.js";
|
|
5
|
+
export { ANSWER_CONTRACT, ContractError, answerError, displayValue, evaluateCondition, isAnswered, validateResponse, visibleQuestions, } from "./contract.js";
|
|
6
|
+
export { FRAME_SANDBOX, buildFrame, buildFrameHtml, frameCsp, frameHeaders } from "./frame.js";
|
|
7
|
+
export { BUNDLE_ARTIFACT, DEFINITION_ARTIFACT, VerificationError, checkCompatibility, verifyRelease, verifySignature, } from "./verify.js";
|
package/dist/verify.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type Definition } from "./contract.js";
|
|
2
|
+
export declare const BUNDLE_ARTIFACT = "questionnaire.js";
|
|
3
|
+
export declare const DEFINITION_ARTIFACT = "definition.json";
|
|
4
|
+
export type VerificationErrorCode = "malformed" | "bad_signature" | "untrusted_key" | "wrong_tenant" | "wrong_origin" | "not_published" | "incompatible" | "missing_artifact" | "digest_mismatch";
|
|
5
|
+
export declare class VerificationError extends Error {
|
|
6
|
+
readonly code: VerificationErrorCode;
|
|
7
|
+
constructor(code: VerificationErrorCode, message: string);
|
|
8
|
+
}
|
|
9
|
+
export interface Envelope {
|
|
10
|
+
manifest: Manifest;
|
|
11
|
+
signature: {
|
|
12
|
+
alg: string;
|
|
13
|
+
keyId: string;
|
|
14
|
+
value: string;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export interface Manifest {
|
|
18
|
+
manifestVersion: number;
|
|
19
|
+
organizationId: string;
|
|
20
|
+
questionnaire: {
|
|
21
|
+
id: string;
|
|
22
|
+
slug: string;
|
|
23
|
+
name: string;
|
|
24
|
+
};
|
|
25
|
+
release: {
|
|
26
|
+
id: string;
|
|
27
|
+
version: number;
|
|
28
|
+
status: string;
|
|
29
|
+
publishedAt: string;
|
|
30
|
+
notes: string;
|
|
31
|
+
};
|
|
32
|
+
runtime: {
|
|
33
|
+
version: string;
|
|
34
|
+
definitionSchema: string;
|
|
35
|
+
};
|
|
36
|
+
deployment: {
|
|
37
|
+
allowedOrigins: string[];
|
|
38
|
+
localDevelopment: boolean;
|
|
39
|
+
previewOrigin: string;
|
|
40
|
+
};
|
|
41
|
+
artifacts: Array<{
|
|
42
|
+
name: string;
|
|
43
|
+
mediaType: string;
|
|
44
|
+
size: number;
|
|
45
|
+
sha256: string;
|
|
46
|
+
cspHash?: string;
|
|
47
|
+
}>;
|
|
48
|
+
compatibility: {
|
|
49
|
+
answerContract: string;
|
|
50
|
+
questionKinds: string[];
|
|
51
|
+
requiresNetwork: boolean;
|
|
52
|
+
[field: string]: unknown;
|
|
53
|
+
};
|
|
54
|
+
[field: string]: unknown;
|
|
55
|
+
}
|
|
56
|
+
export interface VerifiedRelease {
|
|
57
|
+
manifest: Manifest;
|
|
58
|
+
definition: Definition;
|
|
59
|
+
bundle: Uint8Array;
|
|
60
|
+
bundleSha256: string;
|
|
61
|
+
bundleCspHash: string;
|
|
62
|
+
questionnaireId: string;
|
|
63
|
+
releaseId: string;
|
|
64
|
+
version: number;
|
|
65
|
+
}
|
|
66
|
+
export interface VerifyOptions {
|
|
67
|
+
/** Key id → base64url raw Ed25519 public key. Pin these in configuration. */
|
|
68
|
+
trustedKeys: Record<string, string>;
|
|
69
|
+
/** The question kinds your platform accepts; releases using anything else are rejected. */
|
|
70
|
+
supportedKinds: Iterable<string>;
|
|
71
|
+
expectedOrganizationId?: string | null;
|
|
72
|
+
/** e.g. "https://intake.example.com": rejects releases locked to other websites. */
|
|
73
|
+
expectedOrigin?: string | null;
|
|
74
|
+
}
|
|
75
|
+
export declare function verifySignature(envelope: unknown, trustedKeys: Record<string, string>): Manifest;
|
|
76
|
+
export declare function checkCompatibility(manifest: Manifest, supportedKinds: Iterable<string>): void;
|
|
77
|
+
/** Check, in order: signature, workspace, website, status, compatibility, then each file's size, SHA-256 and the script's CSP hash. */
|
|
78
|
+
export declare function verifyRelease(envelope: unknown, artifacts: Record<string, Uint8Array | undefined>, options: VerifyOptions): VerifiedRelease;
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Release verification: authenticity (Ed25519 signature), integrity (SHA-256), compatibility.
|
|
2
|
+
// A port of the Python SDK's verify.py. Pin medFunnels' signing public keys in your configuration;
|
|
3
|
+
// never fetch and trust them at runtime.
|
|
4
|
+
import { createPublicKey, verify as verifySignatureBytes } from "node:crypto";
|
|
5
|
+
import { b64urlDecode, canonicalJson, cspHash, sha256Hex } from "./canonical.js";
|
|
6
|
+
import { ANSWER_CONTRACT } from "./contract.js";
|
|
7
|
+
const SUPPORTED_MANIFEST_VERSIONS = new Set([1]);
|
|
8
|
+
const SUPPORTED_RUNTIME_MAJORS = new Set([1]);
|
|
9
|
+
export const BUNDLE_ARTIFACT = "questionnaire.js";
|
|
10
|
+
export const DEFINITION_ARTIFACT = "definition.json";
|
|
11
|
+
export class VerificationError extends Error {
|
|
12
|
+
code;
|
|
13
|
+
constructor(code, message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "VerificationError";
|
|
16
|
+
this.code = code;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
20
|
+
export function verifySignature(envelope, trustedKeys) {
|
|
21
|
+
const manifest = isObject(envelope) ? envelope.manifest : undefined;
|
|
22
|
+
const signature = isObject(envelope) && isObject(envelope.signature) ? envelope.signature : {};
|
|
23
|
+
if (!isObject(manifest))
|
|
24
|
+
throw new VerificationError("malformed", "Manifest envelope is missing its manifest.");
|
|
25
|
+
if (signature.alg !== "Ed25519")
|
|
26
|
+
throw new VerificationError("bad_signature", "Unsupported signature algorithm.");
|
|
27
|
+
const keyId = String(signature.keyId ?? "");
|
|
28
|
+
const key = Object.prototype.hasOwnProperty.call(trustedKeys, keyId) ? trustedKeys[keyId] : "";
|
|
29
|
+
if (!key)
|
|
30
|
+
throw new VerificationError("untrusted_key", `Signing key '${keyId}' is not trusted by this installation.`);
|
|
31
|
+
let valid = false;
|
|
32
|
+
try {
|
|
33
|
+
const publicKey = createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: key }, format: "jwk" });
|
|
34
|
+
valid = verifySignatureBytes(null, canonicalJson(manifest), publicKey, b64urlDecode(String(signature.value ?? "")));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
valid = false;
|
|
38
|
+
}
|
|
39
|
+
if (!valid)
|
|
40
|
+
throw new VerificationError("bad_signature", "Release manifest signature is invalid.");
|
|
41
|
+
return manifest;
|
|
42
|
+
}
|
|
43
|
+
export function checkCompatibility(manifest, supportedKinds) {
|
|
44
|
+
if (!SUPPORTED_MANIFEST_VERSIONS.has(manifest.manifestVersion))
|
|
45
|
+
throw new VerificationError("incompatible", "Unsupported manifest version.");
|
|
46
|
+
const compatibility = isObject(manifest.compatibility) ? manifest.compatibility : {};
|
|
47
|
+
if (compatibility.answerContract !== ANSWER_CONTRACT)
|
|
48
|
+
throw new VerificationError("incompatible", "Unsupported answer contract.");
|
|
49
|
+
const runtimeVersion = String(manifest.runtime?.version || "0");
|
|
50
|
+
if (!SUPPORTED_RUNTIME_MAJORS.has(Number.parseInt(runtimeVersion.split(".")[0] || "0", 10))) {
|
|
51
|
+
throw new VerificationError("incompatible", `Unsupported runtime version ${runtimeVersion}.`);
|
|
52
|
+
}
|
|
53
|
+
if (compatibility.requiresNetwork !== false)
|
|
54
|
+
throw new VerificationError("incompatible", "Questionnaire runtime must not require network access.");
|
|
55
|
+
const supported = new Set(supportedKinds);
|
|
56
|
+
const unsupported = [...new Set(compatibility.questionKinds ?? [])].filter((kind) => !supported.has(kind)).sort();
|
|
57
|
+
if (unsupported.length)
|
|
58
|
+
throw new VerificationError("incompatible", `Unsupported question kinds: ${unsupported.join(", ")}.`);
|
|
59
|
+
}
|
|
60
|
+
/** Check, in order: signature, workspace, website, status, compatibility, then each file's size, SHA-256 and the script's CSP hash. */
|
|
61
|
+
export function verifyRelease(envelope, artifacts, options) {
|
|
62
|
+
const manifest = verifySignature(envelope, options.trustedKeys);
|
|
63
|
+
if (options.expectedOrganizationId && manifest.organizationId !== options.expectedOrganizationId) {
|
|
64
|
+
throw new VerificationError("wrong_tenant", "Release belongs to a different organization.");
|
|
65
|
+
}
|
|
66
|
+
const allowed = manifest.deployment?.allowedOrigins ?? [];
|
|
67
|
+
const origin = options.expectedOrigin ? options.expectedOrigin.replace(/\/+$/, "") : "";
|
|
68
|
+
if (origin && allowed.length && !allowed.includes(origin)) {
|
|
69
|
+
throw new VerificationError("wrong_origin", `Release is locked to ${allowed.join(", ")}, not ${options.expectedOrigin}.`);
|
|
70
|
+
}
|
|
71
|
+
if (manifest.release?.status !== "published")
|
|
72
|
+
throw new VerificationError("not_published", "Release is not in published status.");
|
|
73
|
+
checkCompatibility(manifest, options.supportedKinds);
|
|
74
|
+
const declared = new Map((manifest.artifacts ?? []).map((item) => [item.name, item]));
|
|
75
|
+
for (const name of [BUNDLE_ARTIFACT, DEFINITION_ARTIFACT]) {
|
|
76
|
+
const meta = declared.get(name);
|
|
77
|
+
const data = artifacts[name];
|
|
78
|
+
if (!meta || !data)
|
|
79
|
+
throw new VerificationError("missing_artifact", `Release artifact ${name} is missing.`);
|
|
80
|
+
if (data.length !== Number(meta.size ?? -1) || sha256Hex(data) !== meta.sha256) {
|
|
81
|
+
throw new VerificationError("digest_mismatch", `Artifact ${name} does not match its signed digest.`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const bundle = artifacts[BUNDLE_ARTIFACT];
|
|
85
|
+
if (declared.get(BUNDLE_ARTIFACT)?.cspHash !== cspHash(bundle))
|
|
86
|
+
throw new VerificationError("digest_mismatch", "Bundle CSP hash does not match.");
|
|
87
|
+
const definition = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(artifacts[DEFINITION_ARTIFACT]));
|
|
88
|
+
return {
|
|
89
|
+
manifest,
|
|
90
|
+
definition,
|
|
91
|
+
bundle,
|
|
92
|
+
bundleSha256: sha256Hex(bundle),
|
|
93
|
+
bundleCspHash: cspHash(bundle),
|
|
94
|
+
questionnaireId: manifest.questionnaire.id,
|
|
95
|
+
releaseId: manifest.release.id,
|
|
96
|
+
version: Number(manifest.release.version),
|
|
97
|
+
};
|
|
98
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@medfunnels/sdk",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Fetch, verify, host and validate medFunnels questionnaire releases on your own server.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://www.medfunnels.app",
|
|
7
|
+
"keywords": ["medfunnels", "telehealth", "questionnaire", "intake", "hipaa"],
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }
|
|
13
|
+
},
|
|
14
|
+
"files": ["dist", "README.md", "LICENSE"],
|
|
15
|
+
"engines": { "node": ">=18.17" },
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "rm -rf dist && tsc -p tsconfig.json",
|
|
19
|
+
"test": "node --test test/*.test.mjs"
|
|
20
|
+
}
|
|
21
|
+
}
|