@@ -6,6 +6,7 @@ import type { Express, Request, Response } from 'express';
66import type { LintArtifactRequest , LintArtifactResponse } from '@open-design/contracts' ;
77import {
88 PREVIEW_OBSERVABILITY_BRIDGE_MARKER ,
9+ buildPreviewBaseHrefBridge ,
910 buildPreviewObservabilityBridge ,
1011} from '@open-design/contracts/runtime/preview-observability' ;
1112import {
@@ -5446,6 +5447,7 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
54465447 projectId : string ,
54475448 ownerFilePath : string ,
54485449 scope : string ,
5450+ expiresAt : number ,
54495451 ) : string {
54505452 // Respect an artifact-authored base URL. Only generated documents without
54515453 // one need the containment base that keeps runtime-created relative URLs
@@ -5456,10 +5458,13 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
54565458 ? ''
54575459 : `${ encodeProjectPathForUrl ( ownerDir ) } /` ;
54585460 const baseTag = `<base href="/api/projects/${ encodeURIComponent ( projectId ) } `
5459- + `/preview/${ encodeURIComponent ( scope ) } /${ dirSuffix } ">` ;
5461+ + `/preview/${ encodeURIComponent ( scope ) } /${ dirSuffix } " data-od-project-preview-base>` ;
5462+ const baseHref = `/api/projects/${ encodeURIComponent ( projectId ) } `
5463+ + `/preview/${ encodeURIComponent ( scope ) } /${ dirSuffix } ` ;
5464+ const bridge = buildPreviewBaseHrefBridge ( { href : baseHref , expiresAt } ) ;
54605465 const head = / < h e a d \b [ ^ > ] * > / i;
5461- if ( head . test ( html ) ) return html . replace ( head , ( tag ) => `${ tag } ${ baseTag } ` ) ;
5462- return `${ baseTag } ${ html } ` ;
5466+ if ( head . test ( html ) ) return html . replace ( head , ( tag ) => `${ tag } ${ baseTag } ${ bridge } ` ) ;
5467+ return `${ baseTag } ${ bridge } ${ html } ` ;
54635468 }
54645469
54655470 function rewriteWorkspaceScopedHtmlAssetUrls (
@@ -5832,13 +5837,19 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
58325837 workspaceMemberId : requestContext . workspaceMemberId ,
58335838 } ,
58345839 ) ;
5840+ const expiresAt = projectPreviewScopes . expiresAt ( project . id , scope ) ;
5841+ if ( expiresAt === undefined ) {
5842+ sendApiError ( res , 503 , 'PREVIEW_SCOPE_NOT_FOUND' , 'preview scope not found' ) ;
5843+ return ;
5844+ }
58355845 /** @type {import('@open-design/contracts').ProjectPreviewUrlResponse } */
58365846 const body = {
58375847 url : `/api/projects/${ encodeURIComponent ( project . id ) } /preview/${ scope } /${ encodeProjectPathForUrl ( meta . name ) } ` ,
58385848 file : meta . name ,
58395849 csp : projectPreviewCsp ,
58405850 iframeSandbox : projectPreviewIframeSandbox ,
58415851 opaqueOrigin : true ,
5852+ expiresAt,
58425853 } ;
58435854 res . setHeader ( 'Cache-Control' , 'no-store' ) ;
58445855 res . json ( body ) ;
@@ -5853,6 +5864,61 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
58535864 }
58545865 } ) ;
58555866
5867+ app . post ( '/api/projects/:id/preview/:scope/renew' , async ( req , res ) => {
5868+ try {
5869+ const projectId = String ( req . params . id ?? '' ) ;
5870+ const scope = String ( req . params . scope ?? '' ) ;
5871+ // The scope is embedded in untrusted preview HTML. Requiring a custom
5872+ // header makes renewal a host-only operation: an opaque-origin iframe
5873+ // cannot set it without a CORS preflight, and this route grants no CORS.
5874+ if ( req . get ( 'x-od-preview-scope-renewal' ) !== '1' ) {
5875+ sendApiError ( res , 403 , 'FORBIDDEN' , 'preview scope renewal requires host authorization' ) ;
5876+ return ;
5877+ }
5878+ if ( ! previewScopeRe . test ( scope ) ) {
5879+ sendApiError ( res , 400 , 'BAD_REQUEST' , 'invalid preview scope' ) ;
5880+ return ;
5881+ }
5882+ const project = getProject ( db , projectId ) ;
5883+ if ( ! project ) {
5884+ sendApiError ( res , 404 , 'PROJECT_NOT_FOUND' , 'project not found' ) ;
5885+ return ;
5886+ }
5887+ const previewWorkspace = projectPreviewScopes . resolve ( project . id , scope ) ;
5888+ if ( previewWorkspace === undefined ) {
5889+ sendApiError ( res , 404 , 'PREVIEW_SCOPE_NOT_FOUND' , 'preview scope not found' ) ;
5890+ return ;
5891+ }
5892+ const authorityRequest = previewWorkspace
5893+ ? {
5894+ query : {
5895+ ...req . query ,
5896+ workspaceId : previewWorkspace . workspaceId ,
5897+ workspaceMemberId : previewWorkspace . workspaceMemberId ,
5898+ } ,
5899+ get : req . get . bind ( req ) ,
5900+ }
5901+ : req ;
5902+ if ( ! await authorizeProjectRequest (
5903+ authorityRequest ,
5904+ res ,
5905+ project . id ,
5906+ { mode : 'read' , allowNavigationQuery : true } ,
5907+ ) ) return ;
5908+ const expiresAt = projectPreviewScopes . renew ( project . id , scope ) ;
5909+ if ( expiresAt === undefined ) {
5910+ sendApiError ( res , 404 , 'PREVIEW_SCOPE_NOT_FOUND' , 'preview scope not found' ) ;
5911+ return ;
5912+ }
5913+ /** @type {import('@open-design/contracts').ProjectPreviewScopeRenewResponse } */
5914+ const body = { expiresAt } ;
5915+ res . setHeader ( 'Cache-Control' , 'no-store' ) ;
5916+ res . json ( body ) ;
5917+ } catch ( err : any ) {
5918+ sendApiError ( res , 400 , 'BAD_REQUEST' , String ( err ) ) ;
5919+ }
5920+ } ) ;
5921+
58565922 app . get ( / ^ \/ a p i \/ p r o j e c t s \/ ( [ ^ / ] + ) \/ t e x t - p r e v i e w \/ ( .+ ) $ / u, async ( req , res ) => {
58575923 let handle : import ( 'fs/promises' ) . FileHandle | null = null ;
58585924 try {
@@ -6082,11 +6148,14 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
60826148 }
60836149 : null ;
60846150 const scope = projectPreviewScopes . mint ( projectId , previewWorkspace ) ;
6151+ const expiresAt = projectPreviewScopes . expiresAt ( projectId , scope ) ;
6152+ if ( expiresAt === undefined ) return html ;
60856153 return injectProjectPreviewBase (
60866154 html ,
60876155 projectId ,
60886156 relPath ,
60896157 scope ,
6158+ expiresAt ,
60906159 ) ;
60916160 } ,
60926161 true , // revalidate: emit ETag/Last-Modified so covers/preview/export reuse cached assets
0 commit comments