[{"data":1,"prerenderedAt":1572},["ShallowReactive",2],{"work-\u002Fwork\u002Fwebfuse-lovable-cobrowse-integration":3},{"id":4,"title":5,"body":6,"date":1556,"description":1557,"extension":1558,"externalUrl":1559,"featured":1560,"kind":1561,"meta":1562,"navigation":513,"path":1563,"seo":1564,"stem":1565,"tags":1566,"__hash__":1571},"work\u002Fwork\u002Fwebfuse-lovable-cobrowse-integration.md","Adding live co-browse to a Lovable app: a Webfuse x Lovable integration",{"type":7,"value":8,"toc":1550},"minimark",[9,13,41,44,49,52,82,85,123,127,130,144,350,360,363,372,389,979,990,1296,1299,1316,1330,1348,1472,1479,1482,1539,1543,1546],[10,11,5],"h1",{"id":12},"adding-live-co-browse-to-a-lovable-app-a-webfuse-x-lovable-integration",[14,15,16,17,24,25,30,31,35,36,40],"p",{},"Built on ",[18,19,23],"a",{"href":20,"rel":21},"https:\u002F\u002Fwww.webfuse.com",[22],"nofollow","Webfuse",". A prospect had built their product on ",[18,26,29],{"href":27,"rel":28},"https:\u002F\u002Flovable.dev",[22],"Lovable"," - scaffolded as an AI-generated React app with Supabase functions - and needed live co-browsing inside their video calls. This is the reference build that answers \"how do you add Webfuse co-browse to a Lovable app,\" end to end: a customer on a call clicks ",[32,33,34],"strong",{},"Cobrowse",", a Webfuse session starts and shows a 4-digit PIN, the customer reads the PIN aloud to the agent, the agent types it, and their screen joins the ",[37,38,39],"em",{},"same"," live session.",[14,42,43],{},"The deliverable was shaped by who receives it. A Lovable builder describes features in a chat box; they don't open terminals or hand-write Edge Functions. So the output is two things: a working reference to import, and a build spec written to be pasted straight into Lovable's chat by a non-engineer. The stack matches Lovable's own - React 19, Vite, TypeScript, Tailwind v4 - with the backend as a Supabase Edge Function, because that is exactly what a Lovable app already deploys to.",[45,46,48],"h2",{"id":47},"the-problem","The problem",[14,50,51],{},"Lovable hands you a React frontend and a Supabase backend. That is the whole surface you get to work with, and every naive way to bolt on \"join a shared session\" breaks against it - each for a reason no better prompt in the Lovable chat can fix:",[53,54,55,66,76],"ul",{},[56,57,58,61,62,65],"li",{},[32,59,60],{},"Pass a session link around."," Every customer gets their own Webfuse session. A shared link or shared join state collides the moment two customers are on calls at once. The agent needs to target ",[37,63,64],{},"one specific"," customer's session, not \"the\" session.",[56,67,68,71,72,75],{},[32,69,70],{},"Do the join from the browser."," Finding the customer's session and minting an authenticated host login needs two secret keys - a Space REST key and a Magic Link signing key. Put those in a Lovable-generated frontend and anyone viewing source can mint host logins. So the agent side ",[37,73,74],{},"must"," run server-side - but the customer side must not need a backend at all, because it only starts a session.",[56,77,78,81],{},[32,79,80],{},"Reach for infrastructure Lovable doesn't have."," No Docker, no separate API gateway, no CLI step. If the integration can't be expressed as \"a Supabase Edge Function plus some frontend,\" a Lovable builder can't ship it.",[14,83,84],{},"Underneath those, three root causes shaped the build:",[86,87,88,99,117],"ol",{},[56,89,90,93,94,98],{},[32,91,92],{},"The trust boundary is the whole design."," One key is public by design - the Widget Key, domain-locked. Two are secret. On Lovable that line is already drawn for you: public ",[95,96,97],"code",{},"VITE_*"," env versus Supabase Secrets. The job is to honor it and never let a secret cross into the frontend.",[56,100,101,104,105,108,109,112,113,116],{},[32,102,103],{},"Webfuse auth is easy to get subtly wrong."," REST uses ",[95,106,107],{},"Authorization: Token",", not ",[95,110,111],{},"Bearer"," - a common cause of a silent 401. And the join mechanism is a ",[95,114,115],{},"session_id"," query param that isn't obvious from the public Widget docs.",[56,118,119,122],{},[32,120,121],{},"The reference has to actually run for a non-engineer."," If starting the demo needs the Supabase CLI plus Docker, the prospect - and future-me - won't run it, and the reference loses its whole point as a live thing to point at.",[45,124,126],{"id":125},"the-design","The design",[14,128,129],{},"Two independent sides of one feature, split on the trust boundary Lovable already gives you.",[14,131,132,135,136,139,140,143],{},[32,133,134],{},"The customer side is frontend only - pure Lovable territory."," It loads ",[95,137,138],{},"surfly.js",", calls ",[95,141,142],{},"initSpace()"," with the public Widget Key, and starts a session. Webfuse generates and displays the PIN itself - the app never fetches or shows it. No secret ever reaches this path, which is exactly why the customer side needs nothing but the React that Lovable already generates.",[145,146,151],"pre",{"className":147,"code":148,"language":149,"meta":150,"style":150},"language-ts shiki shiki-themes material-theme-lighter github-light github-dark","\u002F\u002F In an Assisted Space, Webfuse shows a 4-digit PIN inside the session\n\u002F\u002F automatically - the customer reads it to the agent. No secret keys here.\nexport async function startCustomerSession(iframeSelector: string) {\n  loadSurfly()\n  const space = await window.webfuse.initSpace(WIDGET_KEY, SPACE_ID, {})\n  \u002F\u002F block_until_agent_joins keeps the customer on a \"connecting\" screen until\n  \u002F\u002F the agent joins with the PIN - the live-help experience.\n  const session = space.session({ block_until_agent_joins: true })\n  await session.start(iframeSelector)\n  return session\n}\n","ts","",[95,152,153,162,168,208,218,268,274,280,316,335,344],{"__ignoreMap":150},[154,155,158],"span",{"class":156,"line":157},"line",1,[154,159,161],{"class":160},"sutJx","\u002F\u002F In an Assisted Space, Webfuse shows a 4-digit PIN inside the session\n",[154,163,165],{"class":156,"line":164},2,[154,166,167],{"class":160},"\u002F\u002F automatically - the customer reads it to the agent. No secret keys here.\n",[154,169,171,175,179,182,186,190,194,198,202,205],{"class":156,"line":170},3,[154,172,174],{"class":173},"sVHd0","export",[154,176,178],{"class":177},"sbsja"," async",[154,180,181],{"class":177}," function",[154,183,185],{"class":184},"sGLFI"," startCustomerSession",[154,187,189],{"class":188},"sP7_E","(",[154,191,193],{"class":192},"s99_P","iframeSelector",[154,195,197],{"class":196},"smGrS",":",[154,199,201],{"class":200},"sZMiF"," string",[154,203,204],{"class":188},")",[154,206,207],{"class":188}," {\n",[154,209,211,214],{"class":156,"line":210},4,[154,212,213],{"class":184},"  loadSurfly",[154,215,217],{"class":216},"skxfh","()\n",[154,219,221,224,228,231,234,238,241,244,246,249,251,254,257,260,262,265],{"class":156,"line":220},5,[154,222,223],{"class":177},"  const",[154,225,227],{"class":226},"s_hVV"," space",[154,229,230],{"class":196}," =",[154,232,233],{"class":173}," await",[154,235,237],{"class":236},"su5hD"," window",[154,239,240],{"class":188},".",[154,242,243],{"class":236},"webfuse",[154,245,240],{"class":188},[154,247,248],{"class":184},"initSpace",[154,250,189],{"class":216},[154,252,253],{"class":226},"WIDGET_KEY",[154,255,256],{"class":188},",",[154,258,259],{"class":226}," SPACE_ID",[154,261,256],{"class":188},[154,263,264],{"class":188}," {}",[154,266,267],{"class":216},")\n",[154,269,271],{"class":156,"line":270},6,[154,272,273],{"class":160},"  \u002F\u002F block_until_agent_joins keeps the customer on a \"connecting\" screen until\n",[154,275,277],{"class":156,"line":276},7,[154,278,279],{"class":160},"  \u002F\u002F the agent joins with the PIN - the live-help experience.\n",[154,281,283,285,288,290,292,294,297,299,302,305,307,311,314],{"class":156,"line":282},8,[154,284,223],{"class":177},[154,286,287],{"class":226}," session",[154,289,230],{"class":196},[154,291,227],{"class":236},[154,293,240],{"class":188},[154,295,296],{"class":184},"session",[154,298,189],{"class":216},[154,300,301],{"class":188},"{",[154,303,304],{"class":216}," block_until_agent_joins",[154,306,197],{"class":188},[154,308,310],{"class":309},"syTEX"," true",[154,312,313],{"class":188}," }",[154,315,267],{"class":216},[154,317,319,322,324,326,329,331,333],{"class":156,"line":318},9,[154,320,321],{"class":173},"  await",[154,323,287],{"class":236},[154,325,240],{"class":188},[154,327,328],{"class":184},"start",[154,330,189],{"class":216},[154,332,193],{"class":236},[154,334,267],{"class":216},[154,336,338,341],{"class":156,"line":337},10,[154,339,340],{"class":173},"  return",[154,342,343],{"class":236}," session\n",[154,345,347],{"class":156,"line":346},11,[154,348,349],{"class":188},"}\n",[14,351,352,355,356,359],{},[32,353,354],{},"The agent side is a PIN box plus a Supabase Edge Function."," The PIN POSTs to the function, which finds the active session whose ",[95,357,358],{},"pin"," matches via REST, signs a short-lived Magic Link so the agent joins authenticated and as host, and returns a join URL. The agent's iframe loads that URL and lands in the same session. Choosing a Supabase Edge Function wasn't a preference - it's the one server runtime a Lovable app already ships with, so the integration adds nothing to the prospect's deployment story.",[14,361,362],{},"The judgment calls are where the design earns its keep.",[14,364,365,368,369,371],{},[32,366,367],{},"PIN, not a passed link."," Each customer has their own session and PIN, and the agent's lookup targets exactly one ",[95,370,115],{},". Concurrent sessions just work - there's no shared state to collide. The cost is honest: a 4-digit PIN is only 10,000 combinations, so the join function must be authenticated and rate-limited before production. I called that out in the hand-off's hardening section rather than letting it read as a finished security model.",[14,373,374,381,382,384,385,388],{},[32,375,376,377,380],{},"One ",[95,378,379],{},"core.ts",", two runtimes - so a Lovable builder can actually run it."," The PIN-to-join logic lives in a single import-free ",[95,383,379],{},". ",[95,386,387],{},"fetch"," is global in Deno, Node 18+, and Bun, so the file needs nothing from any runtime. The only two things that differ between environments - how the JWT is signed, and where env comes from - are passed in as parameters.",[145,390,392],{"className":147,"code":391,"language":149,"meta":150,"style":150},"\u002F\u002F This file imports nothing. The only runtime difference - how a Magic Link\n\u002F\u002F (HS256 JWT) is signed - is injected as `signMagicLink`; env is a plain object.\nexport async function resolveJoin(pin: string, env: JoinEnv, signMagicLink: Signer): Promise\u003CJoinResult> {\n  const headers = { Authorization: `Token ${env.WEBFUSE_SPACE_REST_KEY ?? ''}` }  \u002F\u002F Token, not Bearer\n\n  \u002F\u002F 1. Find the active session by PIN.\n  const sres = await fetch(`${apiBase}\u002Fspaces\u002F${spaceId}\u002Fsessions\u002F?active=true`, { headers })\n  const sessions: any[] = Array.isArray(payload) ? payload : (payload.results ?? [])\n  const match = sessions.find((s) => String(s.pin) === String(pin))\n  if (!match) throw new HttpError(404, 'No active session for that PIN. Ask the customer to confirm it.')\n\n  \u002F\u002F 2. Resolve the Space link; normalise the trailing slash so we don't emit `…\u002F\u002F?…`.\n  const base = String(space.link).replace(\u002F\\\u002F+$\u002F, '')\n\n  \u002F\u002F 3. Sign the Magic Link (host) and build the join URL.\n  const claims = { space_id: spaceId, name: env.WEBFUSE_AGENT_NAME ?? 'Agent', can_host: true }\n  const jwt = await signMagicLink(claims, env.WEBFUSE_MAGIC_LINK_KEY ?? '')\n  return {\n    url: `${base}\u002F?magic_link=${encodeURIComponent(jwt)}&session_id=${encodeURIComponent(match.session_id)}`,\n    session_id: match.session_id,\n  }\n}\n",[95,393,394,399,404,462,509,515,520,571,629,682,725,729,735,788,793,799,854,888,895,952,968,974],{"__ignoreMap":150},[154,395,396],{"class":156,"line":157},[154,397,398],{"class":160},"\u002F\u002F This file imports nothing. The only runtime difference - how a Magic Link\n",[154,400,401],{"class":156,"line":164},[154,402,403],{"class":160},"\u002F\u002F (HS256 JWT) is signed - is injected as `signMagicLink`; env is a plain object.\n",[154,405,406,408,410,412,415,417,419,421,423,425,428,430,434,436,439,441,444,446,448,451,454,457,460],{"class":156,"line":170},[154,407,174],{"class":173},[154,409,178],{"class":177},[154,411,181],{"class":177},[154,413,414],{"class":184}," resolveJoin",[154,416,189],{"class":188},[154,418,358],{"class":192},[154,420,197],{"class":196},[154,422,201],{"class":200},[154,424,256],{"class":188},[154,426,427],{"class":192}," env",[154,429,197],{"class":196},[154,431,433],{"class":432},"sbgvK"," JoinEnv",[154,435,256],{"class":188},[154,437,438],{"class":192}," signMagicLink",[154,440,197],{"class":196},[154,442,443],{"class":432}," Signer",[154,445,204],{"class":188},[154,447,197],{"class":196},[154,449,450],{"class":432}," Promise",[154,452,453],{"class":188},"\u003C",[154,455,456],{"class":432},"JoinResult",[154,458,459],{"class":188},">",[154,461,207],{"class":188},[154,463,464,466,469,471,474,477,479,483,487,490,493,495,498,501,504,506],{"class":156,"line":210},[154,465,223],{"class":177},[154,467,468],{"class":226}," headers",[154,470,230],{"class":196},[154,472,473],{"class":188}," {",[154,475,476],{"class":216}," Authorization",[154,478,197],{"class":188},[154,480,482],{"class":481},"sjJ54"," `",[154,484,486],{"class":485},"s_sjI","Token ",[154,488,489],{"class":481},"${",[154,491,492],{"class":236},"env",[154,494,240],{"class":481},[154,496,497],{"class":226},"WEBFUSE_SPACE_REST_KEY",[154,499,500],{"class":196}," ??",[154,502,503],{"class":481}," ''}`",[154,505,313],{"class":188},[154,507,508],{"class":160},"  \u002F\u002F Token, not Bearer\n",[154,510,511],{"class":156,"line":220},[154,512,514],{"emptyLinePlaceholder":513},true,"\n",[154,516,517],{"class":156,"line":270},[154,518,519],{"class":160},"  \u002F\u002F 1. Find the active session by PIN.\n",[154,521,522,524,527,529,531,534,536,539,542,545,548,550,553,555,558,561,563,565,567,569],{"class":156,"line":276},[154,523,223],{"class":177},[154,525,526],{"class":226}," sres",[154,528,230],{"class":196},[154,530,233],{"class":173},[154,532,533],{"class":184}," fetch",[154,535,189],{"class":216},[154,537,538],{"class":481},"`${",[154,540,541],{"class":236},"apiBase",[154,543,544],{"class":481},"}",[154,546,547],{"class":485},"\u002Fspaces\u002F",[154,549,489],{"class":481},[154,551,552],{"class":236},"spaceId",[154,554,544],{"class":481},[154,556,557],{"class":485},"\u002Fsessions\u002F?active=true",[154,559,560],{"class":481},"`",[154,562,256],{"class":188},[154,564,473],{"class":188},[154,566,468],{"class":236},[154,568,313],{"class":188},[154,570,267],{"class":216},[154,572,573,575,578,580,583,586,589,592,594,597,599,602,605,608,611,614,617,619,621,624,626],{"class":156,"line":282},[154,574,223],{"class":177},[154,576,577],{"class":226}," sessions",[154,579,197],{"class":196},[154,581,582],{"class":200}," any",[154,584,585],{"class":216},"[] ",[154,587,588],{"class":196},"=",[154,590,591],{"class":236}," Array",[154,593,240],{"class":188},[154,595,596],{"class":184},"isArray",[154,598,189],{"class":216},[154,600,601],{"class":236},"payload",[154,603,604],{"class":216},") ",[154,606,607],{"class":196},"?",[154,609,610],{"class":236}," payload",[154,612,613],{"class":196}," :",[154,615,616],{"class":216}," (",[154,618,601],{"class":236},[154,620,240],{"class":188},[154,622,623],{"class":236},"results",[154,625,500],{"class":196},[154,627,628],{"class":216}," [])\n",[154,630,631,633,636,638,640,642,645,647,649,652,654,657,660,662,664,666,668,670,673,675,677,679],{"class":156,"line":318},[154,632,223],{"class":177},[154,634,635],{"class":226}," match",[154,637,230],{"class":196},[154,639,577],{"class":236},[154,641,240],{"class":188},[154,643,644],{"class":184},"find",[154,646,189],{"class":216},[154,648,189],{"class":188},[154,650,651],{"class":192},"s",[154,653,204],{"class":188},[154,655,656],{"class":177}," =>",[154,658,659],{"class":184}," String",[154,661,189],{"class":216},[154,663,651],{"class":236},[154,665,240],{"class":188},[154,667,358],{"class":236},[154,669,604],{"class":216},[154,671,672],{"class":196},"===",[154,674,659],{"class":184},[154,676,189],{"class":216},[154,678,358],{"class":236},[154,680,681],{"class":216},"))\n",[154,683,684,687,689,692,695,697,700,703,706,708,712,714,717,720,723],{"class":156,"line":337},[154,685,686],{"class":173},"  if",[154,688,616],{"class":216},[154,690,691],{"class":196},"!",[154,693,694],{"class":236},"match",[154,696,604],{"class":216},[154,698,699],{"class":173},"throw",[154,701,702],{"class":196}," new",[154,704,705],{"class":184}," HttpError",[154,707,189],{"class":216},[154,709,711],{"class":710},"srdBf","404",[154,713,256],{"class":188},[154,715,716],{"class":481}," '",[154,718,719],{"class":485},"No active session for that PIN. Ask the customer to confirm it.",[154,721,722],{"class":481},"'",[154,724,267],{"class":216},[154,726,727],{"class":156,"line":346},[154,728,514],{"emptyLinePlaceholder":513},[154,730,732],{"class":156,"line":731},12,[154,733,734],{"class":160},"  \u002F\u002F 2. Resolve the Space link; normalise the trailing slash so we don't emit `…\u002F\u002F?…`.\n",[154,736,738,740,743,745,747,749,752,754,757,759,761,764,766,769,773,776,779,781,783,786],{"class":156,"line":737},13,[154,739,223],{"class":177},[154,741,742],{"class":226}," base",[154,744,230],{"class":196},[154,746,659],{"class":184},[154,748,189],{"class":216},[154,750,751],{"class":236},"space",[154,753,240],{"class":188},[154,755,756],{"class":236},"link",[154,758,204],{"class":216},[154,760,240],{"class":188},[154,762,763],{"class":184},"replace",[154,765,189],{"class":216},[154,767,768],{"class":481},"\u002F",[154,770,772],{"class":771},"sjYin","\\\u002F",[154,774,775],{"class":196},"+",[154,777,778],{"class":173},"$",[154,780,768],{"class":481},[154,782,256],{"class":188},[154,784,785],{"class":481}," ''",[154,787,267],{"class":216},[154,789,791],{"class":156,"line":790},14,[154,792,514],{"emptyLinePlaceholder":513},[154,794,796],{"class":156,"line":795},15,[154,797,798],{"class":160},"  \u002F\u002F 3. Sign the Magic Link (host) and build the join URL.\n",[154,800,802,804,807,809,811,814,816,819,821,824,826,828,830,833,835,837,840,842,844,847,849,851],{"class":156,"line":801},16,[154,803,223],{"class":177},[154,805,806],{"class":226}," claims",[154,808,230],{"class":196},[154,810,473],{"class":188},[154,812,813],{"class":216}," space_id",[154,815,197],{"class":188},[154,817,818],{"class":236}," spaceId",[154,820,256],{"class":188},[154,822,823],{"class":216}," name",[154,825,197],{"class":188},[154,827,427],{"class":236},[154,829,240],{"class":188},[154,831,832],{"class":226},"WEBFUSE_AGENT_NAME",[154,834,500],{"class":196},[154,836,716],{"class":481},[154,838,839],{"class":485},"Agent",[154,841,722],{"class":481},[154,843,256],{"class":188},[154,845,846],{"class":216}," can_host",[154,848,197],{"class":188},[154,850,310],{"class":309},[154,852,853],{"class":188}," }\n",[154,855,857,859,862,864,866,868,870,873,875,877,879,882,884,886],{"class":156,"line":856},17,[154,858,223],{"class":177},[154,860,861],{"class":226}," jwt",[154,863,230],{"class":196},[154,865,233],{"class":173},[154,867,438],{"class":184},[154,869,189],{"class":216},[154,871,872],{"class":236},"claims",[154,874,256],{"class":188},[154,876,427],{"class":236},[154,878,240],{"class":188},[154,880,881],{"class":226},"WEBFUSE_MAGIC_LINK_KEY",[154,883,500],{"class":196},[154,885,785],{"class":481},[154,887,267],{"class":216},[154,889,891,893],{"class":156,"line":890},18,[154,892,340],{"class":173},[154,894,207],{"class":188},[154,896,898,901,903,906,909,911,914,916,919,922,925,927,929,932,934,936,938,940,942,944,946,949],{"class":156,"line":897},19,[154,899,900],{"class":216},"    url",[154,902,197],{"class":188},[154,904,905],{"class":481}," `${",[154,907,908],{"class":236},"base",[154,910,544],{"class":481},[154,912,913],{"class":485},"\u002F?magic_link=",[154,915,489],{"class":481},[154,917,918],{"class":184},"encodeURIComponent",[154,920,189],{"class":921},"sfo-9",[154,923,924],{"class":236},"jwt",[154,926,204],{"class":921},[154,928,544],{"class":481},[154,930,931],{"class":485},"&session_id=",[154,933,489],{"class":481},[154,935,918],{"class":184},[154,937,189],{"class":921},[154,939,694],{"class":236},[154,941,240],{"class":481},[154,943,115],{"class":236},[154,945,204],{"class":921},[154,947,948],{"class":481},"}`",[154,950,951],{"class":188},",\n",[154,953,955,958,960,962,964,966],{"class":156,"line":954},20,[154,956,957],{"class":216},"    session_id",[154,959,197],{"class":188},[154,961,635],{"class":236},[154,963,240],{"class":188},[154,965,115],{"class":236},[154,967,951],{"class":188},[154,969,971],{"class":156,"line":970},21,[154,972,973],{"class":188},"  }\n",[154,975,977],{"class":156,"line":976},22,[154,978,349],{"class":188},[14,980,981,982,985,986,989],{},"In production that core runs inside the Supabase Edge Function (Deno). In local dev it runs inside a Vite middleware that injects Node's ",[95,983,984],{},"jose"," signer and reads the same secret file layout the deploy uses - so ",[95,987,988],{},"bun run dev"," alone runs the entire demo, with no Supabase CLI and no Docker:",[145,991,993],{"className":147,"code":992,"language":149,"meta":150,"style":150},"\u002F\u002F Serves POST \u002Fapi\u002Fjoin from INSIDE the Vite dev server, using the SAME core.ts\n\u002F\u002F as the Supabase Edge Function - so `bun run dev` alone runs the demo.\nconst sign = (claims, secret) =>\n  new SignJWT(claims).setProtectedHeader({ alg: 'HS256' })\n    .setExpirationTime(MAGIC_LINK_TTL).sign(new TextEncoder().encode(secret))\n\nexport function devJoin(): Plugin {\n  return { name: 'dev-join', apply: 'serve', configureServer(server) {\n    const env = loadEnvFile('.\u002Fsupabase\u002F.env.local')\n    server.middlewares.use('\u002Fapi\u002Fjoin', (req, res) => {\n      \u002F* ...parse {pin}... *\u002F\n      send(200, await resolveJoin(String(pin), env, sign))\n    })\n  }}\n}\n",[95,994,995,1000,1005,1030,1066,1105,1109,1127,1173,1196,1238,1243,1280,1287,1292],{"__ignoreMap":150},[154,996,997],{"class":156,"line":157},[154,998,999],{"class":160},"\u002F\u002F Serves POST \u002Fapi\u002Fjoin from INSIDE the Vite dev server, using the SAME core.ts\n",[154,1001,1002],{"class":156,"line":164},[154,1003,1004],{"class":160},"\u002F\u002F as the Supabase Edge Function - so `bun run dev` alone runs the demo.\n",[154,1006,1007,1010,1014,1016,1018,1020,1022,1025,1027],{"class":156,"line":170},[154,1008,1009],{"class":177},"const",[154,1011,1013],{"class":1012},"sfCm-"," sign",[154,1015,230],{"class":196},[154,1017,616],{"class":188},[154,1019,872],{"class":192},[154,1021,256],{"class":188},[154,1023,1024],{"class":192}," secret",[154,1026,204],{"class":188},[154,1028,1029],{"class":177}," =>\n",[154,1031,1032,1035,1038,1041,1043,1046,1048,1050,1053,1055,1057,1060,1062,1064],{"class":156,"line":210},[154,1033,1034],{"class":196},"  new",[154,1036,1037],{"class":184}," SignJWT",[154,1039,1040],{"class":236},"(claims)",[154,1042,240],{"class":188},[154,1044,1045],{"class":184},"setProtectedHeader",[154,1047,189],{"class":236},[154,1049,301],{"class":188},[154,1051,1052],{"class":216}," alg",[154,1054,197],{"class":188},[154,1056,716],{"class":481},[154,1058,1059],{"class":485},"HS256",[154,1061,722],{"class":481},[154,1063,313],{"class":188},[154,1065,267],{"class":236},[154,1067,1068,1071,1074,1076,1079,1081,1083,1086,1088,1091,1094,1097,1099,1102],{"class":156,"line":220},[154,1069,1070],{"class":188},"    .",[154,1072,1073],{"class":184},"setExpirationTime",[154,1075,189],{"class":236},[154,1077,1078],{"class":226},"MAGIC_LINK_TTL",[154,1080,204],{"class":236},[154,1082,240],{"class":188},[154,1084,1085],{"class":184},"sign",[154,1087,189],{"class":236},[154,1089,1090],{"class":196},"new",[154,1092,1093],{"class":184}," TextEncoder",[154,1095,1096],{"class":236},"()",[154,1098,240],{"class":188},[154,1100,1101],{"class":184},"encode",[154,1103,1104],{"class":236},"(secret))\n",[154,1106,1107],{"class":156,"line":270},[154,1108,514],{"emptyLinePlaceholder":513},[154,1110,1111,1113,1115,1118,1120,1122,1125],{"class":156,"line":276},[154,1112,174],{"class":173},[154,1114,181],{"class":177},[154,1116,1117],{"class":184}," devJoin",[154,1119,1096],{"class":188},[154,1121,197],{"class":196},[154,1123,1124],{"class":432}," Plugin",[154,1126,207],{"class":188},[154,1128,1129,1131,1133,1135,1137,1139,1142,1144,1146,1149,1151,1153,1156,1158,1160,1164,1166,1169,1171],{"class":156,"line":282},[154,1130,340],{"class":173},[154,1132,473],{"class":188},[154,1134,823],{"class":216},[154,1136,197],{"class":188},[154,1138,716],{"class":481},[154,1140,1141],{"class":485},"dev-join",[154,1143,722],{"class":481},[154,1145,256],{"class":188},[154,1147,1148],{"class":216}," apply",[154,1150,197],{"class":188},[154,1152,716],{"class":481},[154,1154,1155],{"class":485},"serve",[154,1157,722],{"class":481},[154,1159,256],{"class":188},[154,1161,1163],{"class":1162},"sVXei"," configureServer",[154,1165,189],{"class":188},[154,1167,1168],{"class":192},"server",[154,1170,204],{"class":188},[154,1172,207],{"class":188},[154,1174,1175,1178,1180,1182,1185,1187,1189,1192,1194],{"class":156,"line":318},[154,1176,1177],{"class":177},"    const",[154,1179,427],{"class":226},[154,1181,230],{"class":196},[154,1183,1184],{"class":184}," loadEnvFile",[154,1186,189],{"class":216},[154,1188,722],{"class":481},[154,1190,1191],{"class":485},".\u002Fsupabase\u002F.env.local",[154,1193,722],{"class":481},[154,1195,267],{"class":216},[154,1197,1198,1201,1203,1206,1208,1211,1213,1215,1218,1220,1222,1224,1227,1229,1232,1234,1236],{"class":156,"line":337},[154,1199,1200],{"class":236},"    server",[154,1202,240],{"class":188},[154,1204,1205],{"class":236},"middlewares",[154,1207,240],{"class":188},[154,1209,1210],{"class":184},"use",[154,1212,189],{"class":216},[154,1214,722],{"class":481},[154,1216,1217],{"class":485},"\u002Fapi\u002Fjoin",[154,1219,722],{"class":481},[154,1221,256],{"class":188},[154,1223,616],{"class":188},[154,1225,1226],{"class":192},"req",[154,1228,256],{"class":188},[154,1230,1231],{"class":192}," res",[154,1233,204],{"class":188},[154,1235,656],{"class":177},[154,1237,207],{"class":188},[154,1239,1240],{"class":156,"line":346},[154,1241,1242],{"class":160},"      \u002F* ...parse {pin}... *\u002F\n",[154,1244,1245,1248,1250,1253,1255,1257,1259,1261,1264,1266,1268,1270,1272,1274,1276,1278],{"class":156,"line":731},[154,1246,1247],{"class":184},"      send",[154,1249,189],{"class":216},[154,1251,1252],{"class":710},"200",[154,1254,256],{"class":188},[154,1256,233],{"class":173},[154,1258,414],{"class":184},[154,1260,189],{"class":216},[154,1262,1263],{"class":184},"String",[154,1265,189],{"class":216},[154,1267,358],{"class":236},[154,1269,204],{"class":216},[154,1271,256],{"class":188},[154,1273,427],{"class":236},[154,1275,256],{"class":188},[154,1277,1013],{"class":236},[154,1279,681],{"class":216},[154,1281,1282,1285],{"class":156,"line":737},[154,1283,1284],{"class":188},"    }",[154,1286,267],{"class":216},[154,1288,1289],{"class":156,"line":790},[154,1290,1291],{"class":188},"  }}\n",[154,1293,1294],{"class":156,"line":795},[154,1295,349],{"class":188},[14,1297,1298],{},"What I test locally is byte-for-byte the logic that ships to Supabase. The environments can't drift, because there's one copy of the logic and the differences are parameters.",[14,1300,1301,1304,1305,1308,1309,1311,1312,1315],{},[32,1302,1303],{},"Secrets split by file, mirroring the Lovable deploy."," ",[95,1306,1307],{},".env.local"," holds only public ",[95,1310,97],{}," values; ",[95,1313,1314],{},"supabase\u002F.env.local"," holds the secret keys and is never imported by the frontend. That is a one-to-one mirror of Lovable public env versus Supabase Secrets, so running the reference teaches the prospect the exact production shape they'll use.",[14,1317,1318,1321,1322,1325,1326,1329],{},[32,1319,1320],{},"Magic Link TTL is 5 minutes."," The token is signed and used within seconds; ",[95,1323,1324],{},"exp"," is checked only at join time and does ",[37,1327,1328],{},"not"," limit session length. Short means a smaller window if the URL leaks. I documented that so nobody \"fixes\" it by lengthening it.",[14,1331,1332,1335,1336,1339,1340,1343,1344,1347],{},[32,1333,1334],{},"A real teardown bug, documented at the fix."," Webfuse tears down its own iframe and modal synchronously inside the ",[95,1337,1338],{},"session_ended"," event. Clearing the stage in that same tick detaches nodes mid-teardown and makes the platform's ",[95,1341,1342],{},"apiframe.js"," throw on ",[95,1345,1346],{},"removeChild",", which hangs the modal. Deferring cleanup one tick lets it finish first.",[145,1349,1353],{"className":1350,"code":1351,"language":1352,"meta":150,"style":150},"language-tsx shiki shiki-themes material-theme-lighter github-light github-dark","session.on('session_ended', () => {\n  setStarted(false); setBusy(false)\n  \u002F\u002F Defer DOM cleanup to the next tick. Let Webfuse finish its own synchronous\n  \u002F\u002F teardown, then clean up, or apiframe.js throws on removeChild (null parent).\n  setTimeout(() => { if (stageRef.current) stageRef.current.innerHTML = '' }, 0)\n})\n","tsx",[95,1354,1355,1381,1405,1410,1415,1466],{"__ignoreMap":150},[154,1356,1357,1359,1361,1364,1366,1368,1370,1372,1374,1377,1379],{"class":156,"line":157},[154,1358,296],{"class":236},[154,1360,240],{"class":188},[154,1362,1363],{"class":184},"on",[154,1365,189],{"class":236},[154,1367,722],{"class":481},[154,1369,1338],{"class":485},[154,1371,722],{"class":481},[154,1373,256],{"class":188},[154,1375,1376],{"class":188}," ()",[154,1378,656],{"class":177},[154,1380,207],{"class":188},[154,1382,1383,1386,1388,1391,1393,1396,1399,1401,1403],{"class":156,"line":164},[154,1384,1385],{"class":184},"  setStarted",[154,1387,189],{"class":216},[154,1389,1390],{"class":309},"false",[154,1392,204],{"class":216},[154,1394,1395],{"class":188},";",[154,1397,1398],{"class":184}," setBusy",[154,1400,189],{"class":216},[154,1402,1390],{"class":309},[154,1404,267],{"class":216},[154,1406,1407],{"class":156,"line":170},[154,1408,1409],{"class":160},"  \u002F\u002F Defer DOM cleanup to the next tick. Let Webfuse finish its own synchronous\n",[154,1411,1412],{"class":156,"line":210},[154,1413,1414],{"class":160},"  \u002F\u002F teardown, then clean up, or apiframe.js throws on removeChild (null parent).\n",[154,1416,1417,1420,1422,1424,1426,1428,1431,1433,1436,1438,1441,1443,1445,1447,1449,1451,1454,1456,1458,1461,1464],{"class":156,"line":220},[154,1418,1419],{"class":184},"  setTimeout",[154,1421,189],{"class":216},[154,1423,1096],{"class":188},[154,1425,656],{"class":177},[154,1427,473],{"class":188},[154,1429,1430],{"class":173}," if",[154,1432,616],{"class":216},[154,1434,1435],{"class":236},"stageRef",[154,1437,240],{"class":188},[154,1439,1440],{"class":236},"current",[154,1442,604],{"class":216},[154,1444,1435],{"class":236},[154,1446,240],{"class":188},[154,1448,1440],{"class":236},[154,1450,240],{"class":188},[154,1452,1453],{"class":236},"innerHTML",[154,1455,230],{"class":196},[154,1457,785],{"class":481},[154,1459,1460],{"class":188}," },",[154,1462,1463],{"class":710}," 0",[154,1465,267],{"class":216},[154,1467,1468,1470],{"class":156,"line":270},[154,1469,544],{"class":188},[154,1471,267],{"class":236},[14,1473,1474,1475,1478],{},"One note on the reference default. The join endpoint ships with Supabase JWT verification off, because for a local reference it only takes a PIN and returns a URL - auth is enforced by the secret keys ",[37,1476,1477],{},"inside"," the function. The hand-off doc is explicit that this is dev-only: before going live, turn Supabase JWT verification back on, require an authenticated agent, and rate-limit the endpoint. A 10,000-combination PIN behind an unauthenticated function is not a production posture, and the docs lead with that.",[45,1480,1481],{"id":623},"Results",[53,1483,1484,1490,1496,1505,1514,1520,1526,1532],{},[56,1485,1486,1489],{},[32,1487,1488],{},"Adds co-browse to a Lovable app with nothing new in the stack"," - a React frontend plus one Supabase Edge Function, the two things a Lovable project already has.",[56,1491,1492,1495],{},[32,1493,1494],{},"The customer side needs no backend at all"," - public Widget Key only, so it's pure Lovable-generated React.",[56,1497,1498,1501,1502,1504],{},[32,1499,1500],{},"One command, zero Docker"," to run the full demo end-to-end - ",[95,1503,988],{}," - because the join backend runs as Vite middleware instead of the Supabase CLI.",[56,1506,1507,1510,1511,1513],{},[32,1508,1509],{},"Logic drift between local and production: zero."," A single shared ",[95,1512,379],{},"; only the JWT signer and the env source differ per runtime.",[56,1515,1516,1519],{},[32,1517,1518],{},"Three keys, one public and two secret"," - and that split maps exactly onto Lovable public env versus Supabase Secrets.",[56,1521,1522,1525],{},[32,1523,1524],{},"Magic Link TTL: 5 minutes."," Signed and consumed within seconds; it never gates session length.",[56,1527,1528,1531],{},[32,1529,1530],{},"PIN space: 10,000 combinations"," - the reason authentication and rate limiting are mandatory before production, called out in the hand-off rather than left implicit.",[56,1533,1534,1535,1538],{},"Deliverable: ",[32,1536,1537],{},"one reference repo plus two hand-off docs"," - a paste-into-Lovable build spec for a non-engineer, and a stack-agnostic integration guide.",[45,1540,1542],{"id":1541},"the-principle","The principle",[14,1544,1545],{},"Integrating a platform into a no-code stack like Lovable is mostly about respecting the surface it already gives you - a React frontend and a Supabase backend - and drawing the trust boundary exactly where the stack already draws it. Keep the secrets on the Edge Function, keep the customer side backend-free, and make the join logic run identically in dev and production. Then the hard part isn't code the builder can't write - it's a spec they can paste into a chat box.",[1547,1548,1549],"style",{},"html pre.shiki code .sutJx, html code.shiki .sutJx{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#6A737D;--shiki-default-font-style:inherit;--shiki-dark:#6A737D;--shiki-dark-font-style:inherit}html pre.shiki code .sVHd0, html code.shiki .sVHd0{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#D73A49;--shiki-default-font-style:inherit;--shiki-dark:#F97583;--shiki-dark-font-style:inherit}html pre.shiki code .sbsja, html code.shiki .sbsja{--shiki-light:#9C3EDA;--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sGLFI, html code.shiki .sGLFI{--shiki-light:#6182B8;--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sP7_E, html code.shiki .sP7_E{--shiki-light:#39ADB5;--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s99_P, html code.shiki .s99_P{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#E36209;--shiki-default-font-style:inherit;--shiki-dark:#FFAB70;--shiki-dark-font-style:inherit}html pre.shiki code .smGrS, html code.shiki .smGrS{--shiki-light:#39ADB5;--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sZMiF, html code.shiki .sZMiF{--shiki-light:#E2931D;--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .skxfh, html code.shiki .skxfh{--shiki-light:#E53935;--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s_hVV, html code.shiki .s_hVV{--shiki-light:#90A4AE;--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .su5hD, html code.shiki .su5hD{--shiki-light:#90A4AE;--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .syTEX, html code.shiki .syTEX{--shiki-light:#FF5370;--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sbgvK, html code.shiki .sbgvK{--shiki-light:#E2931D;--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sjJ54, html code.shiki .sjJ54{--shiki-light:#39ADB5;--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .s_sjI, html code.shiki .s_sjI{--shiki-light:#91B859;--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .srdBf, html code.shiki .srdBf{--shiki-light:#F76D47;--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sjYin, html code.shiki .sjYin{--shiki-light:#90A4AE;--shiki-light-font-weight:inherit;--shiki-default:#22863A;--shiki-default-font-weight:bold;--shiki-dark:#85E89D;--shiki-dark-font-weight:bold}html pre.shiki code .sfo-9, html code.shiki .sfo-9{--shiki-light:#90A4AE;--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sfCm-, html code.shiki .sfCm-{--shiki-light:#90A4AE;--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVXei, html code.shiki .sVXei{--shiki-light:#E53935;--shiki-default:#6F42C1;--shiki-dark:#B392F0}",{"title":150,"searchDepth":164,"depth":164,"links":1551},[1552,1553,1554,1555],{"id":47,"depth":164,"text":48},{"id":125,"depth":164,"text":126},{"id":623,"depth":164,"text":1481},{"id":1541,"depth":164,"text":1542},"2026-07-14","A reference build for adding Webfuse co-browsing to a Lovable app - PIN-join over a video call, with the customer side needing no backend and the whole demo running on one command.","md",null,false,"case-study",{},"\u002Fwork\u002Fwebfuse-lovable-cobrowse-integration",{"title":5,"description":1557},"work\u002Fwebfuse-lovable-cobrowse-integration",[23,29,1567,1568,1569,1570],"Co-browse","Supabase","React","JWT","JBJzBcHRoLWH3vBk21NDPC2E3Yd27S4Hl8DJZP_JH0s",1785421333422]