// @requirements REQ-PRODUCT-CF-HOST import {demoStorageNote,portugueseDemoStorageNote} from './website-notice.mjs'; // @requirements REQ-PRODUCT-CONTRACT-ACTIONS import {contractActionOperationRef} from 'notabenda-cf'; import {contractContext} from './contract-context.mjs'; import {adminPresentation} from './admin-presentation.mjs'; // @requirements REQ-PRODUCT-DEMO-WORKSPACE import {demoExpired} from './demo-presentation.mjs'; import {markDemoResponse} from './demo-marker.mjs'; import {createSetupWorker} from 'notabenda-cf'; // @requirements REQ-PRODUCT-FORM-PERMISSIONS import {formPermissionStreamRef} from 'notabenda-cf'; import {permissionContext} from './permission-context.mjs'; import data from './dist/server-data.json' with {type:'json'}; const cookieBase='notabenda_demo'; const permissionPage=/^\/(?:(?:dark\/)?form-permissions(?:\/react)?|form-permissions\/astro(?:\/review)?)(?:\/(?:index\.html)?)?$/u; const contractPage=/^\/(?:dark\/)?contract-actions\/(withdrawal|cancellation)(?:\/(?:index\.html)?)?$/u; const orderPage=/^\/(?:dark\/)?orders\/(mixed|deferred|conditional)(?:\/(?:index\.html)?)?$/u; const isPage=path=>orderPage.test(path)||permissionPage.test(path)||contractPage.test(path)||/^\/(?:agreement|dark\/agreement|localization\/agreement|recorded-choices|processing-choices)(?:\/|$)/u.test(path); const admin=/^\/demo\/admin(?:\/|$)/u; const api=/^\/demo\/(?:orders\/(?:mixed|deferred|conditional)\/(?:quote|submit)|agreements\/(?:light|dark|pt)|(?:choices|processing)\/(?:head|record)|permissions\/(?:state|record)|contracts\/(?:withdrawal|cancellation))$/u; export const isDemoRoute=path=>isPage(path)||admin.test(path)||api.test(path); const token=()=>Array.from(crypto.getRandomValues(new Uint8Array(32)),b=>b.toString(16).padStart(2,'0')).join(''); const digest=async value=>Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(value))),b=>b.toString(16).padStart(2,'0')).join(''); export async function serveDemo(request,env,{protect,readBody}){ const url=new URL(request.url),path=url.pathname,cookieName=(url.protocol==='https:'?'__Host-':'')+cookieBase; const loopback=['127.0.0.1','localhost','[::1]'].includes(url.hostname); const secret=/^[a-f0-9]{64}$/u.test(env.ADMIN_KEY??'')?env.ADMIN_KEY:env.NOTABENDA_LOCAL_DEV_AUTH==='1'&&loopback?'notabenda-local-demo-signing-v1':undefined; const finish=(response,session,clear=false)=>{ if(response.status===410&&!api.test(path)&&request.method==='GET')response=demoExpired(); const headers=new Headers(protect(response).headers);headers.set('Vary','Cookie');headers.delete('Content-Length'); if(session||clear)headers.append('Set-Cookie',`${cookieName}=${session??''}; Path=/; HttpOnly; SameSite=Lax${clear?'; Max-Age=0':''}${url.protocol==='https:'?'; Secure':''}`); return new Response(response.body,{status:response.status,statusText:response.statusText,headers}); }; if(url.protocol!=='https:'&&!['127.0.0.1','localhost','[::1]'].includes(url.hostname))return finish(new Response('HTTPS is required.',{status:400})); if(!['GET','HEAD','POST'].includes(request.method))return finish(new Response('Method not allowed.',{status:405})); if(request.method==='POST'&&!api.test(path))return finish(new Response('Method not allowed.',{status:405})); let body; if(api.test(path)&&!/(?:head|state|quote)$/u.test(path)){ try{body=await readBody(request);}catch{return finish(Response.json({code:'invalid-request'},{status:400}));} }else if(request.method==='POST')return finish(new Response('Method not allowed.',{status:405})); if(!secret)return finish(new Response('Demo sessions are not configured.',{status:503})); const signingKey=await crypto.subtle.importKey('raw',new TextEncoder().encode(secret),{name:'HMAC',hash:'SHA-256'},false,['sign','verify']); const cookies=(request.headers.get('Cookie')??'').split(';').map(value=>value.trim()).filter(value=>value.startsWith(cookieName+'=')); let capability=cookies.length===1?cookies[0].slice(cookieName.length+1):undefined; let session; if(capability&&/^[a-f0-9]{64}\.[a-f0-9]{64}$/u.test(capability)){ const [candidate,signature]=capability.split('.');const bytes=Uint8Array.from(signature.match(/../gu),hex=>parseInt(hex,16)); if(await crypto.subtle.verify('HMAC',signingKey,bytes,new TextEncoder().encode(candidate)))session=candidate; } if(cookies.length&&!session)return finish(Response.json({code:'demo-expired'},{status:410}),undefined,true); if(!session&&(api.test(path)||request.method!=='GET'))return finish(Response.json({code:'demo-session-required'},{status:409})); const fresh=!session; if(fresh){const ip=request.headers.get('CF-Connecting-IP')??'local';if(!env.DEMO_PROVISION_LIMITER||(await env.DEMO_PROVISION_LIMITER.limit({key:await digest(ip)})).success!==true)return finish(new Response('Please wait a moment before starting another demo.',{status:429,headers:{'Retry-After':'60'}}));} session??=token();const generation=await digest(session); if(fresh){const signed=await crypto.subtle.sign('HMAC',signingKey,new TextEncoder().encode(session));capability=session+'.'+Array.from(new Uint8Array(signed),b=>b.toString(16).padStart(2,'0')).join('');} if(api.test(path)&&request.headers.get('X-Notabenda-Demo-Generation')!==generation)return finish(Response.json({code:'demo-generation-changed'},{status:409})); try{ // A different binding and class: no input can name or reach the operational WORKSPACE. const stub=env.DEMO_WORKSPACES.get(env.DEMO_WORKSPACES.idFromName('public-demo/'+generation)); const headers=new Headers(request.headers);headers.delete('Authorization');headers.delete('Content-Length');headers.set('X-Notabenda-Demo-Generation',generation); if(fresh){const provisionUrl=new URL('/demo/provision',url);const provision=await stub.fetch(new Request(provisionUrl,{method:'POST',headers}));if(!provision.ok)return finish(new Response('Demo workspace unavailable.',{status:503}));} if(!fresh&&(isPage(path)||['/demo/admin/setup','/demo/admin/setup/'].includes(path))){const sessionUrl=new URL('/demo/session',url);const current=await stub.fetch(new Request(sessionUrl,{headers}));if(!current.ok)return finish(current,undefined,current.status===410);} let response; if(isPage(path)){ response=await env.ASSETS.fetch(request); const assetHeaders=new Headers(response.headers);assetHeaders.set('Content-Security-Policy',"default-src 'none'; style-src 'self'; img-src 'self'; script-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"); let content=response.body;if(response.headers.get('Content-Type')?.includes('text/html')&&request.method!=='HEAD'){ const permissionEntry=permissionPage.test(path); const stream=permissionEntry?``:''; const contractRoute=contractPage.exec(path)?.[1],contract=contractRoute?``:''; content=(await response.text()).replace(/',`${stream}${contract}`); } response=new Response(content,{status:response.status,headers:assetHeaders}); }else if(['/demo/admin/setup','/demo/admin/setup/'].includes(path)){ response=await createSetupWorker({...adminPresentation('/demo/admin'),basePath:'/demo/admin/setup',authorize:()=>true,scriptUrl:'/assets/setup.mjs',stylesheetUrl:'/assets/notabenda.css'}).fetch(request,env); }else{ const forwarded=new Request(request.url,{method:request.method,headers,...(body?{body:JSON.stringify(body)}:{})});response=await stub.fetch(forwarded); } if(response.status===410){ return finish(response,undefined,true); } return finish(await markDemoResponse(response),capability); }catch{return finish(new Response('Demo workspace unavailable.',{status:503}));} }