@medfunnels/next 0.0.0-stage → 1.0.1

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 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,65 @@
1
- # Temporary Holding Version
1
+ # @medfunnels/next
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Run [medFunnels](https://www.medfunnels.app) patient questionnaires in your Next.js app (App Router).
4
+
5
+ Your team builds questionnaires in medFunnels. This package downloads each published version at build time,
6
+ verifies its signature and every file, serves it from your own domain in a locked-down sandbox, and validates
7
+ patients' answers on your server before your code sees them. **Patient data never goes to medFunnels.**
8
+
9
+ ## Setup
10
+
11
+ ```bash
12
+ npm install @medfunnels/next
13
+ ```
14
+
15
+ Add your credential (medFunnels → Integrations → New credential) to `.env.local` and your host:
16
+
17
+ ```bash
18
+ MEDFUNNELS_CLIENT_ID=mfc_…
19
+ MEDFUNNELS_CLIENT_SECRET=mfs_…
20
+ ```
21
+
22
+ ```bash
23
+ npx medfunnels init
24
+ ```
25
+
26
+ `init` creates `medfunnels.config.json` (no secrets; commit it), `medfunnels.server.ts`, and two routes under
27
+ `app/medfunnels/[slug]/`. Then run the sync before every dev server and build:
28
+
29
+ ```json
30
+ "scripts": { "predev": "medfunnels pull", "prebuild": "medfunnels pull" }
31
+ ```
32
+
33
+ Show a questionnaire on any page:
34
+
35
+ ```tsx
36
+ import { MedFunnelsQuestionnaire } from "@medfunnels/next/client";
37
+ import { medfunnels } from "../../medfunnels.server";
38
+
39
+ export default function IntakePage() {
40
+ return <MedFunnelsQuestionnaire questionnaire={medfunnels.questionnaire("glp1-intake")} redirectTo="/next-step" />;
41
+ }
42
+ ```
43
+
44
+ And save the answers in the submit route `init` created:
45
+
46
+ ```ts
47
+ export const POST = medfunnels.submitRoute(async (answers, { request, questionnaire }) => {
48
+ // Identify the patient with your own session, then save `answers`.
49
+ // Return { ok: false, message } to show the patient a message instead.
50
+ });
51
+ ```
52
+
53
+ ## How it works
54
+
55
+ - **Build time:** `medfunnels pull` verifies each release (Ed25519 signature against the key pinned in your config,
56
+ your workspace, your website, file digests) and writes it to `.medfunnels/`. A failed check fails the build.
57
+ - **Frame route:** serves the verified script with a Content-Security-Policy that allows only those exact bytes and
58
+ blocks all network access. The page embeds it in an opaque-origin sandbox with no access to your cookies or APIs.
59
+ - **Submit route:** validates every answer against the verified questionnaire, then calls your handler. It never logs answers.
60
+ - **Versions:** each deploy ships the latest published version. Pin one with `"versions": { "slug": 3 }` in the config.
61
+ - **Staging:** set `MEDFUNNELS_SITE_ORIGIN` to your Staging website on staging deployments.
62
+
63
+ The complete guide, with your workspace's values filled in, is in medFunnels under **Integrations → Developer guide**.
64
+
65
+ Using another Node framework? See [`@medfunnels/sdk`](https://www.npmjs.com/package/@medfunnels/sdk).
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+ // medfunnels init | pull
3
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
4
+ import { dirname, join, relative } from "node:path";
5
+ import { MedFunnelsClient } from "@medfunnels/sdk";
6
+ import { CONFIG_FILE, DEFAULT_API, DEFAULT_OUTPUT } from "./config.js";
7
+ import { pull } from "./pull.js";
8
+ const HELP = `medFunnels for Next.js
9
+
10
+ npx medfunnels init Scaffold medfunnels.config.json, medfunnels.server.ts and the frame and submit routes.
11
+ npx medfunnels pull Download and verify the questionnaires this build serves (run before next dev / next build).
12
+
13
+ Environment: MEDFUNNELS_CLIENT_ID, MEDFUNNELS_CLIENT_SECRET (server-side only),
14
+ MEDFUNNELS_SITE_ORIGIN (optional: the website this deployment serves, e.g. your Staging site).
15
+ Guide: medFunnels → Integrations → Developer guide.`;
16
+ function write(cwd, path, content) {
17
+ const target = join(cwd, path);
18
+ if (existsSync(target)) {
19
+ console.log(` kept ${path} (already exists)`);
20
+ return;
21
+ }
22
+ mkdirSync(dirname(target), { recursive: true });
23
+ writeFileSync(target, content);
24
+ console.log(` created ${path}`);
25
+ }
26
+ const importPath = (from, to) => {
27
+ const path = relative(dirname(from), to).replace(/\\/g, "/");
28
+ return path.startsWith("./") || path.startsWith("../") ? path : `./${path}`;
29
+ };
30
+ async function init(cwd) {
31
+ const appDir = existsSync(join(cwd, "src", "app")) ? "src/app" : "app";
32
+ const serverFile = appDir === "src/app" ? "src/medfunnels.server.ts" : "medfunnels.server.ts";
33
+ let config = { api: DEFAULT_API, organizationId: "org_…", signingKeys: { "key-id": "public-key" }, siteOrigin: "https://intake.yourclinic.com" };
34
+ const { MEDFUNNELS_CLIENT_ID: clientId, MEDFUNNELS_CLIENT_SECRET: clientSecret, MEDFUNNELS_API: api } = process.env;
35
+ if (clientId && clientSecret) {
36
+ // One-time setup convenience. Compare the key with the developer guide before you commit it.
37
+ const connection = await new MedFunnelsClient({ baseUrl: api || DEFAULT_API, clientId, clientSecret }).testConnection();
38
+ config = {
39
+ ...config,
40
+ api: api || DEFAULT_API,
41
+ organizationId: connection.organization.id,
42
+ ...(connection.websites?.live ? { siteOrigin: connection.websites.live } : {}),
43
+ signingKeys: Object.fromEntries(connection.signingKeys.map((key) => [key.keyId, key.publicKey])),
44
+ };
45
+ console.log(` connected to ${connection.organization.name}`);
46
+ }
47
+ write(cwd, CONFIG_FILE, `${JSON.stringify(config, null, 2)}\n`);
48
+ write(cwd, serverFile, `import { createMedFunnels } from "@medfunnels/next/server";
49
+
50
+ import releases from "${importPath(serverFile, `${DEFAULT_OUTPUT}/releases.json`)}";
51
+ import config from "${importPath(serverFile, CONFIG_FILE)}";
52
+
53
+ // Server-only. Releases come from \`medfunnels pull\` and are re-verified before they're served.
54
+ export const medfunnels = createMedFunnels({ config, releases });
55
+ `);
56
+ const frame = `${appDir}/medfunnels/[slug]/frame/route.ts`;
57
+ write(cwd, frame, `import { medfunnels } from "${importPath(frame, serverFile.replace(/\.ts$/, ""))}";
58
+
59
+ export const runtime = "nodejs";
60
+ export const GET = medfunnels.frameRoute;
61
+ `);
62
+ const submit = `${appDir}/medfunnels/[slug]/submit/route.ts`;
63
+ write(cwd, submit, `import { medfunnels } from "${importPath(submit, serverFile.replace(/\.ts$/, ""))}";
64
+
65
+ export const runtime = "nodejs";
66
+
67
+ export const POST = medfunnels.submitRoute(async (answers, { request, questionnaire }) => {
68
+ // 1. Identify the patient with your own session (cookies on \`request\`).
69
+ // 2. Save \`answers\` with their record. Each has questionKey, kind, value and displayValue.
70
+ // Return { ok: false, message } to show the patient an error instead.
71
+ });
72
+ `);
73
+ console.log(`
74
+ Next steps:
75
+ 1. Fill in ${CONFIG_FILE} (values are in medFunnels → Integrations → Developer guide).
76
+ 2. Add to package.json scripts: "predev": "medfunnels pull", "prebuild": "medfunnels pull"
77
+ 3. Set MEDFUNNELS_CLIENT_ID and MEDFUNNELS_CLIENT_SECRET in .env.local and your hosting provider.
78
+ 4. Render it on a page:
79
+ <MedFunnelsQuestionnaire questionnaire={medfunnels.questionnaire("your-slug")} redirectTo="/thanks" />`);
80
+ }
81
+ async function main() {
82
+ const [command] = process.argv.slice(2);
83
+ const cwd = process.cwd();
84
+ if (command === "pull") {
85
+ console.log("medfunnels pull");
86
+ const result = await pull({ cwd });
87
+ console.log(` wrote ${relative(cwd, result.file)} (${result.questionnaires.length} questionnaire${result.questionnaires.length === 1 ? "" : "s"})`);
88
+ }
89
+ else if (command === "init") {
90
+ console.log("medfunnels init");
91
+ await init(cwd);
92
+ }
93
+ else {
94
+ console.log(HELP);
95
+ if (command && command !== "help" && command !== "--help" && command !== "-h")
96
+ process.exitCode = 1;
97
+ }
98
+ }
99
+ main().catch((error) => {
100
+ const detail = error;
101
+ console.error(`medfunnels: ${detail.message ?? String(error)}${detail.code ? ` [${detail.code}]` : ""}${detail.requestId ? ` (request ${detail.requestId})` : ""}`);
102
+ process.exitCode = 1;
103
+ });
@@ -0,0 +1,44 @@
1
+ export interface QuestionnaireInfo {
2
+ slug: string;
3
+ questionnaireId: string;
4
+ releaseId: string;
5
+ version: number;
6
+ title: string;
7
+ basePath: string;
8
+ }
9
+ export interface StopScreen {
10
+ questionKey: string;
11
+ clinicalTag: string;
12
+ }
13
+ export interface MedFunnelsQuestionnaireProps {
14
+ /** From `medfunnels.questionnaire("slug")` in a server component. */
15
+ questionnaire: QuestionnaireInfo;
16
+ /** Go here after answers are accepted (works from server components). */
17
+ redirectTo?: string;
18
+ /** Go here when a stop screen is reached. */
19
+ stopRedirectTo?: string;
20
+ /** Override colors and shape: accent, text, muted, background, surface, border, error (hex), radius (0–32), font (system | serif | rounded). */
21
+ theme?: Record<string, string | number>;
22
+ labels?: Record<string, string>;
23
+ /** Restore a patient's in-progress answers (pair with onState). */
24
+ initialAnswers?: Record<string, unknown>;
25
+ className?: string;
26
+ onSubmitted?: () => void;
27
+ onEnd?: (stop: StopScreen) => void;
28
+ onReady?: () => void;
29
+ onBack?: () => void;
30
+ onProgress?: (progress: {
31
+ page: number;
32
+ pages: number;
33
+ answered: number;
34
+ total: number;
35
+ }) => void;
36
+ /** Passing it turns on a live copy of in-progress answers, for restoring a session. */
37
+ onState?: (state: {
38
+ answers: Record<string, unknown>;
39
+ screen: unknown;
40
+ }) => void;
41
+ /** frame_timeout | frame_navigated | release_mismatch | submit_failed */
42
+ onError?: (code: string, detail?: string) => void;
43
+ }
44
+ export declare function MedFunnelsQuestionnaire(props: MedFunnelsQuestionnaireProps): import("react").JSX.Element;
package/dist/client.js ADDED
@@ -0,0 +1,55 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ // <MedFunnelsQuestionnaire>: mounts a verified questionnaire from your own routes inside an opaque-origin
4
+ // sandbox (no access to your cookies, storage or APIs) and posts answers to your submit route.
5
+ import { useEffect, useRef } from "react";
6
+ import { mountQuestionnaire } from "./vendor/embed.js";
7
+ export function MedFunnelsQuestionnaire(props) {
8
+ const container = useRef(null);
9
+ const latest = useRef(props);
10
+ latest.current = props;
11
+ const { slug, releaseId, basePath, title } = props.questionnaire;
12
+ const observe = Boolean(props.onState);
13
+ useEffect(() => {
14
+ const element = container.current;
15
+ if (!element)
16
+ return;
17
+ const base = `${basePath.replace(/\/+$/, "")}/${encodeURIComponent(slug)}`;
18
+ const mounted = mountQuestionnaire({
19
+ container: element,
20
+ frameUrl: `${base}/frame`,
21
+ releaseId,
22
+ title,
23
+ theme: latest.current.theme,
24
+ labels: latest.current.labels,
25
+ initialAnswers: latest.current.initialAnswers,
26
+ onSubmit: async (response) => {
27
+ const reply = await fetch(`${base}/submit`, {
28
+ method: "POST",
29
+ credentials: "same-origin",
30
+ headers: { "Content-Type": "application/json" },
31
+ body: JSON.stringify(response),
32
+ });
33
+ const result = await reply.json().catch(() => ({ ok: false, errors: {} }));
34
+ if (result.ok) {
35
+ latest.current.onSubmitted?.();
36
+ if (latest.current.redirectTo)
37
+ window.location.assign(latest.current.redirectTo);
38
+ }
39
+ return result;
40
+ },
41
+ onEnd: (stop) => {
42
+ latest.current.onEnd?.(stop);
43
+ if (latest.current.stopRedirectTo)
44
+ window.location.assign(latest.current.stopRedirectTo);
45
+ },
46
+ onReady: () => latest.current.onReady?.(),
47
+ onBack: () => latest.current.onBack?.(),
48
+ onProgress: (progress) => latest.current.onProgress?.(progress),
49
+ onState: observe ? (state) => latest.current.onState?.(state) : undefined,
50
+ onError: (code, detail) => latest.current.onError?.(code, detail),
51
+ });
52
+ return () => mounted.destroy();
53
+ }, [slug, releaseId, basePath, title, observe]);
54
+ return _jsx("div", { ref: container, className: props.className, "data-medfunnels": slug });
55
+ }
@@ -0,0 +1,28 @@
1
+ /** medfunnels.config.json, committed to your repository. Contains no secrets. */
2
+ export interface MedFunnelsConfig {
3
+ /** API base URL. Defaults to https://www.medfunnels.app; MEDFUNNELS_API overrides it. */
4
+ api?: string;
5
+ /** Your workspace's organization ID: releases from any other workspace are rejected. */
6
+ organizationId: string;
7
+ /** Pinned signing keys (key id → public key) from the developer guide. Never fetched at runtime. */
8
+ signingKeys: Record<string, string>;
9
+ /** The website this build serves, e.g. https://intake.example.com. MEDFUNNELS_SITE_ORIGIN overrides it (use it for Staging). */
10
+ siteOrigin?: string;
11
+ /** Only sync these questionnaires (by slug). Default: every questionnaire the credential can read. */
12
+ questionnaires?: string[];
13
+ /** Pin versions (slug → version). Unpinned questionnaires use their latest published version. */
14
+ versions?: Record<string, number>;
15
+ /** Question kinds your backend accepts. Default: every kind this package can validate. */
16
+ supportedKinds?: string[];
17
+ /** Where your frame and submit routes live. Default "/medfunnels". */
18
+ basePath?: string;
19
+ /** Where `medfunnels pull` writes verified releases. Default ".medfunnels". */
20
+ output?: string;
21
+ }
22
+ export declare const CONFIG_FILE = "medfunnels.config.json";
23
+ export declare const DEFAULT_API = "https://www.medfunnels.app";
24
+ export declare const DEFAULT_BASE_PATH = "/medfunnels";
25
+ export declare const DEFAULT_OUTPUT = ".medfunnels";
26
+ export declare const ALL_KINDS: string[];
27
+ export declare function loadConfig(cwd: string): MedFunnelsConfig;
28
+ export declare function siteOrigin(config: MedFunnelsConfig, env?: Record<string, string | undefined>): string | undefined;
package/dist/config.js ADDED
@@ -0,0 +1,24 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ export const CONFIG_FILE = "medfunnels.config.json";
4
+ export const DEFAULT_API = "https://www.medfunnels.app";
5
+ export const DEFAULT_BASE_PATH = "/medfunnels";
6
+ export const DEFAULT_OUTPUT = ".medfunnels";
7
+ export const ALL_KINDS = ["boolean", "date", "end", "multi_choice", "number", "single_choice", "statement", "text", "textarea"];
8
+ export function loadConfig(cwd) {
9
+ let raw;
10
+ try {
11
+ raw = readFileSync(join(cwd, CONFIG_FILE), "utf8");
12
+ }
13
+ catch {
14
+ throw new Error(`No ${CONFIG_FILE} in ${cwd}. Run \`npx medfunnels init\` first.`);
15
+ }
16
+ const config = JSON.parse(raw);
17
+ if (!config.organizationId || !config.signingKeys || !Object.keys(config.signingKeys).length) {
18
+ throw new Error(`${CONFIG_FILE} needs organizationId and signingKeys. Copy them from the developer guide in medFunnels (Integrations → Developer guide).`);
19
+ }
20
+ return config;
21
+ }
22
+ export function siteOrigin(config, env = process.env) {
23
+ return env.MEDFUNNELS_SITE_ORIGIN || config.siteOrigin || undefined;
24
+ }
package/dist/pull.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { type Envelope } from "@medfunnels/sdk";
2
+ import { type MedFunnelsConfig } from "./config.js";
3
+ export interface ReleaseFile {
4
+ generatedAt: string;
5
+ questionnaires: Record<string, {
6
+ envelope: Envelope;
7
+ artifacts: {
8
+ "questionnaire.js": string;
9
+ "definition.json": string;
10
+ };
11
+ }>;
12
+ }
13
+ export interface PullOptions {
14
+ cwd?: string;
15
+ env?: Record<string, string | undefined>;
16
+ config?: MedFunnelsConfig;
17
+ log?: (message: string) => void;
18
+ }
19
+ export declare function pull(options?: PullOptions): Promise<{
20
+ file: string;
21
+ questionnaires: Array<{
22
+ slug: string;
23
+ version: number;
24
+ releaseId: string;
25
+ }>;
26
+ }>;
package/dist/pull.js ADDED
@@ -0,0 +1,73 @@
1
+ // `medfunnels pull`: download, verify and store the releases this build will serve. Run it before `next build`
2
+ // (and `next dev`). Any release that fails verification fails the build: nothing unverified reaches patients.
3
+ import { mkdirSync, renameSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { MedFunnelsClient, verifyRelease } from "@medfunnels/sdk";
6
+ import { ALL_KINDS, DEFAULT_API, DEFAULT_OUTPUT, loadConfig, siteOrigin } from "./config.js";
7
+ const decoder = new TextDecoder("utf-8", { fatal: true });
8
+ export async function pull(options = {}) {
9
+ const cwd = options.cwd ?? process.cwd();
10
+ const env = options.env ?? process.env;
11
+ const log = options.log ?? ((message) => console.log(message));
12
+ const config = options.config ?? loadConfig(cwd);
13
+ const clientId = env.MEDFUNNELS_CLIENT_ID;
14
+ const clientSecret = env.MEDFUNNELS_CLIENT_SECRET;
15
+ if (!clientId || !clientSecret) {
16
+ throw new Error("Set MEDFUNNELS_CLIENT_ID and MEDFUNNELS_CLIENT_SECRET (from medFunnels → Integrations → Credentials) in this environment.");
17
+ }
18
+ const client = new MedFunnelsClient({ baseUrl: env.MEDFUNNELS_API || config.api || DEFAULT_API, clientId, clientSecret });
19
+ const available = [];
20
+ let cursor;
21
+ do {
22
+ const page = await client.listQuestionnaires({ cursor, limit: 100 });
23
+ available.push(...page.questionnaires);
24
+ cursor = page.nextCursor ?? undefined;
25
+ } while (cursor);
26
+ const wanted = config.questionnaires ?? available.map((item) => item.slug);
27
+ const missing = wanted.filter((slug) => !available.some((item) => item.slug === slug));
28
+ if (missing.length)
29
+ throw new Error(`Not found or not published for this credential: ${missing.join(", ")}.`);
30
+ const output = { generatedAt: new Date().toISOString(), questionnaires: {} };
31
+ const summary = [];
32
+ for (const slug of [...wanted].sort()) {
33
+ const questionnaire = available.find((item) => item.slug === slug);
34
+ const pinned = config.versions?.[slug];
35
+ let release = questionnaire.latestRelease;
36
+ if (pinned !== undefined) {
37
+ const releases = (await client.listReleases(questionnaire.id)).releases;
38
+ release = releases.find((item) => item.version === pinned);
39
+ if (!release)
40
+ throw new Error(`${slug}: version ${pinned} (pinned in config) doesn't exist.`);
41
+ if (release.status !== "published")
42
+ throw new Error(`${slug}: version ${pinned} (pinned in config) was withdrawn. Pin another version.`);
43
+ }
44
+ if (!release) {
45
+ log(` skipped ${slug}: nothing published yet`);
46
+ continue;
47
+ }
48
+ const envelope = (await client.getManifest(release.id));
49
+ const artifacts = {
50
+ "questionnaire.js": await client.downloadArtifact(release.id, "questionnaire.js"),
51
+ "definition.json": await client.downloadArtifact(release.id, "definition.json"),
52
+ };
53
+ const verified = verifyRelease(envelope, artifacts, {
54
+ trustedKeys: config.signingKeys,
55
+ supportedKinds: config.supportedKinds ?? ALL_KINDS,
56
+ expectedOrganizationId: config.organizationId,
57
+ expectedOrigin: siteOrigin(config, env),
58
+ });
59
+ output.questionnaires[slug] = {
60
+ envelope,
61
+ artifacts: { "questionnaire.js": decoder.decode(artifacts["questionnaire.js"]), "definition.json": decoder.decode(artifacts["definition.json"]) },
62
+ };
63
+ summary.push({ slug, version: verified.version, releaseId: verified.releaseId });
64
+ log(` verified ${slug} v${verified.version}${pinned !== undefined ? " (pinned)" : ""}`);
65
+ }
66
+ const directory = join(cwd, config.output ?? DEFAULT_OUTPUT);
67
+ mkdirSync(directory, { recursive: true });
68
+ writeFileSync(join(directory, ".gitignore"), "# Generated by `medfunnels pull` on every build.\n*\n");
69
+ const file = join(directory, "releases.json");
70
+ writeFileSync(`${file}.tmp`, JSON.stringify(output));
71
+ renameSync(`${file}.tmp`, file);
72
+ return { file, questionnaires: summary };
73
+ }
@@ -0,0 +1,44 @@
1
+ import { type NormalizedAnswer, type VerifiedRelease } from "@medfunnels/sdk";
2
+ import { type MedFunnelsConfig } from "./config.js";
3
+ import type { ReleaseFile } from "./pull.js";
4
+ export type { MedFunnelsConfig } from "./config.js";
5
+ export type { NormalizedAnswer } from "@medfunnels/sdk";
6
+ /** What a page passes to <MedFunnelsQuestionnaire>. Contains no bundle or answers, so it's safe to send to the browser. */
7
+ export interface QuestionnaireInfo {
8
+ slug: string;
9
+ questionnaireId: string;
10
+ releaseId: string;
11
+ version: number;
12
+ title: string;
13
+ basePath: string;
14
+ }
15
+ export interface SubmitContext {
16
+ request: Request;
17
+ questionnaire: QuestionnaireInfo;
18
+ release: VerifiedRelease;
19
+ }
20
+ /** Return nothing to accept, or { ok: false, message } to show the patient an error (e.g. their session expired). */
21
+ export type SubmitHandler = (answers: NormalizedAnswer[], context: SubmitContext) => void | {
22
+ ok: false;
23
+ message?: string;
24
+ } | Promise<void | {
25
+ ok: false;
26
+ message?: string;
27
+ }>;
28
+ type RouteContext = {
29
+ params: Promise<{
30
+ slug: string;
31
+ }> | {
32
+ slug: string;
33
+ };
34
+ };
35
+ export declare function createMedFunnels(options: {
36
+ config: MedFunnelsConfig;
37
+ releases: ReleaseFile | Record<string, unknown>;
38
+ }): {
39
+ questionnaire: (slug: string) => QuestionnaireInfo;
40
+ release: (slug: string) => VerifiedRelease | null;
41
+ frameRoute: (_request: Request, context: RouteContext) => Promise<Response>;
42
+ submitRoute: (handler: SubmitHandler) => (request: Request, context: RouteContext) => Promise<Response>;
43
+ basePath: string;
44
+ };
package/dist/server.js ADDED
@@ -0,0 +1,84 @@
1
+ // Route handlers for the Next.js App Router (web-standard Request/Response, Node.js runtime).
2
+ // Releases are re-verified once per server process before anything is served. Answers are validated against
3
+ // the verified definition and handed to your callback; this module never logs or stores them.
4
+ import { ContractError, buildFrame, validateResponse, verifyRelease } from "@medfunnels/sdk";
5
+ import { ALL_KINDS, DEFAULT_BASE_PATH, siteOrigin } from "./config.js";
6
+ const MAX_BODY_BYTES = 256 * 1024;
7
+ const encoder = new TextEncoder();
8
+ const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
9
+ export function createMedFunnels(options) {
10
+ const { config } = options;
11
+ const file = options.releases;
12
+ const basePath = (config.basePath ?? DEFAULT_BASE_PATH).replace(/\/+$/, "");
13
+ const verified = new Map();
14
+ function release(slug) {
15
+ const cached = verified.get(slug);
16
+ if (cached)
17
+ return cached;
18
+ const entry = file?.questionnaires?.[slug];
19
+ if (!entry)
20
+ return null;
21
+ const result = verifyRelease(entry.envelope, { "questionnaire.js": encoder.encode(entry.artifacts["questionnaire.js"]), "definition.json": encoder.encode(entry.artifacts["definition.json"]) }, { trustedKeys: config.signingKeys, supportedKinds: config.supportedKinds ?? ALL_KINDS, expectedOrganizationId: config.organizationId, expectedOrigin: siteOrigin(config) });
22
+ verified.set(slug, result);
23
+ return result;
24
+ }
25
+ function info(slug, verifiedRelease) {
26
+ return { slug, questionnaireId: verifiedRelease.questionnaireId, releaseId: verifiedRelease.releaseId, version: verifiedRelease.version, title: verifiedRelease.manifest.questionnaire.name, basePath };
27
+ }
28
+ /** For a page: the props <MedFunnelsQuestionnaire questionnaire={…}> needs. */
29
+ function questionnaire(slug) {
30
+ const found = release(slug);
31
+ if (!found) {
32
+ const known = Object.keys(file?.questionnaires ?? {});
33
+ throw new Error(`No synced questionnaire "${slug}". Run \`medfunnels pull\`. Available: ${known.join(", ") || "none"}.`);
34
+ }
35
+ return info(slug, found);
36
+ }
37
+ async function slugFrom(context) {
38
+ return (await context.params).slug;
39
+ }
40
+ /** GET {basePath}/[slug]/frame: the sandboxed questionnaire page, with its exact security headers. */
41
+ async function frameRoute(_request, context) {
42
+ const found = release(await slugFrom(context));
43
+ if (!found)
44
+ return new Response("Not found", { status: 404 });
45
+ const { html, headers } = buildFrame(found.bundle, { title: found.manifest.questionnaire.name });
46
+ // The page is UTF-8; a string body is encoded back to exactly these bytes.
47
+ return new Response(new TextDecoder().decode(html), { status: 200, headers });
48
+ }
49
+ /** POST {basePath}/[slug]/submit: validate the answers, then call your handler with them. */
50
+ function submitRoute(handler) {
51
+ return async function POST(request, context) {
52
+ const slug = await slugFrom(context);
53
+ const found = release(slug);
54
+ if (!found)
55
+ return json({ ok: false, errors: { _: "This questionnaire isn't available." } }, 404);
56
+ if (!(request.headers.get("content-type") ?? "").includes("application/json"))
57
+ return json({ ok: false, errors: { _: "Expected JSON." } }, 415);
58
+ const body = await request.text();
59
+ if (encoder.encode(body).length > MAX_BODY_BYTES)
60
+ return json({ ok: false, errors: { _: "Too large." } }, 413);
61
+ let answers;
62
+ try {
63
+ answers = validateResponse(found.definition, JSON.parse(body), { questionnaireId: found.questionnaireId, releaseId: found.releaseId });
64
+ }
65
+ catch (error) {
66
+ if (error instanceof ContractError)
67
+ return json({ ok: false, errors: error.errors }, 422);
68
+ return json({ ok: false, errors: { _: "Answers couldn't be read." } }, 400);
69
+ }
70
+ try {
71
+ const result = await handler(answers, { request, questionnaire: info(slug, found), release: found });
72
+ if (result && result.ok === false)
73
+ return json({ ok: false, errors: { _: result.message ?? "Your answers couldn't be saved." } }, 409);
74
+ }
75
+ catch (error) {
76
+ // Never log the answers or request body: they're patient data.
77
+ console.error(`[medfunnels] submit handler failed for ${slug}: ${error instanceof Error ? error.name : "error"}`);
78
+ return json({ ok: false, errors: { _: "Your answers couldn't be saved. Please try again." } }, 500);
79
+ }
80
+ return json({ ok: true });
81
+ };
82
+ }
83
+ return { questionnaire, release, frameRoute, submitRoute, basePath };
84
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * @typedef {{ contract: string, questionnaireId: string, releaseId: string, version: number, answers: Array<{ questionKey: string, kind: string, value: unknown }> }} QuestionnaireResponse
3
+ * @typedef {{ ok: true } | { ok: false, errors?: Record<string, string>, message?: string }} SubmitResult
4
+ * @typedef {{
5
+ * container: HTMLElement,
6
+ * frameUrl: string,
7
+ * releaseId: string,
8
+ * title: string,
9
+ * theme?: Record<string, string | number>,
10
+ * labels?: Record<string, string>,
11
+ * initialAnswers?: Record<string, unknown>,
12
+ * onSubmit: (response: QuestionnaireResponse) => Promise<SubmitResult>,
13
+ * onBack?: () => void,
14
+ * onEnd?: (stop: { questionKey: string, clinicalTag: string }) => void,
15
+ * onProgress?: (progress: { page: number, pages: number, answered: number, total: number }) => void,
16
+ * onReady?: () => void,
17
+ * onError?: (code: string, detail?: string) => void,
18
+ * onState?: (state: { answers: Record<string, unknown>, screen: { id: string, type: string, questionKeys: string[] } | null }) => void,
19
+ * readyTimeoutMs?: number,
20
+ * documentRef?: Document,
21
+ * windowRef?: Window,
22
+ * }} MountOptions
23
+ */
24
+ /**
25
+ * @param {MountOptions} options
26
+ */
27
+ export function mountQuestionnaire(options: MountOptions): {
28
+ destroy: () => void;
29
+ iframe: HTMLIFrameElement;
30
+ goto: (questionKey: string) => void;
31
+ };
32
+ export const EMBED_PROTOCOL: 1;
33
+ export const FRAME_SANDBOX: "allow-scripts allow-forms";
34
+ export type QuestionnaireResponse = {
35
+ contract: string;
36
+ questionnaireId: string;
37
+ releaseId: string;
38
+ version: number;
39
+ answers: Array<{
40
+ questionKey: string;
41
+ kind: string;
42
+ value: unknown;
43
+ }>;
44
+ };
45
+ export type SubmitResult = {
46
+ ok: true;
47
+ } | {
48
+ ok: false;
49
+ errors?: Record<string, string>;
50
+ message?: string;
51
+ };
52
+ export type MountOptions = {
53
+ container: HTMLElement;
54
+ frameUrl: string;
55
+ releaseId: string;
56
+ title: string;
57
+ theme?: Record<string, string | number>;
58
+ labels?: Record<string, string>;
59
+ initialAnswers?: Record<string, unknown>;
60
+ onSubmit: (response: QuestionnaireResponse) => Promise<SubmitResult>;
61
+ onBack?: () => void;
62
+ onEnd?: (stop: {
63
+ questionKey: string;
64
+ clinicalTag: string;
65
+ }) => void;
66
+ onProgress?: (progress: {
67
+ page: number;
68
+ pages: number;
69
+ answered: number;
70
+ total: number;
71
+ }) => void;
72
+ onReady?: () => void;
73
+ onError?: (code: string, detail?: string) => void;
74
+ onState?: (state: {
75
+ answers: Record<string, unknown>;
76
+ screen: {
77
+ id: string;
78
+ type: string;
79
+ questionKeys: string[];
80
+ } | null;
81
+ }) => void;
82
+ readyTimeoutMs?: number;
83
+ documentRef?: Document;
84
+ windowRef?: Window;
85
+ };
@@ -0,0 +1,157 @@
1
+ // @ts-check
2
+ // medicalforms.ai embed controller — runs in the CUSTOMER's page (the host).
3
+ //
4
+ // Mounts a verified questionnaire frame served by the customer's own backend inside an
5
+ // opaque-origin sandbox, and relays messages. It never contacts medicalforms.ai and never
6
+ // sends data anywhere itself: submission is delegated to the host's `onSubmit` callback.
7
+ export const EMBED_PROTOCOL = 1;
8
+ export const FRAME_SANDBOX = "allow-scripts allow-forms";
9
+ const MIN_HEIGHT = 240;
10
+ const MAX_HEIGHT = 20000;
11
+ function randomNonce() {
12
+ const bytes = new Uint8Array(16);
13
+ crypto.getRandomValues(bytes);
14
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
15
+ }
16
+ /**
17
+ * @typedef {{ contract: string, questionnaireId: string, releaseId: string, version: number, answers: Array<{ questionKey: string, kind: string, value: unknown }> }} QuestionnaireResponse
18
+ * @typedef {{ ok: true } | { ok: false, errors?: Record<string, string>, message?: string }} SubmitResult
19
+ * @typedef {{
20
+ * container: HTMLElement,
21
+ * frameUrl: string,
22
+ * releaseId: string,
23
+ * title: string,
24
+ * theme?: Record<string, string | number>,
25
+ * labels?: Record<string, string>,
26
+ * initialAnswers?: Record<string, unknown>,
27
+ * onSubmit: (response: QuestionnaireResponse) => Promise<SubmitResult>,
28
+ * onBack?: () => void,
29
+ * onEnd?: (stop: { questionKey: string, clinicalTag: string }) => void,
30
+ * onProgress?: (progress: { page: number, pages: number, answered: number, total: number }) => void,
31
+ * onReady?: () => void,
32
+ * onError?: (code: string, detail?: string) => void,
33
+ * onState?: (state: { answers: Record<string, unknown>, screen: { id: string, type: string, questionKeys: string[] } | null }) => void,
34
+ * readyTimeoutMs?: number,
35
+ * documentRef?: Document,
36
+ * windowRef?: Window,
37
+ * }} MountOptions
38
+ */
39
+ /**
40
+ * @param {MountOptions} options
41
+ */
42
+ export function mountQuestionnaire(options) {
43
+ const doc = options.documentRef || document;
44
+ const win = options.windowRef || window;
45
+ if (!options.frameUrl.startsWith("/"))
46
+ throw new Error("frameUrl must be a same-origin path served by your backend.");
47
+ const nonce = randomNonce();
48
+ const iframe = /** @type {HTMLIFrameElement} */ (doc.createElement("iframe"));
49
+ // Never add allow-same-origin: the frame must not reach the host's cookies, storage, or APIs.
50
+ iframe.setAttribute("sandbox", FRAME_SANDBOX);
51
+ iframe.setAttribute("referrerpolicy", "no-referrer");
52
+ iframe.setAttribute("allow", "");
53
+ iframe.setAttribute("title", options.title);
54
+ iframe.setAttribute("loading", "eager");
55
+ iframe.className = "medicalforms-frame";
56
+ iframe.style.width = "100%";
57
+ iframe.style.border = "0";
58
+ iframe.style.display = "block";
59
+ iframe.style.height = `${MIN_HEIGHT}px`;
60
+ let loads = 0;
61
+ let ready = false;
62
+ let destroyed = false;
63
+ let submitting = false;
64
+ const fail = (/** @type {string} */ code, /** @type {string} */ detail = "") => {
65
+ if (destroyed)
66
+ return;
67
+ destroy();
68
+ options.onError?.(code, detail);
69
+ };
70
+ const post = (/** @type {Record<string, unknown>} */ message) => {
71
+ // "*" is required for an opaque-origin frame; the recipient is pinned by window reference
72
+ // and we stop talking to it if it ever navigates (see the load counter).
73
+ iframe.contentWindow?.postMessage({ ...message, protocol: EMBED_PROTOCOL, nonce }, "*");
74
+ };
75
+ const timer = win.setTimeout(() => { if (!ready)
76
+ fail("frame_timeout", "Questionnaire did not start."); }, options.readyTimeoutMs ?? 15000);
77
+ iframe.addEventListener("load", () => {
78
+ loads += 1;
79
+ if (loads > 1)
80
+ fail("frame_navigated", "Questionnaire frame navigated unexpectedly.");
81
+ });
82
+ /** @param {MessageEvent} event */
83
+ const onMessage = async (event) => {
84
+ if (destroyed || event.source !== iframe.contentWindow || event.origin !== "null")
85
+ return;
86
+ const data = event.data;
87
+ if (!data || typeof data !== "object" || data.protocol !== EMBED_PROTOCOL || typeof data.type !== "string")
88
+ return;
89
+ if (data.type === "mf:ready") {
90
+ if (ready)
91
+ return;
92
+ if (data.releaseId !== options.releaseId)
93
+ return fail("release_mismatch", "Frame is running a different release.");
94
+ ready = true;
95
+ win.clearTimeout(timer);
96
+ post({ type: "mf:init", theme: options.theme || {}, labels: options.labels || {}, initialAnswers: options.initialAnswers || null, observe: Boolean(options.onState) });
97
+ options.onReady?.();
98
+ return;
99
+ }
100
+ if (!ready || data.nonce !== nonce)
101
+ return;
102
+ if (data.type === "mf:resize" && typeof data.height === "number") {
103
+ iframe.style.height = `${Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, Math.ceil(data.height)))}px`;
104
+ }
105
+ else if (data.type === "mf:progress") {
106
+ options.onProgress?.({ page: Number(data.page) || 1, pages: Number(data.pages) || 1, answered: Number(data.answered) || 0, total: Number(data.total) || 0 });
107
+ }
108
+ else if (data.type === "mf:state" && options.onState) {
109
+ options.onState({ answers: data.answers && typeof data.answers === "object" ? data.answers : {}, screen: data.screen || null });
110
+ }
111
+ else if (data.type === "mf:end") {
112
+ // The patient reached a stop screen. No answers are attached; your server decides next steps.
113
+ options.onEnd?.({ questionKey: String(data.questionKey || ""), clinicalTag: String(data.clinicalTag || "") });
114
+ }
115
+ else if (data.type === "mf:back") {
116
+ options.onBack?.();
117
+ }
118
+ else if (data.type === "mf:submit" && !submitting) {
119
+ const response = data.response;
120
+ if (!response || response.releaseId !== options.releaseId || !Array.isArray(response.answers)) {
121
+ post({ type: "mf:submit-result", ok: false, errors: {} });
122
+ return;
123
+ }
124
+ submitting = true;
125
+ try {
126
+ const result = await options.onSubmit(response);
127
+ if (!destroyed)
128
+ post({ type: "mf:submit-result", ...result });
129
+ }
130
+ catch (error) {
131
+ if (!destroyed)
132
+ post({ type: "mf:submit-result", ok: false, errors: {} });
133
+ options.onError?.("submit_failed", error instanceof Error ? error.message : "");
134
+ }
135
+ finally {
136
+ submitting = false;
137
+ }
138
+ }
139
+ };
140
+ function destroy() {
141
+ destroyed = true;
142
+ win.clearTimeout(timer);
143
+ win.removeEventListener("message", onMessage);
144
+ iframe.remove();
145
+ }
146
+ win.addEventListener("message", onMessage);
147
+ // Set src before insertion so the browser never loads an initial about:blank document,
148
+ // which would otherwise count as a navigation.
149
+ iframe.setAttribute("src", options.frameUrl);
150
+ options.container.replaceChildren(iframe);
151
+ /** Jump the questionnaire to the screen containing a question (used by authoring tools). */
152
+ function goto(/** @type {string} */ questionKey) {
153
+ if (ready && !destroyed)
154
+ post({ type: "mf:goto", questionKey });
155
+ }
156
+ return { destroy, iframe, goto };
157
+ }
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "@medfunnels/next",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "description": "Run medFunnels questionnaires in your Next.js app: build-time sync, route handlers and a React component.",
5
+ "license": "MIT",
6
+ "homepage": "https://www.medfunnels.app",
7
+ "keywords": [
8
+ "medfunnels",
9
+ "nextjs",
10
+ "react",
11
+ "telehealth",
12
+ "questionnaire",
13
+ "intake"
14
+ ],
15
+ "type": "module",
16
+ "bin": {
17
+ "medfunnels": "./dist/cli.js"
18
+ },
19
+ "exports": {
20
+ "./server": {
21
+ "types": "./dist/server.d.ts",
22
+ "import": "./dist/server.js"
23
+ },
24
+ "./client": {
25
+ "types": "./dist/client.d.ts",
26
+ "import": "./dist/client.js"
27
+ },
28
+ "./pull": {
29
+ "types": "./dist/pull.d.ts",
30
+ "import": "./dist/pull.js"
31
+ }
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "engines": {
39
+ "node": ">=18.17"
40
+ },
41
+ "scripts": {
42
+ "build": "rm -rf dist && node scripts/copy-embed.mjs && tsc -p tsconfig.json && chmod +x dist/cli.js",
43
+ "test": "node --test test/*.test.mjs"
44
+ },
45
+ "dependencies": {
46
+ "@medfunnels/sdk": "^1.0.0"
47
+ },
48
+ "peerDependencies": {
49
+ "next": ">=14",
50
+ "react": ">=18"
51
+ },
52
+ "peerDependenciesMeta": {
53
+ "next": {
54
+ "optional": true
55
+ }
56
+ }
57
+ }