@dodomain/connect
Advanced tools
+32
-7
@@ -23,2 +23,3 @@ "use strict"; | ||
| __export(index_exports, { | ||
| MOUNT_BLOCKED: () => MOUNT_BLOCKED, | ||
| showDoDomain: () => showDoDomain | ||
@@ -51,2 +52,3 @@ }); | ||
| // src/index.ts | ||
| var MOUNT_BLOCKED = "MOUNT_BLOCKED"; | ||
| var DEFAULT_BASE = DODOMAIN_DEFAULT_ORIGIN; | ||
@@ -60,2 +62,3 @@ var DEFAULT_LOAD_TIMEOUT_MS = 15e3; | ||
| const origin = new URL(base).origin; | ||
| const hostedUrl = `${base}/connect/${encodeURIComponent(opts.token)}`; | ||
| const backdrop = document.createElement("div"); | ||
@@ -73,3 +76,3 @@ backdrop.setAttribute("data-dodomain", "backdrop"); | ||
| const frame = document.createElement("iframe"); | ||
| frame.src = `${base}/connect/${encodeURIComponent(opts.token)}?${EMBED_PARAM}=${EMBED_VALUE}&${ORIGIN_PARAM}=${encodeURIComponent(window.location.origin)}` + (opts.theme ? `&${THEME_PARAM}=${opts.theme}` : ""); | ||
| frame.src = hostedUrl + `?${EMBED_PARAM}=${EMBED_VALUE}&${ORIGIN_PARAM}=${encodeURIComponent(window.location.origin)}` + (opts.theme ? `&${THEME_PARAM}=${opts.theme}` : ""); | ||
| frame.setAttribute("title", "Connect your domain"); | ||
@@ -98,5 +101,8 @@ const dark = opts.theme === "dark"; | ||
| } | ||
| let state = "unknown"; | ||
| let verifiedDomain; | ||
| let closed = false; | ||
| let loadTimer = setTimeout(() => { | ||
| loadTimer = void 0; | ||
| opts.onError?.({ type: "load-timeout" }); | ||
| reportMountFailure("load-timeout"); | ||
| }, opts.loadTimeoutMs ?? DEFAULT_LOAD_TIMEOUT_MS); | ||
@@ -109,8 +115,22 @@ function clearLoadTimer() { | ||
| } | ||
| function onFrameError() { | ||
| let mountFailureReported = false; | ||
| function reportMountFailure(type) { | ||
| if (mountFailureReported || closed) return; | ||
| mountFailureReported = true; | ||
| clearLoadTimer(); | ||
| opts.onError?.({ type: "load-error" }); | ||
| opts.onError?.({ type, code: MOUNT_BLOCKED, hostedUrl }); | ||
| } | ||
| function onFrameError() { | ||
| reportMountFailure("load-error"); | ||
| } | ||
| frame.addEventListener("error", onFrameError); | ||
| let closed = false; | ||
| function onCspViolation(e) { | ||
| const violation = e; | ||
| const directive = typeof violation.violatedDirective === "string" ? violation.violatedDirective : ""; | ||
| const blockedUri = typeof violation.blockedURI === "string" ? violation.blockedURI : ""; | ||
| const framesBlocked = directive.startsWith("frame-src") || directive.startsWith("child-src") || directive.startsWith("default-src"); | ||
| if (!framesBlocked || !blockedUri.startsWith(origin)) return; | ||
| reportMountFailure("load-error"); | ||
| } | ||
| document.addEventListener("securitypolicyviolation", onCspViolation); | ||
| function teardown() { | ||
@@ -122,2 +142,3 @@ if (closed) return; | ||
| frame.removeEventListener("error", onFrameError); | ||
| document.removeEventListener("securitypolicyviolation", onCspViolation); | ||
| backdrop.remove(); | ||
@@ -127,3 +148,3 @@ } | ||
| teardown(); | ||
| opts.onClose?.(); | ||
| opts.onClose?.(verifiedDomain === void 0 ? { state } : { state, domain: verifiedDomain }); | ||
| } | ||
@@ -136,8 +157,12 @@ function onMessage(e) { | ||
| clearLoadTimer(); | ||
| state = "verified"; | ||
| verifiedDomain = data.domain; | ||
| opts.onVerified?.({ domain: data.domain }); | ||
| } else if (data.type === MESSAGE_TYPES.READY) { | ||
| clearLoadTimer(); | ||
| if (state === "unknown") state = "pending"; | ||
| } else if (data.type === MESSAGE_TYPES.ERROR) { | ||
| clearLoadTimer(); | ||
| opts.onError?.({ type: "session-error", code: data.code }); | ||
| if (state !== "verified") state = "failed"; | ||
| opts.onError?.({ type: "session-error", code: data.code, hostedUrl }); | ||
| } else if (data.type === MESSAGE_TYPES.HEIGHT) { | ||
@@ -144,0 +169,0 @@ applyReportedHeight(data.height); |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"sources":["../src/index.ts","../../core/src/origin.ts","../../core/src/message-types.ts"],"sourcesContent":["// @dodomain/connect — the embeddable browser widget.\n// Opens the hosted connect page in a modal iframe and relays lifecycle events.\n// Integrator branding (App.name/logoUrl/brandColor, 2026-07-21) reaches the\n// end user THROUGH the hosted flow this iframe renders — the widget draws no\n// flow chrome of its own, so it needs no branding API and no postMessage\n// contract change (message-types stays as-is).\n//\n// import { showDoDomain } from \"@dodomain/connect\";\n// const session = await fetch(\"/my-api/create-session\").then(r => r.json());\n// showDoDomain({ token: session.token, onVerified: () => refetch() });\n//\n// FIX(F-008, split F-010): imports the message-type constants + a type-only\n// contract from @dodomain/core/message-types — a ZERO-IMPORT module — never\n// zod at runtime (R5: this widget ships into an INTEGRATOR's page bundle, so\n// it stays dependency-free). F-008 originally imported the plain-const half\n// of @dodomain/core/messages (which ALSO imports zod, for zDoDomainMessage),\n// betting a bundler's tree-shaking would drop the unused zod graph. F-010\n// verified that bet against a real tsup build and it did NOT hold (esbuild's\n// default AND Rollup's tree-shaking both left zod's full runtime in dist,\n// confirmed via test/build.smoke.test.ts) — so the plain consts now live in\n// their own zod-free module (messages.ts's header has the full history) and\n// this package imports ONLY from there, guaranteeing zod can never reach\n// this bundle regardless of any bundler's tree-shaking sophistication.\nimport { DODOMAIN_DEFAULT_ORIGIN } from \"@dodomain/core/origin\";\nimport {\n EMBED_PARAM,\n EMBED_VALUE,\n MESSAGE_TYPES,\n ORIGIN_PARAM,\n THEME_PARAM,\n type DoDomainMessage,\n} from \"@dodomain/core/message-types\";\n\nexport interface ShowDoDomainOptions {\n /** Session token from POST /api/v1/sessions (dd_sess_…). */\n token: string;\n /** DoDomain origin. Defaults to https://app.dodomain.io. */\n baseUrl?: string;\n onVerified?: (detail: { domain?: string }) => void;\n onClose?: () => void;\n /**\n * FIX(F-010): fires when the hosted flow fails to load or reports a\n * session error — a cross-origin iframe's HTTP 404/500 exposes neither\n * `onerror` nor readable content by default, so before this fix a broken\n * embed just sat there silently. See DoDomainWidgetError's own doc for the\n * three cases.\n */\n onError?: (detail: DoDomainWidgetError) => void;\n /**\n * FIX(F-010): milliseconds to wait for the hosted flow's `dodomain:ready`\n * handshake before treating the embed as failed-to-load. Default 15000.\n */\n loadTimeoutMs?: number;\n /**\n * Host-page theme (2026-08-04 embed polish). Pass the theme YOUR page is\n * currently rendering so the embedded sheet matches it — the hosted flow\n * adopts it and hides its own theme toggle. Omitted ⇒ the flow resolves\n * its own theme (prefers-color-scheme / its visitor preference).\n */\n theme?: \"light\" | \"dark\";\n}\n\n/**\n * FIX(F-010): the three ways `onError` can fire.\n * - `load-timeout` — no `dodomain:ready`/`dodomain:verified` arrived within\n * `loadTimeoutMs` (covers a 404/DNS failure/hung load — anything that\n * never gets far enough to run the hosted flow's own JS).\n * - `load-error` — the iframe's own `error` event fired (best-effort;\n * browsers rarely fire this for a cross-origin navigation, but it's free\n * to listen for).\n * - `session-error` — the hosted flow mounted and posted `dodomain:error`\n * with a `code` (e.g. an expired/not-found token, or a verify() failure —\n * see connect-flow.tsx).\n */\nexport type DoDomainWidgetError =\n { type: \"load-timeout\" } | { type: \"load-error\" } | { type: \"session-error\"; code: string };\n\nexport interface DoDomainHandle {\n close: () => void;\n}\n\nconst DEFAULT_BASE = DODOMAIN_DEFAULT_ORIGIN;\nconst DEFAULT_LOAD_TIMEOUT_MS = 15_000;\n\nexport function showDoDomain(opts: ShowDoDomainOptions): DoDomainHandle {\n if (typeof document === \"undefined\") {\n throw new Error(\"showDoDomain must run in a browser\");\n }\n const base = (opts.baseUrl ?? DEFAULT_BASE).replace(/\\/$/, \"\");\n const origin = new URL(base).origin;\n\n const backdrop = document.createElement(\"div\");\n backdrop.setAttribute(\"data-dodomain\", \"backdrop\");\n // Graphite & Pine (docs/DESIGN.md): graphite-ink scrim (#17201C at 55%) — no\n // backdrop-blur (the system bans glassmorphism chrome) and no blue-grays.\n Object.assign(backdrop.style, {\n position: \"fixed\",\n inset: \"0\",\n background: \"rgba(23,32,28,0.55)\",\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n zIndex: \"2147483647\",\n } as CSSStyleDeclaration);\n\n const frame = document.createElement(\"iframe\");\n // FIX(F-008/§10.1 origin scoping): appends this page's own origin so the\n // hosted flow can scope postMessage's targetOrigin to it instead of \"*\" —\n // see connect-flow.tsx for the producer side of this handshake. The theme\n // param (2026-08-04 embed polish) hands the HOST page's theme to the flow\n // so the sheet matches the page around it.\n frame.src =\n `${base}/connect/${encodeURIComponent(opts.token)}` +\n `?${EMBED_PARAM}=${EMBED_VALUE}&${ORIGIN_PARAM}=${encodeURIComponent(window.location.origin)}` +\n (opts.theme ? `&${THEME_PARAM}=${opts.theme}` : \"\");\n frame.setAttribute(\"title\", \"Connect your domain\");\n // Graphite & Pine card: surface-1 + 1px hairline, card radius 14px,\n // level-3 (modal) graphite shadow. The background pre-paints the hosted\n // flow's canvas IN THE HANDED-OVER THEME, so a slow load never flashes the\n // wrong brightness. Height starts compact and then HUGS THE CONTENT: the\n // flow reports its natural height via `dodomain:height` (onMessage below)\n // and the frame follows — a fixed-height box left a dead slab of empty\n // canvas under short content (2026-08-04 embed polish).\n const dark = opts.theme === \"dark\";\n Object.assign(frame.style, {\n // content-box is load-bearing: host pages routinely reset every element\n // to border-box (Tailwind Preflight et al), which would make the 1px\n // borders eat into the height applyReportedHeight sets — the inner\n // viewport lands 2px short of the reported content and the sheet grows a\n // permanent scrollbar (found live on Uptimely, 2026-08-04).\n boxSizing: \"content-box\",\n width: \"min(560px, 94vw)\",\n height: \"min(480px, 92vh)\",\n border: dark ? \"1px solid #2a352f\" : \"1px solid #e5e9e7\",\n borderRadius: \"14px\",\n boxShadow: \"0 1px 2px rgba(23,32,28,0.05), 0 12px 32px rgba(23,32,28,0.14)\",\n background: dark ? \"#17201c\" : \"#ffffff\",\n transition: \"height 180ms ease\",\n } as CSSStyleDeclaration);\n\n function applyReportedHeight(height: number) {\n if (!Number.isFinite(height) || height <= 0) return;\n const max = Math.floor(window.innerHeight * 0.92);\n const clamped = Math.max(280, Math.min(Math.ceil(height), max));\n frame.style.height = `${clamped}px`;\n }\n\n // FIX(F-010): the only reliable \"did the flow actually come up?\" signal —\n // a cross-origin iframe's 404/500 fires neither `onerror` nor exposes\n // readable content. Cleared by the first `dodomain:ready`/`dodomain:verified`\n // (onMessage below); otherwise fires onError({type:\"load-timeout\"}).\n let loadTimer: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {\n loadTimer = undefined;\n opts.onError?.({ type: \"load-timeout\" });\n }, opts.loadTimeoutMs ?? DEFAULT_LOAD_TIMEOUT_MS);\n\n function clearLoadTimer() {\n if (loadTimer !== undefined) {\n clearTimeout(loadTimer);\n loadTimer = undefined;\n }\n }\n\n // FIX(F-010): best-effort network-level signal (rarely fires for a\n // cross-origin navigation, but free to listen for) — the load-timeout\n // above is the primary detector.\n function onFrameError() {\n clearLoadTimer();\n opts.onError?.({ type: \"load-error\" });\n }\n frame.addEventListener(\"error\", onFrameError);\n\n let closed = false;\n function teardown() {\n if (closed) return;\n closed = true;\n clearLoadTimer();\n window.removeEventListener(\"message\", onMessage);\n frame.removeEventListener(\"error\", onFrameError);\n backdrop.remove();\n }\n function close() {\n teardown();\n opts.onClose?.();\n }\n\n function onMessage(e: MessageEvent) {\n if (e.origin !== origin) return;\n // Cheap runtime guard (no zod, per R5 — see the module-level fix note\n // above): a `MessageEvent.data` narrowing, not a full schema parse.\n const data = e.data as DoDomainMessage | undefined;\n if (!data || typeof data.type !== \"string\") return;\n if (data.type === MESSAGE_TYPES.VERIFIED) {\n clearLoadTimer();\n opts.onVerified?.({ domain: data.domain });\n } else if (data.type === MESSAGE_TYPES.READY) {\n clearLoadTimer();\n } else if (data.type === MESSAGE_TYPES.ERROR) {\n clearLoadTimer();\n opts.onError?.({ type: \"session-error\", code: data.code });\n } else if (data.type === MESSAGE_TYPES.HEIGHT) {\n applyReportedHeight(data.height);\n } else if (data.type === MESSAGE_TYPES.CLOSE) {\n close();\n }\n }\n\n backdrop.addEventListener(\"click\", (e) => {\n if (e.target === backdrop) close();\n });\n window.addEventListener(\"message\", onMessage);\n\n backdrop.appendChild(frame);\n document.body.appendChild(backdrop);\n\n return { close };\n}\n","// The single canonical public origin for the DoDomain hosted app (F-010) —\n// the production home for BOTH the REST API (`/api/v1/*`) and the hosted\n// connect flow (`/connect/:token`). See root README.md's \"Origins\" section\n// for the topology: `api.dodomain.io` / `connect.dodomain.io` are cosmetic\n// subdomain names for this same apps/web deployment, not separate hosts,\n// until ops splits them onto distinct deployments.\n//\n// Zero imports, framework-free — the one literal both the node SDK\n// (packages/node) and the embeddable widget (packages/connect) default to,\n// so a shipped SDK and a shipped widget can never re-diverge on the prod\n// origin the way they did before this fix (node defaulted to the unregistered\n// `api.dodomain.io`; connect defaulted to the unregistered `connect.dodomain.io`\n// — neither actually resolves, so the widget's iframe would 404 with zero\n// error surface). apps/web's own `env.ts` `APP_ORIGIN` stays a REQUIRED,\n// no-default env var by design (F-015, fail-closed) — this constant is a\n// client-facing SDK/widget default only, never an env fallback.\nexport const DODOMAIN_DEFAULT_ORIGIN = \"https://app.dodomain.io\";\n","// The zod-FREE half of the widget <-> hosted-flow postMessage contract\n// (F-010 split — see messages.ts's header for the full history). Zero\n// imports, so nothing here can ever pull zod into a consuming bundle,\n// regardless of tree-shaking. @dodomain/connect (bundle-size-sensitive — it\n// ships into an INTEGRATOR's page, not DoDomain's own) imports ONLY from\n// this file, never from messages.ts.\n//\n// messages.ts re-exports everything below unchanged, so existing\n// `from \"@dodomain/core/messages\"` imports (apps/web's connect-flow.tsx)\n// keep working without any change — messages.ts is still the one place that\n// ALSO exports the zod validator (zDoDomainMessage) for zod-tolerant\n// consumers.\n\n/**\n * postMessage type discriminants for the widget <-> hosted-flow contract.\n *\n * READY/ERROR are the load-detection handshake — a cross-origin iframe's\n * HTTP 404/500 fires neither `onerror` nor exposes readable content, so a\n * handshake postMessage from the flow is the only reliable \"did this\n * actually load?\" signal. The hosted flow (connect-flow.tsx) posts READY on\n * mount; the widget (packages/connect) starts a `loadTimeoutMs` timer on\n * show and clears it on the first READY/VERIFIED, else calls\n * `onError({type:\"load-timeout\"})`. ERROR carries a `code` (the same\n * verify()-failure vocabulary connect-flow.tsx already renders in its own\n * in-page banner) so the widget can call `onError({type:\"session-error\",code})`\n * — additive: an older widget build safely ignores both unknown types.\n */\nexport const MESSAGE_TYPES = {\n VERIFIED: \"dodomain:verified\",\n CLOSE: \"dodomain:close\",\n READY: \"dodomain:ready\",\n ERROR: \"dodomain:error\",\n // Content-height report (2026-08-04 embed polish): the hosted flow posts\n // its natural content height on mount and on every resize so the widget's\n // iframe can hug the content instead of sitting at a fixed height with\n // dead space below the footer. Additive — an older widget build safely\n // ignores the unknown type, and an older flow simply never posts it (the\n // widget keeps its initial height).\n HEIGHT: \"dodomain:height\",\n} as const;\n\n// ── The iframe URL contract ──────────────────────────────────────────────\n// packages/connect builds `${base}/connect/${token}?${EMBED_PARAM}=${EMBED_VALUE}\n// &${ORIGIN_PARAM}=<its own origin>`; the hosted connect page\n// (apps/web/src/app/connect/[token]/connect-flow.tsx) reads both params — ONE\n// set of query-param names instead of \"embed\"/\"origin\" string literals\n// hand-typed on both sides. `ORIGIN_PARAM` carries the embedding integrator's\n// origin so the hosted flow can scope postMessage's targetOrigin to it\n// instead of \"*\" (PLAN-F-008 §2/§10.1 — see connect-flow.tsx for the\n// documented \"*\" fallback when the param is absent).\nexport const EMBED_PARAM = \"embed\";\nexport const EMBED_VALUE = \"1\";\nexport const ORIGIN_PARAM = \"origin\";\n// Host-app theme handoff (2026-08-04 embed polish): the widget passes the\n// integrator page's theme so the embedded sheet matches it — a theme toggle\n// inside someone else's modal is chrome noise, so the hosted flow hides its\n// own toggle in embed mode and adopts this value instead. Only \"light\" and\n// \"dark\" are honored; anything else falls back to the flow's own resolution.\nexport const THEME_PARAM = \"theme\";\n\n// Hand-written (not `z.infer<typeof zDoDomainMessage>`, unlike before the\n// split — that schema now lives in messages.ts, which imports zod, and this\n// file must not). messages.ts's zDoDomainMessage is annotated\n// `z.ZodType<DoDomainMessage>` against THIS type, so if the two shapes ever\n// drift, messages.ts fails to typecheck — compiler-enforced sync, not just a\n// documentation promise.\nexport type DoDomainMessage =\n | { type: typeof MESSAGE_TYPES.VERIFIED; domain?: string }\n | { type: typeof MESSAGE_TYPES.CLOSE }\n | { type: typeof MESSAGE_TYPES.READY }\n | { type: typeof MESSAGE_TYPES.ERROR; code: string }\n | { type: typeof MESSAGE_TYPES.HEIGHT; height: number };\n"],"mappings":"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","names":[]} | ||
| {"version":3,"sources":["../src/index.ts","../../core/src/origin.ts","../../core/src/message-types.ts"],"sourcesContent":["// @dodomain/connect — the embeddable browser widget.\n// Opens the hosted connect page in a modal iframe and relays lifecycle events.\n// Integrator branding (App.name/logoUrl/brandColor, 2026-07-21) reaches the\n// end user THROUGH the hosted flow this iframe renders — the widget draws no\n// flow chrome of its own, so it needs no branding API and no postMessage\n// contract change (message-types stays as-is).\n//\n// import { showDoDomain } from \"@dodomain/connect\";\n// const session = await fetch(\"/my-api/create-session\").then(r => r.json());\n// showDoDomain({\n// token: session.token,\n// onVerified: () => refetch(),\n// onClose: ({ state }) => { if (state !== \"verified\") keepPromptVisible(); },\n// // The embed can't mount (host CSP / network / content blocker) — send\n// // the user to the same flow full-page instead of failing silently.\n// onError: (e) => { if (e.code === \"MOUNT_BLOCKED\") location.assign(e.hostedUrl); },\n// });\n//\n// FIX(F-008, split F-010): imports the message-type constants + a type-only\n// contract from @dodomain/core/message-types — a ZERO-IMPORT module — never\n// zod at runtime (R5: this widget ships into an INTEGRATOR's page bundle, so\n// it stays dependency-free). F-008 originally imported the plain-const half\n// of @dodomain/core/messages (which ALSO imports zod, for zDoDomainMessage),\n// betting a bundler's tree-shaking would drop the unused zod graph. F-010\n// verified that bet against a real tsup build and it did NOT hold (esbuild's\n// default AND Rollup's tree-shaking both left zod's full runtime in dist,\n// confirmed via test/build.smoke.test.ts) — so the plain consts now live in\n// their own zod-free module (messages.ts's header has the full history) and\n// this package imports ONLY from there, guaranteeing zod can never reach\n// this bundle regardless of any bundler's tree-shaking sophistication.\nimport { DODOMAIN_DEFAULT_ORIGIN } from \"@dodomain/core/origin\";\nimport {\n EMBED_PARAM,\n EMBED_VALUE,\n MESSAGE_TYPES,\n ORIGIN_PARAM,\n THEME_PARAM,\n type DoDomainMessage,\n} from \"@dodomain/core/message-types\";\n\nexport interface ShowDoDomainOptions {\n /** Session token from POST /api/v1/sessions (dd_sess_…). */\n token: string;\n /** DoDomain origin. Defaults to https://app.dodomain.io. */\n baseUrl?: string;\n onVerified?: (detail: { domain?: string }) => void;\n /**\n * Fires when the modal is dismissed (backdrop click, the flow's own close\n * affordance, or `handle.close()`).\n *\n * The detail argument (2026-08-17, BioFlow feedback) carries the session's\n * last-known state so closing means something: partners reported that a\n * zero-arg close \"proves nothing either way\", forcing them to re-poll their\n * own backend after every dismissal. Existing zero-arg handlers keep\n * compiling and behaving identically — a `() => void` is assignable to this\n * type, and the argument is simply ignored.\n */\n onClose?: (detail: DoDomainCloseDetail) => void;\n /**\n * FIX(F-010): fires when the hosted flow fails to load or reports a\n * session error — a cross-origin iframe's HTTP 404/500 exposes neither\n * `onerror` nor readable content by default, so before this fix a broken\n * embed just sat there silently. See DoDomainWidgetError's own doc for the\n * three cases.\n */\n onError?: (detail: DoDomainWidgetError) => void;\n /**\n * FIX(F-010): milliseconds to wait for the hosted flow's `dodomain:ready`\n * handshake before treating the embed as failed-to-load. Default 15000.\n */\n loadTimeoutMs?: number;\n /**\n * Host-page theme (2026-08-04 embed polish). Pass the theme YOUR page is\n * currently rendering so the embedded sheet matches it — the hosted flow\n * adopts it and hides its own theme toggle. Omitted ⇒ the flow resolves\n * its own theme (prefers-color-scheme / its visitor preference).\n */\n theme?: \"light\" | \"dark\";\n}\n\n/**\n * The one machine-readable code meaning \"the sheet never came up — nothing\n * the user does inside this modal can succeed\" (2026-08-17, BioFlow\n * feedback: a host-page CSP whose `frame-src` omitted the DoDomain origin\n * failed indistinguishably from every other error, so the integrator had to\n * hang a hosted-URL fallback off a generic `onError`).\n *\n * Deliberately NOT named CSP_BLOCKED: the widget cannot tell a CSP block\n * from a DNS failure, an offline network, or a content blocker eating the\n * frame — all four look identical from the parent page (a cross-origin\n * iframe exposes neither readable content nor a reliable error event). The\n * honest name covers all of them, and the handling is the same for all of\n * them: send the user to `hostedUrl` (see the README's \"Origins & CSP\").\n */\nexport const MOUNT_BLOCKED = \"MOUNT_BLOCKED\";\n\n/**\n * FIX(F-010): the three ways `onError` can fire.\n * - `load-timeout` — no `dodomain:ready`/`dodomain:verified` arrived within\n * `loadTimeoutMs` (covers a 404/DNS failure/hung load — anything that\n * never gets far enough to run the hosted flow's own JS).\n * - `load-error` — the iframe's own `error` event fired, OR the HOST page's\n * own CSP reported blocking this frame (`securitypolicyviolation` on\n * `frame-src`/`child-src`/`default-src` — 2026-08-17). Both are\n * best-effort fast paths for the same fact the `load-timeout` above\n * eventually proves anyway; the CSP one just gets there in milliseconds\n * instead of `loadTimeoutMs`.\n * - `session-error` — the hosted flow mounted and posted `dodomain:error`\n * with a `code` (e.g. an expired/not-found token, or a verify() failure —\n * see connect-flow.tsx).\n *\n * Every variant carries `hostedUrl` (the full-page `/connect/<token>` URL,\n * no embed params) and a `code`. `code === MOUNT_BLOCKED` is the single\n * check a partner needs for \"the embed is impossible here, fall back\":\n * navigate to `hostedUrl`. `type` stays the pre-existing 3-value vocabulary\n * so no consumer's switch changes meaning.\n */\nexport type DoDomainWidgetError =\n | { type: \"load-timeout\"; code: typeof MOUNT_BLOCKED; hostedUrl: string }\n | { type: \"load-error\"; code: typeof MOUNT_BLOCKED; hostedUrl: string }\n | { type: \"session-error\"; code: string; hostedUrl: string };\n\n/**\n * The session's last-known state, derived entirely from the postMessage\n * traffic already flowing from the hosted flow (2026-08-17) — no new message\n * type was needed, and the widget never talks to the API itself.\n * - `unknown` — nothing was ever heard from the flow (it never mounted:\n * CSP/network/blocked, i.e. the `MOUNT_BLOCKED` case).\n * - `pending` — the flow mounted (`dodomain:ready`) but reached no outcome\n * before the user closed it.\n * - `verified` — `dodomain:verified` arrived; the domain is connected.\n * Sticky: a later `dodomain:error` cannot downgrade it.\n * - `failed` — the flow reported `dodomain:error` (e.g. a verify() failure)\n * and never went on to verify.\n */\nexport type DoDomainSessionState = \"verified\" | \"pending\" | \"failed\" | \"unknown\";\n\n/**\n * What `onClose` receives (2026-08-17). Additive: handlers written as\n * `() => …` before this existed keep compiling and behaving identically.\n */\nexport interface DoDomainCloseDetail {\n state: DoDomainSessionState;\n /** The verified domain, when `state === \"verified\"` reported one. */\n domain?: string;\n}\n\nexport interface DoDomainHandle {\n close: () => void;\n}\n\nconst DEFAULT_BASE = DODOMAIN_DEFAULT_ORIGIN;\nconst DEFAULT_LOAD_TIMEOUT_MS = 15_000;\n\nexport function showDoDomain(opts: ShowDoDomainOptions): DoDomainHandle {\n if (typeof document === \"undefined\") {\n throw new Error(\"showDoDomain must run in a browser\");\n }\n const base = (opts.baseUrl ?? DEFAULT_BASE).replace(/\\/$/, \"\");\n const origin = new URL(base).origin;\n // The full-page flow for this same session — what a partner navigates to\n // when the embed can't mount (2026-08-17). Deliberately WITHOUT the embed/\n // origin/theme params the iframe carries: those put the flow in embedded\n // mode (no page chrome, postMessage close), which is wrong for a top-level\n // navigation.\n const hostedUrl = `${base}/connect/${encodeURIComponent(opts.token)}`;\n\n const backdrop = document.createElement(\"div\");\n backdrop.setAttribute(\"data-dodomain\", \"backdrop\");\n // Graphite & Pine (docs/DESIGN.md): graphite-ink scrim (#17201C at 55%) — no\n // backdrop-blur (the system bans glassmorphism chrome) and no blue-grays.\n Object.assign(backdrop.style, {\n position: \"fixed\",\n inset: \"0\",\n background: \"rgba(23,32,28,0.55)\",\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n zIndex: \"2147483647\",\n } as CSSStyleDeclaration);\n\n const frame = document.createElement(\"iframe\");\n // FIX(F-008/§10.1 origin scoping): appends this page's own origin so the\n // hosted flow can scope postMessage's targetOrigin to it instead of \"*\" —\n // see connect-flow.tsx for the producer side of this handshake. The theme\n // param (2026-08-04 embed polish) hands the HOST page's theme to the flow\n // so the sheet matches the page around it.\n frame.src =\n hostedUrl +\n `?${EMBED_PARAM}=${EMBED_VALUE}&${ORIGIN_PARAM}=${encodeURIComponent(window.location.origin)}` +\n (opts.theme ? `&${THEME_PARAM}=${opts.theme}` : \"\");\n frame.setAttribute(\"title\", \"Connect your domain\");\n // Graphite & Pine card: surface-1 + 1px hairline, card radius 14px,\n // level-3 (modal) graphite shadow. The background pre-paints the hosted\n // flow's canvas IN THE HANDED-OVER THEME, so a slow load never flashes the\n // wrong brightness. Height starts compact and then HUGS THE CONTENT: the\n // flow reports its natural height via `dodomain:height` (onMessage below)\n // and the frame follows — a fixed-height box left a dead slab of empty\n // canvas under short content (2026-08-04 embed polish).\n const dark = opts.theme === \"dark\";\n Object.assign(frame.style, {\n // content-box is load-bearing: host pages routinely reset every element\n // to border-box (Tailwind Preflight et al), which would make the 1px\n // borders eat into the height applyReportedHeight sets — the inner\n // viewport lands 2px short of the reported content and the sheet grows a\n // permanent scrollbar (found live on Uptimely, 2026-08-04).\n boxSizing: \"content-box\",\n width: \"min(560px, 94vw)\",\n height: \"min(480px, 92vh)\",\n border: dark ? \"1px solid #2a352f\" : \"1px solid #e5e9e7\",\n borderRadius: \"14px\",\n boxShadow: \"0 1px 2px rgba(23,32,28,0.05), 0 12px 32px rgba(23,32,28,0.14)\",\n background: dark ? \"#17201c\" : \"#ffffff\",\n transition: \"height 180ms ease\",\n } as CSSStyleDeclaration);\n\n function applyReportedHeight(height: number) {\n if (!Number.isFinite(height) || height <= 0) return;\n const max = Math.floor(window.innerHeight * 0.92);\n const clamped = Math.max(280, Math.min(Math.ceil(height), max));\n frame.style.height = `${clamped}px`;\n }\n\n // The session's last-known state, derived from the postMessage traffic\n // this widget already receives (2026-08-17) — see DoDomainSessionState.\n // Starts \"unknown\": a modal that never heard from the flow proves nothing.\n let state: DoDomainSessionState = \"unknown\";\n let verifiedDomain: string | undefined;\n let closed = false;\n\n // FIX(F-010): the only reliable \"did the flow actually come up?\" signal —\n // a cross-origin iframe's 404/500 fires neither `onerror` nor exposes\n // readable content. Cleared by the first `dodomain:ready`/`dodomain:verified`\n // (onMessage below); otherwise fires onError({type:\"load-timeout\"}).\n let loadTimer: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {\n loadTimer = undefined;\n reportMountFailure(\"load-timeout\");\n }, opts.loadTimeoutMs ?? DEFAULT_LOAD_TIMEOUT_MS);\n\n function clearLoadTimer() {\n if (loadTimer !== undefined) {\n clearTimeout(loadTimer);\n loadTimer = undefined;\n }\n }\n\n // \"The frame never mounted\" is ONE fact with several possible detectors\n // (2026-08-17), so it reports at most once no matter how many of them\n // fire: the CSP violation event and the iframe error event can both land\n // for the same block, and either would otherwise be followed by the\n // load-timeout as well.\n let mountFailureReported = false;\n function reportMountFailure(type: \"load-timeout\" | \"load-error\") {\n if (mountFailureReported || closed) return;\n mountFailureReported = true;\n clearLoadTimer();\n opts.onError?.({ type, code: MOUNT_BLOCKED, hostedUrl });\n }\n\n // FIX(F-010): best-effort network-level signal (rarely fires for a\n // cross-origin navigation, but free to listen for) — the load-timeout\n // above is the primary detector.\n function onFrameError() {\n reportMountFailure(\"load-error\");\n }\n frame.addEventListener(\"error\", onFrameError);\n\n // 2026-08-17 (BioFlow): the host page's OWN CSP is the one mount failure\n // the browser will actually tell us about — when it refuses to load this\n // frame it fires `securitypolicyviolation` on the embedding document. That\n // turns a 15-second silent wait into an immediate, correctly-coded\n // MOUNT_BLOCKED. It is a fast path, never the only one: browsers without\n // the event (or a block that isn't CSP at all — DNS, offline, a content\n // blocker) still land on the load-timeout above.\n //\n // The event is typed loosely on purpose: `SecurityPolicyViolationEvent`\n // isn't guaranteed to exist at runtime, and the two fields read here are\n // the only ones this needs.\n function onCspViolation(e: Event) {\n const violation = e as { blockedURI?: unknown; violatedDirective?: unknown };\n const directive =\n typeof violation.violatedDirective === \"string\" ? violation.violatedDirective : \"\";\n const blockedUri = typeof violation.blockedURI === \"string\" ? violation.blockedURI : \"\";\n // CSP falls back frame-src → child-src → default-src, and the report\n // names whichever directive was actually enforced, so all three mean\n // \"this page's policy refused our frame\". (Browsers report either the\n // bare directive name or `<name> <source-list>`, hence startsWith.)\n const framesBlocked =\n directive.startsWith(\"frame-src\") ||\n directive.startsWith(\"child-src\") ||\n directive.startsWith(\"default-src\");\n // blockedURI is the frame URL, or just its origin when the browser\n // strips it cross-origin — both start with our origin.\n if (!framesBlocked || !blockedUri.startsWith(origin)) return;\n reportMountFailure(\"load-error\");\n }\n document.addEventListener(\"securitypolicyviolation\", onCspViolation);\n\n function teardown() {\n if (closed) return;\n closed = true;\n clearLoadTimer();\n window.removeEventListener(\"message\", onMessage);\n frame.removeEventListener(\"error\", onFrameError);\n document.removeEventListener(\"securitypolicyviolation\", onCspViolation);\n backdrop.remove();\n }\n function close() {\n teardown();\n // `domain` is omitted rather than passed as undefined so the detail\n // object reads the way a consumer would write it.\n opts.onClose?.(verifiedDomain === undefined ? { state } : { state, domain: verifiedDomain });\n }\n\n function onMessage(e: MessageEvent) {\n if (e.origin !== origin) return;\n // Cheap runtime guard (no zod, per R5 — see the module-level fix note\n // above): a `MessageEvent.data` narrowing, not a full schema parse.\n const data = e.data as DoDomainMessage | undefined;\n if (!data || typeof data.type !== \"string\") return;\n if (data.type === MESSAGE_TYPES.VERIFIED) {\n clearLoadTimer();\n // Terminal and sticky: a later dodomain:error (e.g. a re-check the\n // user triggered after the fact) cannot un-verify a connected domain.\n state = \"verified\";\n verifiedDomain = data.domain;\n opts.onVerified?.({ domain: data.domain });\n } else if (data.type === MESSAGE_TYPES.READY) {\n clearLoadTimer();\n // The flow is up but has reached no outcome — anything stronger than\n // \"pending\" would be a claim we haven't heard.\n if (state === \"unknown\") state = \"pending\";\n } else if (data.type === MESSAGE_TYPES.ERROR) {\n clearLoadTimer();\n // connect-flow.tsx posts this for a verify() failure, which the user\n // can still retry inside the same session — so it is NOT terminal, and\n // a subsequent dodomain:verified promotes the state above.\n if (state !== \"verified\") state = \"failed\";\n opts.onError?.({ type: \"session-error\", code: data.code, hostedUrl });\n } else if (data.type === MESSAGE_TYPES.HEIGHT) {\n applyReportedHeight(data.height);\n } else if (data.type === MESSAGE_TYPES.CLOSE) {\n close();\n }\n }\n\n backdrop.addEventListener(\"click\", (e) => {\n if (e.target === backdrop) close();\n });\n window.addEventListener(\"message\", onMessage);\n\n backdrop.appendChild(frame);\n document.body.appendChild(backdrop);\n\n return { close };\n}\n","// The single canonical public origin for the DoDomain hosted app (F-010) —\n// the production home for BOTH the REST API (`/api/v1/*`) and the hosted\n// connect flow (`/connect/:token`). See root README.md's \"Origins\" section\n// for the topology: `api.dodomain.io` / `connect.dodomain.io` are cosmetic\n// subdomain names for this same apps/web deployment, not separate hosts,\n// until ops splits them onto distinct deployments.\n//\n// Zero imports, framework-free — the one literal both the node SDK\n// (packages/node) and the embeddable widget (packages/connect) default to,\n// so a shipped SDK and a shipped widget can never re-diverge on the prod\n// origin the way they did before this fix (node defaulted to the unregistered\n// `api.dodomain.io`; connect defaulted to the unregistered `connect.dodomain.io`\n// — neither actually resolves, so the widget's iframe would 404 with zero\n// error surface). apps/web's own `env.ts` `APP_ORIGIN` stays a REQUIRED,\n// no-default env var by design (F-015, fail-closed) — this constant is a\n// client-facing SDK/widget default only, never an env fallback.\nexport const DODOMAIN_DEFAULT_ORIGIN = \"https://app.dodomain.io\";\n","// The zod-FREE half of the widget <-> hosted-flow postMessage contract\n// (F-010 split — see messages.ts's header for the full history). Zero\n// imports, so nothing here can ever pull zod into a consuming bundle,\n// regardless of tree-shaking. @dodomain/connect (bundle-size-sensitive — it\n// ships into an INTEGRATOR's page, not DoDomain's own) imports ONLY from\n// this file, never from messages.ts.\n//\n// messages.ts re-exports everything below unchanged, so existing\n// `from \"@dodomain/core/messages\"` imports (apps/web's connect-flow.tsx)\n// keep working without any change — messages.ts is still the one place that\n// ALSO exports the zod validator (zDoDomainMessage) for zod-tolerant\n// consumers.\n\n/**\n * postMessage type discriminants for the widget <-> hosted-flow contract.\n *\n * READY/ERROR are the load-detection handshake — a cross-origin iframe's\n * HTTP 404/500 fires neither `onerror` nor exposes readable content, so a\n * handshake postMessage from the flow is the only reliable \"did this\n * actually load?\" signal. The hosted flow (connect-flow.tsx) posts READY on\n * mount; the widget (packages/connect) starts a `loadTimeoutMs` timer on\n * show and clears it on the first READY/VERIFIED, else calls\n * `onError({type:\"load-timeout\"})`. ERROR carries a `code` (the same\n * verify()-failure vocabulary connect-flow.tsx already renders in its own\n * in-page banner) so the widget can call `onError({type:\"session-error\",code})`\n * — additive: an older widget build safely ignores both unknown types.\n */\nexport const MESSAGE_TYPES = {\n VERIFIED: \"dodomain:verified\",\n CLOSE: \"dodomain:close\",\n READY: \"dodomain:ready\",\n ERROR: \"dodomain:error\",\n // Content-height report (2026-08-04 embed polish): the hosted flow posts\n // its natural content height on mount and on every resize so the widget's\n // iframe can hug the content instead of sitting at a fixed height with\n // dead space below the footer. Additive — an older widget build safely\n // ignores the unknown type, and an older flow simply never posts it (the\n // widget keeps its initial height).\n HEIGHT: \"dodomain:height\",\n} as const;\n\n// ── The iframe URL contract ──────────────────────────────────────────────\n// packages/connect builds `${base}/connect/${token}?${EMBED_PARAM}=${EMBED_VALUE}\n// &${ORIGIN_PARAM}=<its own origin>`; the hosted connect page\n// (apps/web/src/app/connect/[token]/connect-flow.tsx) reads both params — ONE\n// set of query-param names instead of \"embed\"/\"origin\" string literals\n// hand-typed on both sides. `ORIGIN_PARAM` carries the embedding integrator's\n// origin so the hosted flow can scope postMessage's targetOrigin to it\n// instead of \"*\" (PLAN-F-008 §2/§10.1 — see connect-flow.tsx for the\n// documented \"*\" fallback when the param is absent).\nexport const EMBED_PARAM = \"embed\";\nexport const EMBED_VALUE = \"1\";\nexport const ORIGIN_PARAM = \"origin\";\n// Host-app theme handoff (2026-08-04 embed polish): the widget passes the\n// integrator page's theme so the embedded sheet matches it — a theme toggle\n// inside someone else's modal is chrome noise, so the hosted flow hides its\n// own toggle in embed mode and adopts this value instead. Only \"light\" and\n// \"dark\" are honored; anything else falls back to the flow's own resolution.\nexport const THEME_PARAM = \"theme\";\n\n// Hand-written (not `z.infer<typeof zDoDomainMessage>`, unlike before the\n// split — that schema now lives in messages.ts, which imports zod, and this\n// file must not). messages.ts's zDoDomainMessage is annotated\n// `z.ZodType<DoDomainMessage>` against THIS type, so if the two shapes ever\n// drift, messages.ts fails to typecheck — compiler-enforced sync, not just a\n// documentation promise.\nexport type DoDomainMessage =\n | { type: typeof MESSAGE_TYPES.VERIFIED; domain?: string }\n | { type: typeof MESSAGE_TYPES.CLOSE }\n | { type: typeof MESSAGE_TYPES.READY }\n | { type: typeof MESSAGE_TYPES.ERROR; code: string }\n | { type: typeof MESSAGE_TYPES.HEIGHT; height: number };\n"],"mappings":"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","names":[]} |
+68
-5
@@ -9,4 +9,15 @@ interface ShowDoDomainOptions { | ||
| }) => void; | ||
| onClose?: () => void; | ||
| /** | ||
| * Fires when the modal is dismissed (backdrop click, the flow's own close | ||
| * affordance, or `handle.close()`). | ||
| * | ||
| * The detail argument (2026-08-17, BioFlow feedback) carries the session's | ||
| * last-known state so closing means something: partners reported that a | ||
| * zero-arg close "proves nothing either way", forcing them to re-poll their | ||
| * own backend after every dismissal. Existing zero-arg handlers keep | ||
| * compiling and behaving identically — a `() => void` is assignable to this | ||
| * type, and the argument is simply ignored. | ||
| */ | ||
| onClose?: (detail: DoDomainCloseDetail) => void; | ||
| /** | ||
| * FIX(F-010): fires when the hosted flow fails to load or reports a | ||
@@ -33,2 +44,17 @@ * session error — a cross-origin iframe's HTTP 404/500 exposes neither | ||
| /** | ||
| * The one machine-readable code meaning "the sheet never came up — nothing | ||
| * the user does inside this modal can succeed" (2026-08-17, BioFlow | ||
| * feedback: a host-page CSP whose `frame-src` omitted the DoDomain origin | ||
| * failed indistinguishably from every other error, so the integrator had to | ||
| * hang a hosted-URL fallback off a generic `onError`). | ||
| * | ||
| * Deliberately NOT named CSP_BLOCKED: the widget cannot tell a CSP block | ||
| * from a DNS failure, an offline network, or a content blocker eating the | ||
| * frame — all four look identical from the parent page (a cross-origin | ||
| * iframe exposes neither readable content nor a reliable error event). The | ||
| * honest name covers all of them, and the handling is the same for all of | ||
| * them: send the user to `hostedUrl` (see the README's "Origins & CSP"). | ||
| */ | ||
| declare const MOUNT_BLOCKED = "MOUNT_BLOCKED"; | ||
| /** | ||
| * FIX(F-010): the three ways `onError` can fire. | ||
@@ -38,17 +64,54 @@ * - `load-timeout` — no `dodomain:ready`/`dodomain:verified` arrived within | ||
| * never gets far enough to run the hosted flow's own JS). | ||
| * - `load-error` — the iframe's own `error` event fired (best-effort; | ||
| * browsers rarely fire this for a cross-origin navigation, but it's free | ||
| * to listen for). | ||
| * - `load-error` — the iframe's own `error` event fired, OR the HOST page's | ||
| * own CSP reported blocking this frame (`securitypolicyviolation` on | ||
| * `frame-src`/`child-src`/`default-src` — 2026-08-17). Both are | ||
| * best-effort fast paths for the same fact the `load-timeout` above | ||
| * eventually proves anyway; the CSP one just gets there in milliseconds | ||
| * instead of `loadTimeoutMs`. | ||
| * - `session-error` — the hosted flow mounted and posted `dodomain:error` | ||
| * with a `code` (e.g. an expired/not-found token, or a verify() failure — | ||
| * see connect-flow.tsx). | ||
| * | ||
| * Every variant carries `hostedUrl` (the full-page `/connect/<token>` URL, | ||
| * no embed params) and a `code`. `code === MOUNT_BLOCKED` is the single | ||
| * check a partner needs for "the embed is impossible here, fall back": | ||
| * navigate to `hostedUrl`. `type` stays the pre-existing 3-value vocabulary | ||
| * so no consumer's switch changes meaning. | ||
| */ | ||
| type DoDomainWidgetError = { | ||
| type: "load-timeout"; | ||
| code: typeof MOUNT_BLOCKED; | ||
| hostedUrl: string; | ||
| } | { | ||
| type: "load-error"; | ||
| code: typeof MOUNT_BLOCKED; | ||
| hostedUrl: string; | ||
| } | { | ||
| type: "session-error"; | ||
| code: string; | ||
| hostedUrl: string; | ||
| }; | ||
| /** | ||
| * The session's last-known state, derived entirely from the postMessage | ||
| * traffic already flowing from the hosted flow (2026-08-17) — no new message | ||
| * type was needed, and the widget never talks to the API itself. | ||
| * - `unknown` — nothing was ever heard from the flow (it never mounted: | ||
| * CSP/network/blocked, i.e. the `MOUNT_BLOCKED` case). | ||
| * - `pending` — the flow mounted (`dodomain:ready`) but reached no outcome | ||
| * before the user closed it. | ||
| * - `verified` — `dodomain:verified` arrived; the domain is connected. | ||
| * Sticky: a later `dodomain:error` cannot downgrade it. | ||
| * - `failed` — the flow reported `dodomain:error` (e.g. a verify() failure) | ||
| * and never went on to verify. | ||
| */ | ||
| type DoDomainSessionState = "verified" | "pending" | "failed" | "unknown"; | ||
| /** | ||
| * What `onClose` receives (2026-08-17). Additive: handlers written as | ||
| * `() => …` before this existed keep compiling and behaving identically. | ||
| */ | ||
| interface DoDomainCloseDetail { | ||
| state: DoDomainSessionState; | ||
| /** The verified domain, when `state === "verified"` reported one. */ | ||
| domain?: string; | ||
| } | ||
| interface DoDomainHandle { | ||
@@ -59,2 +122,2 @@ close: () => void; | ||
| export { type DoDomainHandle, type DoDomainWidgetError, type ShowDoDomainOptions, showDoDomain }; | ||
| export { type DoDomainCloseDetail, type DoDomainHandle, type DoDomainSessionState, type DoDomainWidgetError, MOUNT_BLOCKED, type ShowDoDomainOptions, showDoDomain }; |
+68
-5
@@ -9,4 +9,15 @@ interface ShowDoDomainOptions { | ||
| }) => void; | ||
| onClose?: () => void; | ||
| /** | ||
| * Fires when the modal is dismissed (backdrop click, the flow's own close | ||
| * affordance, or `handle.close()`). | ||
| * | ||
| * The detail argument (2026-08-17, BioFlow feedback) carries the session's | ||
| * last-known state so closing means something: partners reported that a | ||
| * zero-arg close "proves nothing either way", forcing them to re-poll their | ||
| * own backend after every dismissal. Existing zero-arg handlers keep | ||
| * compiling and behaving identically — a `() => void` is assignable to this | ||
| * type, and the argument is simply ignored. | ||
| */ | ||
| onClose?: (detail: DoDomainCloseDetail) => void; | ||
| /** | ||
| * FIX(F-010): fires when the hosted flow fails to load or reports a | ||
@@ -33,2 +44,17 @@ * session error — a cross-origin iframe's HTTP 404/500 exposes neither | ||
| /** | ||
| * The one machine-readable code meaning "the sheet never came up — nothing | ||
| * the user does inside this modal can succeed" (2026-08-17, BioFlow | ||
| * feedback: a host-page CSP whose `frame-src` omitted the DoDomain origin | ||
| * failed indistinguishably from every other error, so the integrator had to | ||
| * hang a hosted-URL fallback off a generic `onError`). | ||
| * | ||
| * Deliberately NOT named CSP_BLOCKED: the widget cannot tell a CSP block | ||
| * from a DNS failure, an offline network, or a content blocker eating the | ||
| * frame — all four look identical from the parent page (a cross-origin | ||
| * iframe exposes neither readable content nor a reliable error event). The | ||
| * honest name covers all of them, and the handling is the same for all of | ||
| * them: send the user to `hostedUrl` (see the README's "Origins & CSP"). | ||
| */ | ||
| declare const MOUNT_BLOCKED = "MOUNT_BLOCKED"; | ||
| /** | ||
| * FIX(F-010): the three ways `onError` can fire. | ||
@@ -38,17 +64,54 @@ * - `load-timeout` — no `dodomain:ready`/`dodomain:verified` arrived within | ||
| * never gets far enough to run the hosted flow's own JS). | ||
| * - `load-error` — the iframe's own `error` event fired (best-effort; | ||
| * browsers rarely fire this for a cross-origin navigation, but it's free | ||
| * to listen for). | ||
| * - `load-error` — the iframe's own `error` event fired, OR the HOST page's | ||
| * own CSP reported blocking this frame (`securitypolicyviolation` on | ||
| * `frame-src`/`child-src`/`default-src` — 2026-08-17). Both are | ||
| * best-effort fast paths for the same fact the `load-timeout` above | ||
| * eventually proves anyway; the CSP one just gets there in milliseconds | ||
| * instead of `loadTimeoutMs`. | ||
| * - `session-error` — the hosted flow mounted and posted `dodomain:error` | ||
| * with a `code` (e.g. an expired/not-found token, or a verify() failure — | ||
| * see connect-flow.tsx). | ||
| * | ||
| * Every variant carries `hostedUrl` (the full-page `/connect/<token>` URL, | ||
| * no embed params) and a `code`. `code === MOUNT_BLOCKED` is the single | ||
| * check a partner needs for "the embed is impossible here, fall back": | ||
| * navigate to `hostedUrl`. `type` stays the pre-existing 3-value vocabulary | ||
| * so no consumer's switch changes meaning. | ||
| */ | ||
| type DoDomainWidgetError = { | ||
| type: "load-timeout"; | ||
| code: typeof MOUNT_BLOCKED; | ||
| hostedUrl: string; | ||
| } | { | ||
| type: "load-error"; | ||
| code: typeof MOUNT_BLOCKED; | ||
| hostedUrl: string; | ||
| } | { | ||
| type: "session-error"; | ||
| code: string; | ||
| hostedUrl: string; | ||
| }; | ||
| /** | ||
| * The session's last-known state, derived entirely from the postMessage | ||
| * traffic already flowing from the hosted flow (2026-08-17) — no new message | ||
| * type was needed, and the widget never talks to the API itself. | ||
| * - `unknown` — nothing was ever heard from the flow (it never mounted: | ||
| * CSP/network/blocked, i.e. the `MOUNT_BLOCKED` case). | ||
| * - `pending` — the flow mounted (`dodomain:ready`) but reached no outcome | ||
| * before the user closed it. | ||
| * - `verified` — `dodomain:verified` arrived; the domain is connected. | ||
| * Sticky: a later `dodomain:error` cannot downgrade it. | ||
| * - `failed` — the flow reported `dodomain:error` (e.g. a verify() failure) | ||
| * and never went on to verify. | ||
| */ | ||
| type DoDomainSessionState = "verified" | "pending" | "failed" | "unknown"; | ||
| /** | ||
| * What `onClose` receives (2026-08-17). Additive: handlers written as | ||
| * `() => …` before this existed keep compiling and behaving identically. | ||
| */ | ||
| interface DoDomainCloseDetail { | ||
| state: DoDomainSessionState; | ||
| /** The verified domain, when `state === "verified"` reported one. */ | ||
| domain?: string; | ||
| } | ||
| interface DoDomainHandle { | ||
@@ -59,2 +122,2 @@ close: () => void; | ||
| export { type DoDomainHandle, type DoDomainWidgetError, type ShowDoDomainOptions, showDoDomain }; | ||
| export { type DoDomainCloseDetail, type DoDomainHandle, type DoDomainSessionState, type DoDomainWidgetError, MOUNT_BLOCKED, type ShowDoDomainOptions, showDoDomain }; |
+32
-7
@@ -24,2 +24,3 @@ // ../core/src/origin.ts | ||
| // src/index.ts | ||
| var MOUNT_BLOCKED = "MOUNT_BLOCKED"; | ||
| var DEFAULT_BASE = DODOMAIN_DEFAULT_ORIGIN; | ||
@@ -33,2 +34,3 @@ var DEFAULT_LOAD_TIMEOUT_MS = 15e3; | ||
| const origin = new URL(base).origin; | ||
| const hostedUrl = `${base}/connect/${encodeURIComponent(opts.token)}`; | ||
| const backdrop = document.createElement("div"); | ||
@@ -46,3 +48,3 @@ backdrop.setAttribute("data-dodomain", "backdrop"); | ||
| const frame = document.createElement("iframe"); | ||
| frame.src = `${base}/connect/${encodeURIComponent(opts.token)}?${EMBED_PARAM}=${EMBED_VALUE}&${ORIGIN_PARAM}=${encodeURIComponent(window.location.origin)}` + (opts.theme ? `&${THEME_PARAM}=${opts.theme}` : ""); | ||
| frame.src = hostedUrl + `?${EMBED_PARAM}=${EMBED_VALUE}&${ORIGIN_PARAM}=${encodeURIComponent(window.location.origin)}` + (opts.theme ? `&${THEME_PARAM}=${opts.theme}` : ""); | ||
| frame.setAttribute("title", "Connect your domain"); | ||
@@ -71,5 +73,8 @@ const dark = opts.theme === "dark"; | ||
| } | ||
| let state = "unknown"; | ||
| let verifiedDomain; | ||
| let closed = false; | ||
| let loadTimer = setTimeout(() => { | ||
| loadTimer = void 0; | ||
| opts.onError?.({ type: "load-timeout" }); | ||
| reportMountFailure("load-timeout"); | ||
| }, opts.loadTimeoutMs ?? DEFAULT_LOAD_TIMEOUT_MS); | ||
@@ -82,8 +87,22 @@ function clearLoadTimer() { | ||
| } | ||
| function onFrameError() { | ||
| let mountFailureReported = false; | ||
| function reportMountFailure(type) { | ||
| if (mountFailureReported || closed) return; | ||
| mountFailureReported = true; | ||
| clearLoadTimer(); | ||
| opts.onError?.({ type: "load-error" }); | ||
| opts.onError?.({ type, code: MOUNT_BLOCKED, hostedUrl }); | ||
| } | ||
| function onFrameError() { | ||
| reportMountFailure("load-error"); | ||
| } | ||
| frame.addEventListener("error", onFrameError); | ||
| let closed = false; | ||
| function onCspViolation(e) { | ||
| const violation = e; | ||
| const directive = typeof violation.violatedDirective === "string" ? violation.violatedDirective : ""; | ||
| const blockedUri = typeof violation.blockedURI === "string" ? violation.blockedURI : ""; | ||
| const framesBlocked = directive.startsWith("frame-src") || directive.startsWith("child-src") || directive.startsWith("default-src"); | ||
| if (!framesBlocked || !blockedUri.startsWith(origin)) return; | ||
| reportMountFailure("load-error"); | ||
| } | ||
| document.addEventListener("securitypolicyviolation", onCspViolation); | ||
| function teardown() { | ||
@@ -95,2 +114,3 @@ if (closed) return; | ||
| frame.removeEventListener("error", onFrameError); | ||
| document.removeEventListener("securitypolicyviolation", onCspViolation); | ||
| backdrop.remove(); | ||
@@ -100,3 +120,3 @@ } | ||
| teardown(); | ||
| opts.onClose?.(); | ||
| opts.onClose?.(verifiedDomain === void 0 ? { state } : { state, domain: verifiedDomain }); | ||
| } | ||
@@ -109,8 +129,12 @@ function onMessage(e) { | ||
| clearLoadTimer(); | ||
| state = "verified"; | ||
| verifiedDomain = data.domain; | ||
| opts.onVerified?.({ domain: data.domain }); | ||
| } else if (data.type === MESSAGE_TYPES.READY) { | ||
| clearLoadTimer(); | ||
| if (state === "unknown") state = "pending"; | ||
| } else if (data.type === MESSAGE_TYPES.ERROR) { | ||
| clearLoadTimer(); | ||
| opts.onError?.({ type: "session-error", code: data.code }); | ||
| if (state !== "verified") state = "failed"; | ||
| opts.onError?.({ type: "session-error", code: data.code, hostedUrl }); | ||
| } else if (data.type === MESSAGE_TYPES.HEIGHT) { | ||
@@ -131,4 +155,5 @@ applyReportedHeight(data.height); | ||
| export { | ||
| MOUNT_BLOCKED, | ||
| showDoDomain | ||
| }; | ||
| //# sourceMappingURL=index.js.map |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"sources":["../../core/src/origin.ts","../../core/src/message-types.ts","../src/index.ts"],"sourcesContent":["// The single canonical public origin for the DoDomain hosted app (F-010) —\n// the production home for BOTH the REST API (`/api/v1/*`) and the hosted\n// connect flow (`/connect/:token`). See root README.md's \"Origins\" section\n// for the topology: `api.dodomain.io` / `connect.dodomain.io` are cosmetic\n// subdomain names for this same apps/web deployment, not separate hosts,\n// until ops splits them onto distinct deployments.\n//\n// Zero imports, framework-free — the one literal both the node SDK\n// (packages/node) and the embeddable widget (packages/connect) default to,\n// so a shipped SDK and a shipped widget can never re-diverge on the prod\n// origin the way they did before this fix (node defaulted to the unregistered\n// `api.dodomain.io`; connect defaulted to the unregistered `connect.dodomain.io`\n// — neither actually resolves, so the widget's iframe would 404 with zero\n// error surface). apps/web's own `env.ts` `APP_ORIGIN` stays a REQUIRED,\n// no-default env var by design (F-015, fail-closed) — this constant is a\n// client-facing SDK/widget default only, never an env fallback.\nexport const DODOMAIN_DEFAULT_ORIGIN = \"https://app.dodomain.io\";\n","// The zod-FREE half of the widget <-> hosted-flow postMessage contract\n// (F-010 split — see messages.ts's header for the full history). Zero\n// imports, so nothing here can ever pull zod into a consuming bundle,\n// regardless of tree-shaking. @dodomain/connect (bundle-size-sensitive — it\n// ships into an INTEGRATOR's page, not DoDomain's own) imports ONLY from\n// this file, never from messages.ts.\n//\n// messages.ts re-exports everything below unchanged, so existing\n// `from \"@dodomain/core/messages\"` imports (apps/web's connect-flow.tsx)\n// keep working without any change — messages.ts is still the one place that\n// ALSO exports the zod validator (zDoDomainMessage) for zod-tolerant\n// consumers.\n\n/**\n * postMessage type discriminants for the widget <-> hosted-flow contract.\n *\n * READY/ERROR are the load-detection handshake — a cross-origin iframe's\n * HTTP 404/500 fires neither `onerror` nor exposes readable content, so a\n * handshake postMessage from the flow is the only reliable \"did this\n * actually load?\" signal. The hosted flow (connect-flow.tsx) posts READY on\n * mount; the widget (packages/connect) starts a `loadTimeoutMs` timer on\n * show and clears it on the first READY/VERIFIED, else calls\n * `onError({type:\"load-timeout\"})`. ERROR carries a `code` (the same\n * verify()-failure vocabulary connect-flow.tsx already renders in its own\n * in-page banner) so the widget can call `onError({type:\"session-error\",code})`\n * — additive: an older widget build safely ignores both unknown types.\n */\nexport const MESSAGE_TYPES = {\n VERIFIED: \"dodomain:verified\",\n CLOSE: \"dodomain:close\",\n READY: \"dodomain:ready\",\n ERROR: \"dodomain:error\",\n // Content-height report (2026-08-04 embed polish): the hosted flow posts\n // its natural content height on mount and on every resize so the widget's\n // iframe can hug the content instead of sitting at a fixed height with\n // dead space below the footer. Additive — an older widget build safely\n // ignores the unknown type, and an older flow simply never posts it (the\n // widget keeps its initial height).\n HEIGHT: \"dodomain:height\",\n} as const;\n\n// ── The iframe URL contract ──────────────────────────────────────────────\n// packages/connect builds `${base}/connect/${token}?${EMBED_PARAM}=${EMBED_VALUE}\n// &${ORIGIN_PARAM}=<its own origin>`; the hosted connect page\n// (apps/web/src/app/connect/[token]/connect-flow.tsx) reads both params — ONE\n// set of query-param names instead of \"embed\"/\"origin\" string literals\n// hand-typed on both sides. `ORIGIN_PARAM` carries the embedding integrator's\n// origin so the hosted flow can scope postMessage's targetOrigin to it\n// instead of \"*\" (PLAN-F-008 §2/§10.1 — see connect-flow.tsx for the\n// documented \"*\" fallback when the param is absent).\nexport const EMBED_PARAM = \"embed\";\nexport const EMBED_VALUE = \"1\";\nexport const ORIGIN_PARAM = \"origin\";\n// Host-app theme handoff (2026-08-04 embed polish): the widget passes the\n// integrator page's theme so the embedded sheet matches it — a theme toggle\n// inside someone else's modal is chrome noise, so the hosted flow hides its\n// own toggle in embed mode and adopts this value instead. Only \"light\" and\n// \"dark\" are honored; anything else falls back to the flow's own resolution.\nexport const THEME_PARAM = \"theme\";\n\n// Hand-written (not `z.infer<typeof zDoDomainMessage>`, unlike before the\n// split — that schema now lives in messages.ts, which imports zod, and this\n// file must not). messages.ts's zDoDomainMessage is annotated\n// `z.ZodType<DoDomainMessage>` against THIS type, so if the two shapes ever\n// drift, messages.ts fails to typecheck — compiler-enforced sync, not just a\n// documentation promise.\nexport type DoDomainMessage =\n | { type: typeof MESSAGE_TYPES.VERIFIED; domain?: string }\n | { type: typeof MESSAGE_TYPES.CLOSE }\n | { type: typeof MESSAGE_TYPES.READY }\n | { type: typeof MESSAGE_TYPES.ERROR; code: string }\n | { type: typeof MESSAGE_TYPES.HEIGHT; height: number };\n","// @dodomain/connect — the embeddable browser widget.\n// Opens the hosted connect page in a modal iframe and relays lifecycle events.\n// Integrator branding (App.name/logoUrl/brandColor, 2026-07-21) reaches the\n// end user THROUGH the hosted flow this iframe renders — the widget draws no\n// flow chrome of its own, so it needs no branding API and no postMessage\n// contract change (message-types stays as-is).\n//\n// import { showDoDomain } from \"@dodomain/connect\";\n// const session = await fetch(\"/my-api/create-session\").then(r => r.json());\n// showDoDomain({ token: session.token, onVerified: () => refetch() });\n//\n// FIX(F-008, split F-010): imports the message-type constants + a type-only\n// contract from @dodomain/core/message-types — a ZERO-IMPORT module — never\n// zod at runtime (R5: this widget ships into an INTEGRATOR's page bundle, so\n// it stays dependency-free). F-008 originally imported the plain-const half\n// of @dodomain/core/messages (which ALSO imports zod, for zDoDomainMessage),\n// betting a bundler's tree-shaking would drop the unused zod graph. F-010\n// verified that bet against a real tsup build and it did NOT hold (esbuild's\n// default AND Rollup's tree-shaking both left zod's full runtime in dist,\n// confirmed via test/build.smoke.test.ts) — so the plain consts now live in\n// their own zod-free module (messages.ts's header has the full history) and\n// this package imports ONLY from there, guaranteeing zod can never reach\n// this bundle regardless of any bundler's tree-shaking sophistication.\nimport { DODOMAIN_DEFAULT_ORIGIN } from \"@dodomain/core/origin\";\nimport {\n EMBED_PARAM,\n EMBED_VALUE,\n MESSAGE_TYPES,\n ORIGIN_PARAM,\n THEME_PARAM,\n type DoDomainMessage,\n} from \"@dodomain/core/message-types\";\n\nexport interface ShowDoDomainOptions {\n /** Session token from POST /api/v1/sessions (dd_sess_…). */\n token: string;\n /** DoDomain origin. Defaults to https://app.dodomain.io. */\n baseUrl?: string;\n onVerified?: (detail: { domain?: string }) => void;\n onClose?: () => void;\n /**\n * FIX(F-010): fires when the hosted flow fails to load or reports a\n * session error — a cross-origin iframe's HTTP 404/500 exposes neither\n * `onerror` nor readable content by default, so before this fix a broken\n * embed just sat there silently. See DoDomainWidgetError's own doc for the\n * three cases.\n */\n onError?: (detail: DoDomainWidgetError) => void;\n /**\n * FIX(F-010): milliseconds to wait for the hosted flow's `dodomain:ready`\n * handshake before treating the embed as failed-to-load. Default 15000.\n */\n loadTimeoutMs?: number;\n /**\n * Host-page theme (2026-08-04 embed polish). Pass the theme YOUR page is\n * currently rendering so the embedded sheet matches it — the hosted flow\n * adopts it and hides its own theme toggle. Omitted ⇒ the flow resolves\n * its own theme (prefers-color-scheme / its visitor preference).\n */\n theme?: \"light\" | \"dark\";\n}\n\n/**\n * FIX(F-010): the three ways `onError` can fire.\n * - `load-timeout` — no `dodomain:ready`/`dodomain:verified` arrived within\n * `loadTimeoutMs` (covers a 404/DNS failure/hung load — anything that\n * never gets far enough to run the hosted flow's own JS).\n * - `load-error` — the iframe's own `error` event fired (best-effort;\n * browsers rarely fire this for a cross-origin navigation, but it's free\n * to listen for).\n * - `session-error` — the hosted flow mounted and posted `dodomain:error`\n * with a `code` (e.g. an expired/not-found token, or a verify() failure —\n * see connect-flow.tsx).\n */\nexport type DoDomainWidgetError =\n { type: \"load-timeout\" } | { type: \"load-error\" } | { type: \"session-error\"; code: string };\n\nexport interface DoDomainHandle {\n close: () => void;\n}\n\nconst DEFAULT_BASE = DODOMAIN_DEFAULT_ORIGIN;\nconst DEFAULT_LOAD_TIMEOUT_MS = 15_000;\n\nexport function showDoDomain(opts: ShowDoDomainOptions): DoDomainHandle {\n if (typeof document === \"undefined\") {\n throw new Error(\"showDoDomain must run in a browser\");\n }\n const base = (opts.baseUrl ?? DEFAULT_BASE).replace(/\\/$/, \"\");\n const origin = new URL(base).origin;\n\n const backdrop = document.createElement(\"div\");\n backdrop.setAttribute(\"data-dodomain\", \"backdrop\");\n // Graphite & Pine (docs/DESIGN.md): graphite-ink scrim (#17201C at 55%) — no\n // backdrop-blur (the system bans glassmorphism chrome) and no blue-grays.\n Object.assign(backdrop.style, {\n position: \"fixed\",\n inset: \"0\",\n background: \"rgba(23,32,28,0.55)\",\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n zIndex: \"2147483647\",\n } as CSSStyleDeclaration);\n\n const frame = document.createElement(\"iframe\");\n // FIX(F-008/§10.1 origin scoping): appends this page's own origin so the\n // hosted flow can scope postMessage's targetOrigin to it instead of \"*\" —\n // see connect-flow.tsx for the producer side of this handshake. The theme\n // param (2026-08-04 embed polish) hands the HOST page's theme to the flow\n // so the sheet matches the page around it.\n frame.src =\n `${base}/connect/${encodeURIComponent(opts.token)}` +\n `?${EMBED_PARAM}=${EMBED_VALUE}&${ORIGIN_PARAM}=${encodeURIComponent(window.location.origin)}` +\n (opts.theme ? `&${THEME_PARAM}=${opts.theme}` : \"\");\n frame.setAttribute(\"title\", \"Connect your domain\");\n // Graphite & Pine card: surface-1 + 1px hairline, card radius 14px,\n // level-3 (modal) graphite shadow. The background pre-paints the hosted\n // flow's canvas IN THE HANDED-OVER THEME, so a slow load never flashes the\n // wrong brightness. Height starts compact and then HUGS THE CONTENT: the\n // flow reports its natural height via `dodomain:height` (onMessage below)\n // and the frame follows — a fixed-height box left a dead slab of empty\n // canvas under short content (2026-08-04 embed polish).\n const dark = opts.theme === \"dark\";\n Object.assign(frame.style, {\n // content-box is load-bearing: host pages routinely reset every element\n // to border-box (Tailwind Preflight et al), which would make the 1px\n // borders eat into the height applyReportedHeight sets — the inner\n // viewport lands 2px short of the reported content and the sheet grows a\n // permanent scrollbar (found live on Uptimely, 2026-08-04).\n boxSizing: \"content-box\",\n width: \"min(560px, 94vw)\",\n height: \"min(480px, 92vh)\",\n border: dark ? \"1px solid #2a352f\" : \"1px solid #e5e9e7\",\n borderRadius: \"14px\",\n boxShadow: \"0 1px 2px rgba(23,32,28,0.05), 0 12px 32px rgba(23,32,28,0.14)\",\n background: dark ? \"#17201c\" : \"#ffffff\",\n transition: \"height 180ms ease\",\n } as CSSStyleDeclaration);\n\n function applyReportedHeight(height: number) {\n if (!Number.isFinite(height) || height <= 0) return;\n const max = Math.floor(window.innerHeight * 0.92);\n const clamped = Math.max(280, Math.min(Math.ceil(height), max));\n frame.style.height = `${clamped}px`;\n }\n\n // FIX(F-010): the only reliable \"did the flow actually come up?\" signal —\n // a cross-origin iframe's 404/500 fires neither `onerror` nor exposes\n // readable content. Cleared by the first `dodomain:ready`/`dodomain:verified`\n // (onMessage below); otherwise fires onError({type:\"load-timeout\"}).\n let loadTimer: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {\n loadTimer = undefined;\n opts.onError?.({ type: \"load-timeout\" });\n }, opts.loadTimeoutMs ?? DEFAULT_LOAD_TIMEOUT_MS);\n\n function clearLoadTimer() {\n if (loadTimer !== undefined) {\n clearTimeout(loadTimer);\n loadTimer = undefined;\n }\n }\n\n // FIX(F-010): best-effort network-level signal (rarely fires for a\n // cross-origin navigation, but free to listen for) — the load-timeout\n // above is the primary detector.\n function onFrameError() {\n clearLoadTimer();\n opts.onError?.({ type: \"load-error\" });\n }\n frame.addEventListener(\"error\", onFrameError);\n\n let closed = false;\n function teardown() {\n if (closed) return;\n closed = true;\n clearLoadTimer();\n window.removeEventListener(\"message\", onMessage);\n frame.removeEventListener(\"error\", onFrameError);\n backdrop.remove();\n }\n function close() {\n teardown();\n opts.onClose?.();\n }\n\n function onMessage(e: MessageEvent) {\n if (e.origin !== origin) return;\n // Cheap runtime guard (no zod, per R5 — see the module-level fix note\n // above): a `MessageEvent.data` narrowing, not a full schema parse.\n const data = e.data as DoDomainMessage | undefined;\n if (!data || typeof data.type !== \"string\") return;\n if (data.type === MESSAGE_TYPES.VERIFIED) {\n clearLoadTimer();\n opts.onVerified?.({ domain: data.domain });\n } else if (data.type === MESSAGE_TYPES.READY) {\n clearLoadTimer();\n } else if (data.type === MESSAGE_TYPES.ERROR) {\n clearLoadTimer();\n opts.onError?.({ type: \"session-error\", code: data.code });\n } else if (data.type === MESSAGE_TYPES.HEIGHT) {\n applyReportedHeight(data.height);\n } else if (data.type === MESSAGE_TYPES.CLOSE) {\n close();\n }\n }\n\n backdrop.addEventListener(\"click\", (e) => {\n if (e.target === backdrop) close();\n });\n window.addEventListener(\"message\", onMessage);\n\n backdrop.appendChild(frame);\n document.body.appendChild(backdrop);\n\n return { close };\n}\n"],"mappings":"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","names":[]} | ||
| {"version":3,"sources":["../../core/src/origin.ts","../../core/src/message-types.ts","../src/index.ts"],"sourcesContent":["// The single canonical public origin for the DoDomain hosted app (F-010) —\n// the production home for BOTH the REST API (`/api/v1/*`) and the hosted\n// connect flow (`/connect/:token`). See root README.md's \"Origins\" section\n// for the topology: `api.dodomain.io` / `connect.dodomain.io` are cosmetic\n// subdomain names for this same apps/web deployment, not separate hosts,\n// until ops splits them onto distinct deployments.\n//\n// Zero imports, framework-free — the one literal both the node SDK\n// (packages/node) and the embeddable widget (packages/connect) default to,\n// so a shipped SDK and a shipped widget can never re-diverge on the prod\n// origin the way they did before this fix (node defaulted to the unregistered\n// `api.dodomain.io`; connect defaulted to the unregistered `connect.dodomain.io`\n// — neither actually resolves, so the widget's iframe would 404 with zero\n// error surface). apps/web's own `env.ts` `APP_ORIGIN` stays a REQUIRED,\n// no-default env var by design (F-015, fail-closed) — this constant is a\n// client-facing SDK/widget default only, never an env fallback.\nexport const DODOMAIN_DEFAULT_ORIGIN = \"https://app.dodomain.io\";\n","// The zod-FREE half of the widget <-> hosted-flow postMessage contract\n// (F-010 split — see messages.ts's header for the full history). Zero\n// imports, so nothing here can ever pull zod into a consuming bundle,\n// regardless of tree-shaking. @dodomain/connect (bundle-size-sensitive — it\n// ships into an INTEGRATOR's page, not DoDomain's own) imports ONLY from\n// this file, never from messages.ts.\n//\n// messages.ts re-exports everything below unchanged, so existing\n// `from \"@dodomain/core/messages\"` imports (apps/web's connect-flow.tsx)\n// keep working without any change — messages.ts is still the one place that\n// ALSO exports the zod validator (zDoDomainMessage) for zod-tolerant\n// consumers.\n\n/**\n * postMessage type discriminants for the widget <-> hosted-flow contract.\n *\n * READY/ERROR are the load-detection handshake — a cross-origin iframe's\n * HTTP 404/500 fires neither `onerror` nor exposes readable content, so a\n * handshake postMessage from the flow is the only reliable \"did this\n * actually load?\" signal. The hosted flow (connect-flow.tsx) posts READY on\n * mount; the widget (packages/connect) starts a `loadTimeoutMs` timer on\n * show and clears it on the first READY/VERIFIED, else calls\n * `onError({type:\"load-timeout\"})`. ERROR carries a `code` (the same\n * verify()-failure vocabulary connect-flow.tsx already renders in its own\n * in-page banner) so the widget can call `onError({type:\"session-error\",code})`\n * — additive: an older widget build safely ignores both unknown types.\n */\nexport const MESSAGE_TYPES = {\n VERIFIED: \"dodomain:verified\",\n CLOSE: \"dodomain:close\",\n READY: \"dodomain:ready\",\n ERROR: \"dodomain:error\",\n // Content-height report (2026-08-04 embed polish): the hosted flow posts\n // its natural content height on mount and on every resize so the widget's\n // iframe can hug the content instead of sitting at a fixed height with\n // dead space below the footer. Additive — an older widget build safely\n // ignores the unknown type, and an older flow simply never posts it (the\n // widget keeps its initial height).\n HEIGHT: \"dodomain:height\",\n} as const;\n\n// ── The iframe URL contract ──────────────────────────────────────────────\n// packages/connect builds `${base}/connect/${token}?${EMBED_PARAM}=${EMBED_VALUE}\n// &${ORIGIN_PARAM}=<its own origin>`; the hosted connect page\n// (apps/web/src/app/connect/[token]/connect-flow.tsx) reads both params — ONE\n// set of query-param names instead of \"embed\"/\"origin\" string literals\n// hand-typed on both sides. `ORIGIN_PARAM` carries the embedding integrator's\n// origin so the hosted flow can scope postMessage's targetOrigin to it\n// instead of \"*\" (PLAN-F-008 §2/§10.1 — see connect-flow.tsx for the\n// documented \"*\" fallback when the param is absent).\nexport const EMBED_PARAM = \"embed\";\nexport const EMBED_VALUE = \"1\";\nexport const ORIGIN_PARAM = \"origin\";\n// Host-app theme handoff (2026-08-04 embed polish): the widget passes the\n// integrator page's theme so the embedded sheet matches it — a theme toggle\n// inside someone else's modal is chrome noise, so the hosted flow hides its\n// own toggle in embed mode and adopts this value instead. Only \"light\" and\n// \"dark\" are honored; anything else falls back to the flow's own resolution.\nexport const THEME_PARAM = \"theme\";\n\n// Hand-written (not `z.infer<typeof zDoDomainMessage>`, unlike before the\n// split — that schema now lives in messages.ts, which imports zod, and this\n// file must not). messages.ts's zDoDomainMessage is annotated\n// `z.ZodType<DoDomainMessage>` against THIS type, so if the two shapes ever\n// drift, messages.ts fails to typecheck — compiler-enforced sync, not just a\n// documentation promise.\nexport type DoDomainMessage =\n | { type: typeof MESSAGE_TYPES.VERIFIED; domain?: string }\n | { type: typeof MESSAGE_TYPES.CLOSE }\n | { type: typeof MESSAGE_TYPES.READY }\n | { type: typeof MESSAGE_TYPES.ERROR; code: string }\n | { type: typeof MESSAGE_TYPES.HEIGHT; height: number };\n","// @dodomain/connect — the embeddable browser widget.\n// Opens the hosted connect page in a modal iframe and relays lifecycle events.\n// Integrator branding (App.name/logoUrl/brandColor, 2026-07-21) reaches the\n// end user THROUGH the hosted flow this iframe renders — the widget draws no\n// flow chrome of its own, so it needs no branding API and no postMessage\n// contract change (message-types stays as-is).\n//\n// import { showDoDomain } from \"@dodomain/connect\";\n// const session = await fetch(\"/my-api/create-session\").then(r => r.json());\n// showDoDomain({\n// token: session.token,\n// onVerified: () => refetch(),\n// onClose: ({ state }) => { if (state !== \"verified\") keepPromptVisible(); },\n// // The embed can't mount (host CSP / network / content blocker) — send\n// // the user to the same flow full-page instead of failing silently.\n// onError: (e) => { if (e.code === \"MOUNT_BLOCKED\") location.assign(e.hostedUrl); },\n// });\n//\n// FIX(F-008, split F-010): imports the message-type constants + a type-only\n// contract from @dodomain/core/message-types — a ZERO-IMPORT module — never\n// zod at runtime (R5: this widget ships into an INTEGRATOR's page bundle, so\n// it stays dependency-free). F-008 originally imported the plain-const half\n// of @dodomain/core/messages (which ALSO imports zod, for zDoDomainMessage),\n// betting a bundler's tree-shaking would drop the unused zod graph. F-010\n// verified that bet against a real tsup build and it did NOT hold (esbuild's\n// default AND Rollup's tree-shaking both left zod's full runtime in dist,\n// confirmed via test/build.smoke.test.ts) — so the plain consts now live in\n// their own zod-free module (messages.ts's header has the full history) and\n// this package imports ONLY from there, guaranteeing zod can never reach\n// this bundle regardless of any bundler's tree-shaking sophistication.\nimport { DODOMAIN_DEFAULT_ORIGIN } from \"@dodomain/core/origin\";\nimport {\n EMBED_PARAM,\n EMBED_VALUE,\n MESSAGE_TYPES,\n ORIGIN_PARAM,\n THEME_PARAM,\n type DoDomainMessage,\n} from \"@dodomain/core/message-types\";\n\nexport interface ShowDoDomainOptions {\n /** Session token from POST /api/v1/sessions (dd_sess_…). */\n token: string;\n /** DoDomain origin. Defaults to https://app.dodomain.io. */\n baseUrl?: string;\n onVerified?: (detail: { domain?: string }) => void;\n /**\n * Fires when the modal is dismissed (backdrop click, the flow's own close\n * affordance, or `handle.close()`).\n *\n * The detail argument (2026-08-17, BioFlow feedback) carries the session's\n * last-known state so closing means something: partners reported that a\n * zero-arg close \"proves nothing either way\", forcing them to re-poll their\n * own backend after every dismissal. Existing zero-arg handlers keep\n * compiling and behaving identically — a `() => void` is assignable to this\n * type, and the argument is simply ignored.\n */\n onClose?: (detail: DoDomainCloseDetail) => void;\n /**\n * FIX(F-010): fires when the hosted flow fails to load or reports a\n * session error — a cross-origin iframe's HTTP 404/500 exposes neither\n * `onerror` nor readable content by default, so before this fix a broken\n * embed just sat there silently. See DoDomainWidgetError's own doc for the\n * three cases.\n */\n onError?: (detail: DoDomainWidgetError) => void;\n /**\n * FIX(F-010): milliseconds to wait for the hosted flow's `dodomain:ready`\n * handshake before treating the embed as failed-to-load. Default 15000.\n */\n loadTimeoutMs?: number;\n /**\n * Host-page theme (2026-08-04 embed polish). Pass the theme YOUR page is\n * currently rendering so the embedded sheet matches it — the hosted flow\n * adopts it and hides its own theme toggle. Omitted ⇒ the flow resolves\n * its own theme (prefers-color-scheme / its visitor preference).\n */\n theme?: \"light\" | \"dark\";\n}\n\n/**\n * The one machine-readable code meaning \"the sheet never came up — nothing\n * the user does inside this modal can succeed\" (2026-08-17, BioFlow\n * feedback: a host-page CSP whose `frame-src` omitted the DoDomain origin\n * failed indistinguishably from every other error, so the integrator had to\n * hang a hosted-URL fallback off a generic `onError`).\n *\n * Deliberately NOT named CSP_BLOCKED: the widget cannot tell a CSP block\n * from a DNS failure, an offline network, or a content blocker eating the\n * frame — all four look identical from the parent page (a cross-origin\n * iframe exposes neither readable content nor a reliable error event). The\n * honest name covers all of them, and the handling is the same for all of\n * them: send the user to `hostedUrl` (see the README's \"Origins & CSP\").\n */\nexport const MOUNT_BLOCKED = \"MOUNT_BLOCKED\";\n\n/**\n * FIX(F-010): the three ways `onError` can fire.\n * - `load-timeout` — no `dodomain:ready`/`dodomain:verified` arrived within\n * `loadTimeoutMs` (covers a 404/DNS failure/hung load — anything that\n * never gets far enough to run the hosted flow's own JS).\n * - `load-error` — the iframe's own `error` event fired, OR the HOST page's\n * own CSP reported blocking this frame (`securitypolicyviolation` on\n * `frame-src`/`child-src`/`default-src` — 2026-08-17). Both are\n * best-effort fast paths for the same fact the `load-timeout` above\n * eventually proves anyway; the CSP one just gets there in milliseconds\n * instead of `loadTimeoutMs`.\n * - `session-error` — the hosted flow mounted and posted `dodomain:error`\n * with a `code` (e.g. an expired/not-found token, or a verify() failure —\n * see connect-flow.tsx).\n *\n * Every variant carries `hostedUrl` (the full-page `/connect/<token>` URL,\n * no embed params) and a `code`. `code === MOUNT_BLOCKED` is the single\n * check a partner needs for \"the embed is impossible here, fall back\":\n * navigate to `hostedUrl`. `type` stays the pre-existing 3-value vocabulary\n * so no consumer's switch changes meaning.\n */\nexport type DoDomainWidgetError =\n | { type: \"load-timeout\"; code: typeof MOUNT_BLOCKED; hostedUrl: string }\n | { type: \"load-error\"; code: typeof MOUNT_BLOCKED; hostedUrl: string }\n | { type: \"session-error\"; code: string; hostedUrl: string };\n\n/**\n * The session's last-known state, derived entirely from the postMessage\n * traffic already flowing from the hosted flow (2026-08-17) — no new message\n * type was needed, and the widget never talks to the API itself.\n * - `unknown` — nothing was ever heard from the flow (it never mounted:\n * CSP/network/blocked, i.e. the `MOUNT_BLOCKED` case).\n * - `pending` — the flow mounted (`dodomain:ready`) but reached no outcome\n * before the user closed it.\n * - `verified` — `dodomain:verified` arrived; the domain is connected.\n * Sticky: a later `dodomain:error` cannot downgrade it.\n * - `failed` — the flow reported `dodomain:error` (e.g. a verify() failure)\n * and never went on to verify.\n */\nexport type DoDomainSessionState = \"verified\" | \"pending\" | \"failed\" | \"unknown\";\n\n/**\n * What `onClose` receives (2026-08-17). Additive: handlers written as\n * `() => …` before this existed keep compiling and behaving identically.\n */\nexport interface DoDomainCloseDetail {\n state: DoDomainSessionState;\n /** The verified domain, when `state === \"verified\"` reported one. */\n domain?: string;\n}\n\nexport interface DoDomainHandle {\n close: () => void;\n}\n\nconst DEFAULT_BASE = DODOMAIN_DEFAULT_ORIGIN;\nconst DEFAULT_LOAD_TIMEOUT_MS = 15_000;\n\nexport function showDoDomain(opts: ShowDoDomainOptions): DoDomainHandle {\n if (typeof document === \"undefined\") {\n throw new Error(\"showDoDomain must run in a browser\");\n }\n const base = (opts.baseUrl ?? DEFAULT_BASE).replace(/\\/$/, \"\");\n const origin = new URL(base).origin;\n // The full-page flow for this same session — what a partner navigates to\n // when the embed can't mount (2026-08-17). Deliberately WITHOUT the embed/\n // origin/theme params the iframe carries: those put the flow in embedded\n // mode (no page chrome, postMessage close), which is wrong for a top-level\n // navigation.\n const hostedUrl = `${base}/connect/${encodeURIComponent(opts.token)}`;\n\n const backdrop = document.createElement(\"div\");\n backdrop.setAttribute(\"data-dodomain\", \"backdrop\");\n // Graphite & Pine (docs/DESIGN.md): graphite-ink scrim (#17201C at 55%) — no\n // backdrop-blur (the system bans glassmorphism chrome) and no blue-grays.\n Object.assign(backdrop.style, {\n position: \"fixed\",\n inset: \"0\",\n background: \"rgba(23,32,28,0.55)\",\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n zIndex: \"2147483647\",\n } as CSSStyleDeclaration);\n\n const frame = document.createElement(\"iframe\");\n // FIX(F-008/§10.1 origin scoping): appends this page's own origin so the\n // hosted flow can scope postMessage's targetOrigin to it instead of \"*\" —\n // see connect-flow.tsx for the producer side of this handshake. The theme\n // param (2026-08-04 embed polish) hands the HOST page's theme to the flow\n // so the sheet matches the page around it.\n frame.src =\n hostedUrl +\n `?${EMBED_PARAM}=${EMBED_VALUE}&${ORIGIN_PARAM}=${encodeURIComponent(window.location.origin)}` +\n (opts.theme ? `&${THEME_PARAM}=${opts.theme}` : \"\");\n frame.setAttribute(\"title\", \"Connect your domain\");\n // Graphite & Pine card: surface-1 + 1px hairline, card radius 14px,\n // level-3 (modal) graphite shadow. The background pre-paints the hosted\n // flow's canvas IN THE HANDED-OVER THEME, so a slow load never flashes the\n // wrong brightness. Height starts compact and then HUGS THE CONTENT: the\n // flow reports its natural height via `dodomain:height` (onMessage below)\n // and the frame follows — a fixed-height box left a dead slab of empty\n // canvas under short content (2026-08-04 embed polish).\n const dark = opts.theme === \"dark\";\n Object.assign(frame.style, {\n // content-box is load-bearing: host pages routinely reset every element\n // to border-box (Tailwind Preflight et al), which would make the 1px\n // borders eat into the height applyReportedHeight sets — the inner\n // viewport lands 2px short of the reported content and the sheet grows a\n // permanent scrollbar (found live on Uptimely, 2026-08-04).\n boxSizing: \"content-box\",\n width: \"min(560px, 94vw)\",\n height: \"min(480px, 92vh)\",\n border: dark ? \"1px solid #2a352f\" : \"1px solid #e5e9e7\",\n borderRadius: \"14px\",\n boxShadow: \"0 1px 2px rgba(23,32,28,0.05), 0 12px 32px rgba(23,32,28,0.14)\",\n background: dark ? \"#17201c\" : \"#ffffff\",\n transition: \"height 180ms ease\",\n } as CSSStyleDeclaration);\n\n function applyReportedHeight(height: number) {\n if (!Number.isFinite(height) || height <= 0) return;\n const max = Math.floor(window.innerHeight * 0.92);\n const clamped = Math.max(280, Math.min(Math.ceil(height), max));\n frame.style.height = `${clamped}px`;\n }\n\n // The session's last-known state, derived from the postMessage traffic\n // this widget already receives (2026-08-17) — see DoDomainSessionState.\n // Starts \"unknown\": a modal that never heard from the flow proves nothing.\n let state: DoDomainSessionState = \"unknown\";\n let verifiedDomain: string | undefined;\n let closed = false;\n\n // FIX(F-010): the only reliable \"did the flow actually come up?\" signal —\n // a cross-origin iframe's 404/500 fires neither `onerror` nor exposes\n // readable content. Cleared by the first `dodomain:ready`/`dodomain:verified`\n // (onMessage below); otherwise fires onError({type:\"load-timeout\"}).\n let loadTimer: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {\n loadTimer = undefined;\n reportMountFailure(\"load-timeout\");\n }, opts.loadTimeoutMs ?? DEFAULT_LOAD_TIMEOUT_MS);\n\n function clearLoadTimer() {\n if (loadTimer !== undefined) {\n clearTimeout(loadTimer);\n loadTimer = undefined;\n }\n }\n\n // \"The frame never mounted\" is ONE fact with several possible detectors\n // (2026-08-17), so it reports at most once no matter how many of them\n // fire: the CSP violation event and the iframe error event can both land\n // for the same block, and either would otherwise be followed by the\n // load-timeout as well.\n let mountFailureReported = false;\n function reportMountFailure(type: \"load-timeout\" | \"load-error\") {\n if (mountFailureReported || closed) return;\n mountFailureReported = true;\n clearLoadTimer();\n opts.onError?.({ type, code: MOUNT_BLOCKED, hostedUrl });\n }\n\n // FIX(F-010): best-effort network-level signal (rarely fires for a\n // cross-origin navigation, but free to listen for) — the load-timeout\n // above is the primary detector.\n function onFrameError() {\n reportMountFailure(\"load-error\");\n }\n frame.addEventListener(\"error\", onFrameError);\n\n // 2026-08-17 (BioFlow): the host page's OWN CSP is the one mount failure\n // the browser will actually tell us about — when it refuses to load this\n // frame it fires `securitypolicyviolation` on the embedding document. That\n // turns a 15-second silent wait into an immediate, correctly-coded\n // MOUNT_BLOCKED. It is a fast path, never the only one: browsers without\n // the event (or a block that isn't CSP at all — DNS, offline, a content\n // blocker) still land on the load-timeout above.\n //\n // The event is typed loosely on purpose: `SecurityPolicyViolationEvent`\n // isn't guaranteed to exist at runtime, and the two fields read here are\n // the only ones this needs.\n function onCspViolation(e: Event) {\n const violation = e as { blockedURI?: unknown; violatedDirective?: unknown };\n const directive =\n typeof violation.violatedDirective === \"string\" ? violation.violatedDirective : \"\";\n const blockedUri = typeof violation.blockedURI === \"string\" ? violation.blockedURI : \"\";\n // CSP falls back frame-src → child-src → default-src, and the report\n // names whichever directive was actually enforced, so all three mean\n // \"this page's policy refused our frame\". (Browsers report either the\n // bare directive name or `<name> <source-list>`, hence startsWith.)\n const framesBlocked =\n directive.startsWith(\"frame-src\") ||\n directive.startsWith(\"child-src\") ||\n directive.startsWith(\"default-src\");\n // blockedURI is the frame URL, or just its origin when the browser\n // strips it cross-origin — both start with our origin.\n if (!framesBlocked || !blockedUri.startsWith(origin)) return;\n reportMountFailure(\"load-error\");\n }\n document.addEventListener(\"securitypolicyviolation\", onCspViolation);\n\n function teardown() {\n if (closed) return;\n closed = true;\n clearLoadTimer();\n window.removeEventListener(\"message\", onMessage);\n frame.removeEventListener(\"error\", onFrameError);\n document.removeEventListener(\"securitypolicyviolation\", onCspViolation);\n backdrop.remove();\n }\n function close() {\n teardown();\n // `domain` is omitted rather than passed as undefined so the detail\n // object reads the way a consumer would write it.\n opts.onClose?.(verifiedDomain === undefined ? { state } : { state, domain: verifiedDomain });\n }\n\n function onMessage(e: MessageEvent) {\n if (e.origin !== origin) return;\n // Cheap runtime guard (no zod, per R5 — see the module-level fix note\n // above): a `MessageEvent.data` narrowing, not a full schema parse.\n const data = e.data as DoDomainMessage | undefined;\n if (!data || typeof data.type !== \"string\") return;\n if (data.type === MESSAGE_TYPES.VERIFIED) {\n clearLoadTimer();\n // Terminal and sticky: a later dodomain:error (e.g. a re-check the\n // user triggered after the fact) cannot un-verify a connected domain.\n state = \"verified\";\n verifiedDomain = data.domain;\n opts.onVerified?.({ domain: data.domain });\n } else if (data.type === MESSAGE_TYPES.READY) {\n clearLoadTimer();\n // The flow is up but has reached no outcome — anything stronger than\n // \"pending\" would be a claim we haven't heard.\n if (state === \"unknown\") state = \"pending\";\n } else if (data.type === MESSAGE_TYPES.ERROR) {\n clearLoadTimer();\n // connect-flow.tsx posts this for a verify() failure, which the user\n // can still retry inside the same session — so it is NOT terminal, and\n // a subsequent dodomain:verified promotes the state above.\n if (state !== \"verified\") state = \"failed\";\n opts.onError?.({ type: \"session-error\", code: data.code, hostedUrl });\n } else if (data.type === MESSAGE_TYPES.HEIGHT) {\n applyReportedHeight(data.height);\n } else if (data.type === MESSAGE_TYPES.CLOSE) {\n close();\n }\n }\n\n backdrop.addEventListener(\"click\", (e) => {\n if (e.target === backdrop) close();\n });\n window.addEventListener(\"message\", onMessage);\n\n backdrop.appendChild(frame);\n document.body.appendChild(backdrop);\n\n return { close };\n}\n"],"mappings":"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","names":[]} |
+9
-10
| { | ||
| "name": "@dodomain/connect", | ||
| "version": "0.2.1", | ||
| "version": "0.3.0", | ||
| "description": "Official browser widget for DoDomain — opens the hosted domain-connect flow in a modal iframe and relays its lifecycle events.", | ||
@@ -46,10 +46,3 @@ "license": "MIT", | ||
| ], | ||
| "scripts": { | ||
| "build": "tsup", | ||
| "typecheck": "tsc --noEmit", | ||
| "test": "node --experimental-strip-types --test test/*.test.ts", | ||
| "prepublishOnly": "pnpm run typecheck && pnpm run test && pnpm run build" | ||
| }, | ||
| "devDependencies": { | ||
| "@dodomain/core": "workspace:*", | ||
| "tsup": "^8.5.1", | ||
@@ -59,4 +52,10 @@ "jsdom": "^29.1.1", | ||
| "@types/node": "^22.10.5", | ||
| "typescript": "^5.7.3" | ||
| "typescript": "^5.7.3", | ||
| "@dodomain/core": "0.0.0" | ||
| }, | ||
| "scripts": { | ||
| "build": "tsup", | ||
| "typecheck": "tsc --noEmit", | ||
| "test": "node --experimental-strip-types --test test/*.test.ts" | ||
| } | ||
| } | ||
| } |
+94
-19
@@ -31,6 +31,15 @@ # @dodomain/connect | ||
| }, | ||
| onClose: () => console.log("user closed the modal"), | ||
| onClose: ({ state }) => { | ||
| // "verified" | "pending" | "failed" | "unknown" — closing now tells you | ||
| // something, so you don't have to re-poll your own backend to find out. | ||
| if (state !== "verified") keepTheConnectDomainPromptVisible(); | ||
| }, | ||
| onError: (err) => { | ||
| // { type: "load-timeout" } | { type: "load-error" } | { type: "session-error", code } | ||
| console.error("connect flow failed to load:", err); | ||
| if (err.code === "MOUNT_BLOCKED") { | ||
| // The iframe never mounted (host CSP, network, content blocker). | ||
| // Same session, full page — see "Origins & CSP" below. | ||
| location.assign(err.hostedUrl); | ||
| return; | ||
| } | ||
| console.error("connect flow failed:", err.type, err.code); | ||
| }, | ||
@@ -46,10 +55,10 @@ }); | ||
| | Option | Type | Notes | | ||
| | --------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | ||
| | `token` | `string` | Required. Session token (`dd_sess_…`) from `POST /api/v1/sessions`. | | ||
| | `baseUrl` | `string` | DoDomain origin. Defaults to `https://app.dodomain.io`. | | ||
| | `onVerified` | `(detail: { domain?: string }) => void` | Fires when the domain verifies. | | ||
| | `onClose` | `() => void` | Fires when the modal is dismissed (backdrop click or in-flow close). | | ||
| | `onError` | `(detail: DoDomainWidgetError) => void` | Fires when the flow fails to load or reports a session error — see below. Absent by default (previously: silent). | | ||
| | `loadTimeoutMs` | `number` | How long to wait for the hosted flow's load handshake before treating the embed as failed. Default `15000`. | | ||
| | Option | Type | Notes | | ||
| | --------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | ||
| | `token` | `string` | Required. Session token (`dd_sess_…`) from `POST /api/v1/sessions`. | | ||
| | `baseUrl` | `string` | DoDomain origin. Defaults to `https://app.dodomain.io`. | | ||
| | `onVerified` | `(detail: { domain?: string }) => void` | Fires when the domain verifies. | | ||
| | `onClose` | `(detail: DoDomainCloseDetail) => void` | Fires when the modal is dismissed (backdrop click, in-flow close, `handle.close()`), with the session's last-known state — see below. A zero-arg `() => …` handler written against an older version keeps working. | | ||
| | `onError` | `(detail: DoDomainWidgetError) => void` | Fires when the flow fails to load or reports a session error — see below. Absent by default (previously: silent). | | ||
| | `loadTimeoutMs` | `number` | How long to wait for the hosted flow's load handshake before treating the embed as failed. Default `15000`. | | ||
@@ -62,12 +71,78 @@ Returns a handle with `close()`. Messages from the iframe are **origin-checked** against | ||
| A cross-origin iframe's HTTP 404/500 doesn't fire `onerror` or expose readable content, so a | ||
| broken embed used to be entirely silent. `onError` now fires with one of: | ||
| broken embed used to be entirely silent. Every `onError` detail carries a `code` and a | ||
| `hostedUrl` (the full-page flow for the same session), plus a `type`: | ||
| - `{ type: "load-timeout" }` — no load handshake arrived within `loadTimeoutMs` (covers a 404, | ||
| DNS failure, or a hang — anything that never gets far enough to run the hosted flow's own JS). | ||
| - `{ type: "load-error" }` — the iframe's own `error` event fired (best-effort; rarely fires for a | ||
| cross-origin navigation, but free to listen for). | ||
| - `{ type: "session-error", code: string }` — the flow loaded but reported a failure (e.g. a | ||
| `verify()` call failing mid-flow). `code` matches the same vocabulary the hosted page's own | ||
| in-page error banner uses (`expired`, `not_found`, `invalid_request`, `internal`). | ||
| - `{ type: "load-timeout", code: "MOUNT_BLOCKED" }` — no load handshake arrived within | ||
| `loadTimeoutMs` (a 404, DNS failure, offline network, content blocker, or a host-page CSP — | ||
| anything that never gets far enough to run the hosted flow's own JS). | ||
| - `{ type: "load-error", code: "MOUNT_BLOCKED" }` — a faster signal for the same fact: the | ||
| iframe's own `error` event fired, **or** your page's CSP reported blocking the frame | ||
| (`securitypolicyviolation` on `frame-src`/`child-src`/`default-src`), which lands in | ||
| milliseconds instead of waiting out `loadTimeoutMs`. | ||
| - `{ type: "session-error", code: string }` — the hosted page loaded and reported a failure. | ||
| `code` matches the same vocabulary the hosted page's own in-page error banner uses | ||
| (`expired`, `not_found`, `invalid_request`, `internal`). Two producers: a `verify()` call | ||
| failing mid-flow, and — since 2026-08-18 — a token that was **already dead when the sheet | ||
| opened** (`expired` / `not_found`), which previously reached you as a misleading | ||
| `load-timeout` instead. Treat this one as terminal: close the sheet and mint a fresh session | ||
| rather than retrying the same token. | ||
| **`code === "MOUNT_BLOCKED"` is the one check you need for "the embed is impossible here":** | ||
| the sheet never came up, so nothing the user does inside it can succeed — send them to | ||
| `err.hostedUrl` instead. It is deliberately not called `CSP_BLOCKED`: from the parent page a | ||
| CSP block, a DNS failure and an ad blocker are indistinguishable, and all four want the same | ||
| fallback. | ||
| ### `onClose` — `DoDomainCloseDetail` | ||
| `onClose` receives `{ state, domain? }`, derived from the flow's own postMessage traffic, so a | ||
| dismissal is informative instead of ambiguous: | ||
| | `state` | Meaning | | ||
| | ---------- | --------------------------------------------------------------------------------------------------- | | ||
| | `verified` | The flow reported the domain verified (`domain` is set). Sticky — a later error can't downgrade it. | | ||
| | `pending` | The flow mounted, but the user closed it before any outcome. | | ||
| | `failed` | The flow reported an error (e.g. a `verify()` failure) and never verified. | | ||
| | `unknown` | The widget never heard from the flow at all — pair this with a `MOUNT_BLOCKED` `onError`. | | ||
| The signed `connection.verified` webhook remains the source of truth; `state` is a UI cue, and | ||
| anything in a browser can be spoofed. | ||
| ## Origins & CSP | ||
| The widget loads the hosted flow in an iframe from **one** origin, so a Content-Security-Policy | ||
| on your page must allow it in `frame-src` (browsers fall back to `child-src`, then | ||
| `default-src`, so allow it in whichever of those you actually set): | ||
| ``` | ||
| Content-Security-Policy: frame-src https://app.dodomain.io; | ||
| ``` | ||
| - `https://app.dodomain.io` is the production origin — the value of `DODOMAIN_DEFAULT_ORIGIN` | ||
| ([`packages/core/src/origin.ts`](../core/src/origin.ts)), which is what `baseUrl` defaults to | ||
| and the single place this repo defines it. `api.dodomain.io` and `connect.dodomain.io` are | ||
| cosmetic names for the same deployment and are **not** served today — don't allowlist them. | ||
| - If you pass your own `baseUrl` (self-hosted or staging), allowlist that origin instead. | ||
| - The widget injects no scripts, styles, fonts or images into your page, so `frame-src` is the | ||
| only directive it needs. It does listen for your page's own `securitypolicyviolation` events | ||
| to detect a block early — a read-only listener, nothing is reported anywhere. | ||
| ### Hosted-URL fallback | ||
| If the frame can't mount, fall back to the same session full-page — no second API call, the | ||
| token is already yours: | ||
| ```ts | ||
| showDoDomain({ | ||
| token, | ||
| onError: (err) => { | ||
| if (err.code === "MOUNT_BLOCKED") location.assign(err.hostedUrl); // {baseUrl}/connect/{token} | ||
| }, | ||
| }); | ||
| ``` | ||
| `hostedUrl` deliberately omits the `embed`/`origin`/`theme` query params the iframe carries: | ||
| those put the flow into embedded mode, which is wrong for a top-level navigation. Pass | ||
| `returnUrl` when you mint the session so the user lands back in your app afterwards. | ||
| See [`src/index.ts`](src/index.ts) for the implementation. |
87037
54.91%433
35.31%146
105.63%