@patientos/website-kit 0.2.3 → 0.2.5

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.
@@ -6,7 +6,7 @@ import {
6
6
  PortalAccountClient,
7
7
  PortalPanelClient,
8
8
  StoreClient
9
- } from "./chunk-LHP4RZ3N.js";
9
+ } from "./chunk-75FJ5AJB.js";
10
10
  import {
11
11
  mountIslandsWith
12
12
  } from "./chunk-THMT43MV.js";
@@ -9,9 +9,135 @@ var PORTAL_WHOAMI_PATH = "/portal/api/whoami";
9
9
  var PORTAL_MAGIC_LINK_PATH = "/portal/api/magic-link";
10
10
  var PORTAL_PREFILL_PATH = "/portal/api/prefill";
11
11
  var PORTAL_PUBLIC_CLAIM_PATH = "/portal/api/public-claim";
12
+ var PORTAL_APPLICATION_HANDOFF_PATH = "/portal/api/application-run-handoffs";
13
+ var PORTAL_SESSION_EXCHANGE_PATH = "/portal/api/session-exchange";
12
14
  var PORTAL_RETURN_PATH = "/portal/return";
15
+ var PORTAL_HANDOFF_FRAGMENT_PREFIX = "#patientos-handoff=";
13
16
  var BRIDGE_TIMEOUT_MS = 15e3;
14
17
  var BRIDGE_PROBE_TIMEOUT_MS = 8e3;
18
+ var PORTAL_BEARER_STORAGE_PREFIX = "patientos.portal.bearer:";
19
+ var parkedHandoffPayload = null;
20
+ var pendingHandoffTokens = /* @__PURE__ */ new Map();
21
+ var pendingHandoffExchanges = /* @__PURE__ */ new Map();
22
+ var memoryBearers = /* @__PURE__ */ new Map();
23
+ function onLocalhostDevPage() {
24
+ if (typeof window === "undefined") return false;
25
+ return window.location.protocol === "http:" && window.location.hostname === "localhost" && window.location.port !== "";
26
+ }
27
+ function localhostHandoffEnabled(apiOrigin) {
28
+ return apiOrigin !== "" && onLocalhostDevPage();
29
+ }
30
+ function bearerStorage() {
31
+ try {
32
+ return typeof window === "undefined" ? null : window.sessionStorage;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+ function bearerStorageKey(apiOrigin) {
38
+ return `${PORTAL_BEARER_STORAGE_PREFIX}${apiOrigin}`;
39
+ }
40
+ function readBearer(apiOrigin) {
41
+ const memory = memoryBearers.get(apiOrigin);
42
+ if (memory) return memory;
43
+ try {
44
+ const stored = bearerStorage()?.getItem(bearerStorageKey(apiOrigin)) ?? null;
45
+ if (!stored || stored.length > 4096) {
46
+ clearBearer(apiOrigin);
47
+ return null;
48
+ }
49
+ memoryBearers.set(apiOrigin, stored);
50
+ return stored;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+ function writeBearer(apiOrigin, token) {
56
+ memoryBearers.set(apiOrigin, token);
57
+ try {
58
+ bearerStorage()?.setItem(bearerStorageKey(apiOrigin), token);
59
+ } catch {
60
+ }
61
+ }
62
+ function clearBearer(apiOrigin) {
63
+ memoryBearers.delete(apiOrigin);
64
+ try {
65
+ bearerStorage()?.removeItem(bearerStorageKey(apiOrigin));
66
+ } catch {
67
+ }
68
+ }
69
+ function decodeBase64UrlJson(raw) {
70
+ const b64 = raw.replace(/-/g, "+").replace(/_/g, "/");
71
+ const binary = atob(b64.padEnd(Math.ceil(b64.length / 4) * 4, "="));
72
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
73
+ return JSON.parse(new TextDecoder().decode(bytes));
74
+ }
75
+ function consumePortalHandoffFragment() {
76
+ if (!onLocalhostDevPage()) return;
77
+ const raw = window.location.hash;
78
+ if (!raw.startsWith(PORTAL_HANDOFF_FRAGMENT_PREFIX)) return;
79
+ let payload = null;
80
+ try {
81
+ const decoded = decodeBase64UrlJson(raw.slice(PORTAL_HANDOFF_FRAGMENT_PREFIX.length));
82
+ const candidate = decoded;
83
+ if (typeof candidate?.token === "string" && candidate.token.length > 0 && candidate.token.length <= 512 && typeof candidate.returnHash === "string") {
84
+ payload = { token: candidate.token, returnHash: candidate.returnHash };
85
+ }
86
+ } catch {
87
+ }
88
+ const restoredHash = payload?.returnHash ? `#${payload.returnHash}` : "";
89
+ window.history.replaceState(
90
+ window.history.state,
91
+ "",
92
+ `${window.location.pathname}${window.location.search}${restoredHash}`
93
+ );
94
+ if (payload) parkedHandoffPayload = payload;
95
+ }
96
+ function adoptParkedHandoff(apiOrigin) {
97
+ const payload = parkedHandoffPayload;
98
+ if (!payload || !localhostHandoffEnabled(apiOrigin)) return;
99
+ parkedHandoffPayload = null;
100
+ clearBearer(apiOrigin);
101
+ pendingHandoffTokens.set(apiOrigin, payload.token);
102
+ }
103
+ async function exchangePortalHandoff(apiOrigin, fetchImpl) {
104
+ const pending = pendingHandoffExchanges.get(apiOrigin);
105
+ if (pending) return pending;
106
+ const oneTimeToken = pendingHandoffTokens.get(apiOrigin);
107
+ if (!oneTimeToken) return readBearer(apiOrigin);
108
+ pendingHandoffTokens.delete(apiOrigin);
109
+ const exchange = (async () => {
110
+ const controller = new AbortController();
111
+ const timer = setTimeout(() => controller.abort(), BRIDGE_PROBE_TIMEOUT_MS);
112
+ try {
113
+ const response = await fetchImpl(`${apiOrigin}${PORTAL_SESSION_EXCHANGE_PATH}`, {
114
+ ...PORTAL_REQUEST_INIT,
115
+ method: "POST",
116
+ headers: { accept: "application/json", "content-type": "application/json" },
117
+ body: JSON.stringify({ token: oneTimeToken }),
118
+ signal: controller.signal
119
+ });
120
+ const result = await readPortalResult(response);
121
+ const bearer = result.ok && typeof result.data?.token === "string" ? result.data.token : null;
122
+ if (!bearer) return null;
123
+ writeBearer(apiOrigin, bearer);
124
+ return bearer;
125
+ } catch {
126
+ pendingHandoffTokens.set(apiOrigin, oneTimeToken);
127
+ return null;
128
+ } finally {
129
+ clearTimeout(timer);
130
+ }
131
+ })();
132
+ pendingHandoffExchanges.set(apiOrigin, exchange);
133
+ try {
134
+ return await exchange;
135
+ } finally {
136
+ if (pendingHandoffExchanges.get(apiOrigin) === exchange) {
137
+ pendingHandoffExchanges.delete(apiOrigin);
138
+ }
139
+ }
140
+ }
15
141
  var PORTAL_REQUEST_INIT = {
16
142
  credentials: "include",
17
143
  cache: "no-store"
@@ -71,17 +197,41 @@ function createPortalClient(config = {}) {
71
197
  const portalHref = config.portalUrl?.trim() ?? "";
72
198
  const available = apiOrigin !== "" || sameOriginAvailable(config.portalOrigin);
73
199
  const fetchImpl = config.fetchImpl ?? ((input, init) => globalThis.fetch(input, init));
200
+ const handoffEnabled = localhostHandoffEnabled(apiOrigin);
201
+ consumePortalHandoffFragment();
202
+ adoptParkedHandoff(apiOrigin);
74
203
  const url = (path) => apiOrigin && path.startsWith("/") ? apiOrigin + path : path;
204
+ const fetchWithSession = async (input, init) => {
205
+ let belongsToApiOrigin = false;
206
+ if (handoffEnabled) {
207
+ try {
208
+ belongsToApiOrigin = new URL(input, window.location.href).origin === apiOrigin;
209
+ } catch {
210
+ }
211
+ }
212
+ const bearer = belongsToApiOrigin ? await exchangePortalHandoff(apiOrigin, fetchImpl) : null;
213
+ if (!bearer) return fetchImpl(input, init);
214
+ const headers = new Headers(init?.headers);
215
+ headers.set("authorization", `Bearer ${bearer}`);
216
+ return fetchImpl(input, { ...init, headers });
217
+ };
75
218
  async function request(path, init, timeoutMs = BRIDGE_TIMEOUT_MS) {
76
219
  const controller = new AbortController();
77
220
  const timer = setTimeout(() => controller.abort(), timeoutMs);
78
221
  try {
79
- const response = await fetchImpl(url(path), {
222
+ const response = await fetchWithSession(url(path), {
80
223
  ...PORTAL_REQUEST_INIT,
81
224
  ...init,
82
225
  signal: controller.signal
83
226
  });
84
- return await readPortalResult(response);
227
+ const result = await readPortalResult(response);
228
+ if (handoffEnabled) {
229
+ const body = result.ok ? result.data : null;
230
+ if (path === "/portal/api/sign-out" && result.ok || !result.ok && result.status === 401 || body?.signedIn === false) {
231
+ clearBearer(apiOrigin);
232
+ }
233
+ }
234
+ return result;
85
235
  } catch {
86
236
  return { ok: false, status: null, error: null };
87
237
  } finally {
@@ -95,18 +245,27 @@ function createPortalClient(config = {}) {
95
245
  body: JSON.stringify(body ?? {})
96
246
  });
97
247
  const del = (path) => request(path, { method: "DELETE", headers: { accept: "application/json" } });
98
- const returnPath = () => {
248
+ const returnPath = (targetUrl) => {
99
249
  if (typeof window === "undefined") return "/";
100
- const path = `${window.location.pathname || "/"}${window.location.search}${window.location.hash}`;
101
- if (!apiOrigin) return path;
102
- return `${PORTAL_RETURN_PATH}?to=${encodePortalReturnTarget(window.location.origin + path)}`;
250
+ let target = targetUrl ?? window.location.href;
251
+ try {
252
+ const parsed2 = new URL(target, window.location.href);
253
+ if (parsed2.origin !== window.location.origin) target = window.location.href;
254
+ else target = parsed2.href;
255
+ } catch {
256
+ target = window.location.href;
257
+ }
258
+ const parsed = new URL(target);
259
+ const path = `${parsed.pathname || "/"}${parsed.search}${parsed.hash}`;
260
+ if (!apiOrigin && !targetUrl) return path;
261
+ return `${PORTAL_RETURN_PATH}?to=${encodePortalReturnTarget(target)}`;
103
262
  };
104
263
  return {
105
264
  available,
106
265
  apiOrigin,
107
266
  portalHref,
108
267
  url,
109
- fetch: fetchImpl,
268
+ fetch: fetchWithSession,
110
269
  async whoAmI() {
111
270
  if (!available) return { signedIn: false };
112
271
  const result = await request(
@@ -134,6 +293,15 @@ function createPortalClient(config = {}) {
134
293
  const result = await send(PORTAL_PUBLIC_CLAIM_PATH, "POST", {});
135
294
  return result.ok ? result.data : null;
136
295
  },
296
+ async redeemApplicationRunHandoff(handoffId) {
297
+ if (!available) return null;
298
+ const result = await send(
299
+ `${PORTAL_APPLICATION_HANDOFF_PATH}/${encodeURIComponent(handoffId)}/redeem`,
300
+ "POST",
301
+ {}
302
+ );
303
+ return result.ok ? result.data : null;
304
+ },
137
305
  returnPath,
138
306
  get,
139
307
  send,
@@ -145,13 +313,9 @@ function createPortalClient(config = {}) {
145
313
  import { jsx, jsxs } from "react/jsx-runtime";
146
314
  var defaultPortalClient = createPortalClient();
147
315
  var PORTAL_SIGN_OUT_URL = "/portal/api/sign-out";
148
- function portalFetch(fetchImpl) {
149
- return fetchImpl ?? ((input, init) => fetch(input, init));
150
- }
151
316
  var BOUND_PORTAL_CLIENT = /* @__PURE__ */ Symbol("boundPortalClient");
152
- function portalFetchForClient(client2, fetchImpl) {
153
- const fetcher = portalFetch(fetchImpl);
154
- const bound = (input, init) => fetcher(client2.url(input), init);
317
+ function portalFetchForClient(client2) {
318
+ const bound = (input, init) => client2.fetch(client2.url(input), init);
155
319
  bound[BOUND_PORTAL_CLIENT] = client2;
156
320
  return bound;
157
321
  }
@@ -563,6 +727,25 @@ function resetSession() {
563
727
  clientKey = "";
564
728
  }
565
729
  var flowKey = (requestId) => `pos:flow:${requestId}`;
730
+ var applicationRunKey = (runId) => `pos:application-run:${runId}`;
731
+ function saveApplicationRunToken(c, runId) {
732
+ const token = c.getApplicationRunToken(runId);
733
+ if (!token) return;
734
+ try {
735
+ session()?.setItem(applicationRunKey(runId), token);
736
+ } catch {
737
+ }
738
+ }
739
+ function restoreApplicationRunToken(c, runId) {
740
+ try {
741
+ const token = session()?.getItem(applicationRunKey(runId)) ?? null;
742
+ if (!token) return false;
743
+ c.setApplicationRunToken(runId, token);
744
+ return true;
745
+ } catch {
746
+ return false;
747
+ }
748
+ }
566
749
  function saveFlowRecord(requestId, record) {
567
750
  try {
568
751
  session()?.setItem(flowKey(requestId), JSON.stringify(record));
@@ -1283,6 +1466,8 @@ export {
1283
1466
  persistClaimToken,
1284
1467
  adoptClaimToken,
1285
1468
  resetSession,
1469
+ saveApplicationRunToken,
1470
+ restoreApplicationRunToken,
1286
1471
  saveFlowRecord,
1287
1472
  loadFlowRecord,
1288
1473
  once,
package/dist/index.d.ts CHANGED
@@ -2,12 +2,13 @@ export { BPOINT_SCRIPT_ORIGIN, BPOINT_SCRIPT_URL, CONSULT_CHUNK_PATH, THIRD_PART
2
2
  import * as React from 'react';
3
3
  export { ISLAND_ATTR, ISLAND_PROPS_ATTR, Island, IslandProps, IslandRegistry, mountIslandsWith, readIslandProps } from './islands.js';
4
4
  export { ISLANDS, IslandName, mountIslands } from './islands-registry.js';
5
- import { P as PortalClient, W as WhoAmI, a as PortalPrefill, b as PublicClaim } from './portal-account-pI1cWgsT.js';
6
- export { F as FetchLike, c as PortalAccount, d as PortalAccountMarkerProps, e as PortalAccountProps, f as PortalAccountSurface, g as PortalClientConfig, h as PortalPanel, i as PortalPanelMarkerProps, j as PortalPanelProps, k as PortalResult, l as PortalSurface, m as createPortalClient } from './portal-account-pI1cWgsT.js';
5
+ import { P as PortalClient, W as WhoAmI, a as PortalPrefill, b as PublicClaim } from './portal-account-8uQc_Ncx.js';
6
+ export { F as FetchLike, c as PortalAccount, d as PortalAccountMarkerProps, e as PortalAccountProps, f as PortalAccountSurface, g as PortalClientConfig, h as PortalPanel, i as PortalPanelMarkerProps, j as PortalPanelProps, k as PortalResult, l as PortalSurface, m as createPortalClient } from './portal-account-8uQc_Ncx.js';
7
7
  export { B as BookingBlock, a as BookingBlockMarkerProps, b as BookingBlockProps, c as BookingTypeOption, C as Cart, d as CartMarkerProps, e as CartProps, f as CertificateFunnel, g as CertificateFunnelMarkerProps, h as CertificateFunnelProps, i as Checkout, j as CheckoutMarkerProps, k as CheckoutProps, F as FunnelPublicApi, l as FunnelServiceOption, S as Store, m as StoreMarkerProps, n as StoreProps } from './checkout-block-Cog4H4I4.js';
8
8
  export { PatientSurfaceTheme, patientSurfaceTheme } from './patient-surface-theme.js';
9
9
  export { Rgb, contrastRatio, deriveForeground, parseHexColor, relativeLuminance } from './contrast.js';
10
10
  export { AnswerValue, Answers, ShowWhenCondition, evaluateShowWhen, visibleQuestions } from './show-when.js';
11
+ import '@patientos/public-sdk';
11
12
 
12
13
  /**
13
14
  * Theme tokens for the website-kit.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  ISLANDS,
3
3
  mountIslands
4
- } from "./chunk-MTOKBMBG.js";
4
+ } from "./chunk-7UPUEMXX.js";
5
5
  import {
6
6
  PortalAccount,
7
7
  PortalPanel,
@@ -13,7 +13,7 @@ import {
13
13
  getThemeTokens,
14
14
  usePortalClient,
15
15
  usePortalSession
16
- } from "./chunk-LHP4RZ3N.js";
16
+ } from "./chunk-75FJ5AJB.js";
17
17
  import {
18
18
  BPOINT_SCRIPT_ORIGIN,
19
19
  BPOINT_SCRIPT_URL,
@@ -35,7 +35,7 @@ import {
35
35
  } from "./chunk-THMT43MV.js";
36
36
  import {
37
37
  createPortalClient
38
- } from "./chunk-BHVDY6SC.js";
38
+ } from "./chunk-FSZ726DM.js";
39
39
  import {
40
40
  contrastRatio,
41
41
  deriveForeground,
@@ -1,7 +1,8 @@
1
1
  import * as React from 'react';
2
2
  import { b as BookingBlockProps, h as CertificateFunnelProps, n as StoreProps, e as CartProps, k as CheckoutProps } from './checkout-block-Cog4H4I4.js';
3
- import { j as PortalPanelProps, F as FetchLike } from './portal-account-pI1cWgsT.js';
4
- export { P as PortalAccountClient } from './portal-account.client-DPRRmfCJ.js';
3
+ import { j as PortalPanelProps, F as FetchLike } from './portal-account-8uQc_Ncx.js';
4
+ export { P as PortalAccountClient } from './portal-account.client-BBe_q9yC.js';
5
+ import '@patientos/public-sdk';
5
6
 
6
7
  declare function BookingBlockClient(props: BookingBlockProps): React.ReactElement;
7
8
 
@@ -6,11 +6,11 @@ import {
6
6
  PortalAccountClient,
7
7
  PortalPanelClient,
8
8
  StoreClient
9
- } from "./chunk-LHP4RZ3N.js";
9
+ } from "./chunk-75FJ5AJB.js";
10
10
  import "./chunk-ZOF22TJA.js";
11
11
  import "./chunk-QDO3SJWR.js";
12
12
  import "./chunk-THMT43MV.js";
13
- import "./chunk-BHVDY6SC.js";
13
+ import "./chunk-FSZ726DM.js";
14
14
  import "./chunk-MLKGABMK.js";
15
15
  export {
16
16
  BookingBlockClient,
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  ISLANDS,
3
3
  mountIslands
4
- } from "./chunk-MTOKBMBG.js";
5
- import "./chunk-LHP4RZ3N.js";
4
+ } from "./chunk-7UPUEMXX.js";
5
+ import "./chunk-75FJ5AJB.js";
6
6
  import "./chunk-ZOF22TJA.js";
7
7
  import "./chunk-QDO3SJWR.js";
8
8
  import "./chunk-THMT43MV.js";
9
- import "./chunk-BHVDY6SC.js";
9
+ import "./chunk-FSZ726DM.js";
10
10
  import "./chunk-MLKGABMK.js";
11
11
  export {
12
12
  ISLANDS,
@@ -1,3 +1,4 @@
1
+ import { ApplicationRun } from '@patientos/public-sdk';
1
2
  import * as React from 'react';
2
3
 
3
4
  /** The injectable browser transport. */
@@ -64,8 +65,13 @@ interface PortalClient {
64
65
  prefill(): Promise<PortalPrefill | null>;
65
66
  /** Mint a public claim only for the signed-in patient. */
66
67
  mintPublicClaim(): Promise<PublicClaim | null>;
67
- /** Current page callback, using the app-side return hop when cross-origin. */
68
- returnPath(): string;
68
+ /** Redeem a single-use magic-link continuation after patient sign-in. */
69
+ redeemApplicationRunHandoff(handoffId: string): Promise<{
70
+ run: ApplicationRun;
71
+ capabilityToken: string;
72
+ } | null>;
73
+ /** Page callback, using the app-side return hop when cross-origin. */
74
+ returnPath(targetUrl?: string): string;
69
75
  /** Back-compat account-surface transport; callers still use constant paths. */
70
76
  get<T>(path: string): Promise<PortalResult<T>>;
71
77
  /** Back-compat account-surface JSON write; callers still use constant paths. */
@@ -1,5 +1,5 @@
1
1
  import * as React from 'react';
2
- import { e as PortalAccountProps, F as FetchLike } from './portal-account-pI1cWgsT.js';
2
+ import { e as PortalAccountProps, F as FetchLike } from './portal-account-8uQc_Ncx.js';
3
3
 
4
4
  type PortalAddress = {
5
5
  id: string;
@@ -1,6 +1,7 @@
1
1
  import * as React from 'react';
2
- import { F as FetchLike, k as PortalResult } from './portal-account-pI1cWgsT.js';
3
- import { a as PortalAppointment, b as PortalAppointmentsBody } from './portal-account.client-DPRRmfCJ.js';
2
+ import { F as FetchLike, k as PortalResult } from './portal-account-8uQc_Ncx.js';
3
+ import { a as PortalAppointment, b as PortalAppointmentsBody } from './portal-account.client-BBe_q9yC.js';
4
+ import '@patientos/public-sdk';
4
5
 
5
6
  /** One bookable instant, collapsing the practitioners free at the same time. */
6
7
  interface TimeSlot {
@@ -22,7 +22,7 @@ import {
22
22
  releasePortalHold,
23
23
  rescheduleContextTypeId,
24
24
  reschedulePortalAppointmentAndReload
25
- } from "./chunk-BHVDY6SC.js";
25
+ } from "./chunk-FSZ726DM.js";
26
26
  import "./chunk-MLKGABMK.js";
27
27
  export {
28
28
  AVAILABILITY_FAILED_COPY,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patientos/website-kit",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "PatientOS clinic website components and patient-facing interactive islands.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -109,12 +109,13 @@
109
109
  "@types/node": "^25.6.0",
110
110
  "@types/react": "^19.2.14",
111
111
  "@types/react-dom": "^19.2.3",
112
+ "jsdom": "^29.1.1",
112
113
  "tsup": "^8.5.0",
113
114
  "typescript": "^5.9.3",
114
115
  "vitest": "^4.1.5",
115
116
  "livekit-client": "^2.20.0"
116
117
  },
117
118
  "dependencies": {
118
- "@patientos/public-sdk": "^0.2.0"
119
+ "@patientos/public-sdk": "^0.2.2"
119
120
  }
120
121
  }