diff --git a/src/server/handlers/preview/markdown-preview.handler.test.ts b/src/server/handlers/preview/markdown-preview.handler.test.ts index 39a9e55775..3aa5572474 100644 --- a/src/server/handlers/preview/markdown-preview.handler.test.ts +++ b/src/server/handlers/preview/markdown-preview.handler.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertNotEquals } from "#veryfront/testing/assert.ts"; import type { HandlerContext } from "../types.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { MarkdownPreviewHandler } from "./markdown-preview.handler.ts"; @@ -99,6 +99,73 @@ Deno.test("MarkdownPreviewHandler fails closed before shared source reads", asyn assertEquals(reads, 0); }); +Deno.test("MarkdownPreviewHandler renders shared markdown once the host grants execution", async () => { + const originalFetch = globalThis.fetch; + let contentReads = 0; + globalThis.fetch = (input) => { + const url = String(input); + if (url.includes("/git/trees/")) { + return Promise.resolve(Response.json({ + sha: "tree", + tree: [{ path: "README.md", mode: "100644", type: "blob", sha: "readme", size: 7 }], + truncated: false, + })); + } + if (url.includes("/contents/README.md")) { + contentReads += 1; + const content = "# Hello"; + return Promise.resolve(Response.json({ + type: "file", + name: "README.md", + path: "README.md", + sha: "readme", + size: content.length, + content: btoa(content), + encoding: "base64", + download_url: null, + })); + } + return Promise.resolve(new Response("Not found", { status: 404 })); + }; + + const github = new GitHubFSAdapter({ + type: "github", + projectDir: "/project", + github: { token: "token", owner: "owner", repo: "repo" }, + }); + const fs = new FSAdapterWrapper(github); + try { + const result = await new MarkdownPreviewHandler().handle( + new Request("https://tenant.example/README.md"), + makeCtx({ + isLocalProject: false, + allowHostProjectCodeExecution: true, + requestContext: { mode: "preview" } as HandlerContext["requestContext"], + prepareHostedConfigContext: (() => + Promise.resolve( + undefined, + )) as unknown as HandlerContext["prepareHostedConfigContext"], + securityConfig: null, + adapter: { fs } as unknown as HandlerContext["adapter"], + }), + ); + + assertNotEquals( + result.response?.status, + 503, + "a granted shared executor must not return project-execution-unavailable", + ); + assertEquals( + contentReads, + 1, + "the request must reach the project source read instead of failing at the guard", + ); + } finally { + await fs.shutdown(); + globalThis.fetch = originalFetch; + } +}); + Deno.test("MarkdownPreviewHandler admits and reads through a real wrapped GitHub adapter", async () => { const originalFetch = globalThis.fetch; let contentReads = 0; diff --git a/src/server/handlers/preview/markdown-preview.handler.ts b/src/server/handlers/preview/markdown-preview.handler.ts index 2336b9a7d7..96794c6225 100644 --- a/src/server/handlers/preview/markdown-preview.handler.ts +++ b/src/server/handlers/preview/markdown-preview.handler.ts @@ -16,7 +16,7 @@ import { extract } from "#std/front-matter/yaml.ts"; import { tryNotFoundFallback } from "../request/ssr/not-found-fallback.ts"; import { generateMarkdownHtml } from "./markdown-html-generator.ts"; import { validateLexicalPath, validatePath, ValidationPresets } from "#veryfront/security"; -import { isSharedProjectRuntime } from "#veryfront/security/project-locality.ts"; +import { requiresIsolatedProjectRuntime } from "#veryfront/security/project-locality.ts"; import { createErrorResponseFromDefinition, PROJECT_EXECUTION_UNAVAILABLE, @@ -48,7 +48,7 @@ export class MarkdownPreviewHandler extends BaseHandler { return this.continue(); } - if (isSharedProjectRuntime(ctx)) { + if (requiresIsolatedProjectRuntime(ctx)) { const problem = createErrorResponseFromDefinition( PROJECT_EXECUTION_UNAVAILABLE, { diff --git a/src/server/handlers/request/module/module.handler.test.ts b/src/server/handlers/request/module/module.handler.test.ts index 1406d94b0e..adf05a60c5 100644 --- a/src/server/handlers/request/module/module.handler.test.ts +++ b/src/server/handlers/request/module/module.handler.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertNotEquals } from "#veryfront/testing/assert.ts"; import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; import { ModuleHandler } from "./module.handler.ts"; import { handleBatchModuleEndpoint } from "./batch-module-handler.ts"; @@ -273,6 +273,54 @@ describe("server/handlers/request/module/module.handler", () => { assertEquals(result.response?.status, 503); assertEquals(await result.response?.text(), ""); }); + + it("reaches the host renderer once the host grants execution", async () => { + let rendererCalls = 0; + const renderer = { + renderPage: () => + Promise.resolve({ pageModule: { code: "export default 1;" } }) as ReturnType< + Renderer["renderPage"] + >, + } as unknown as Renderer; + setRendererInitializer({ + initialize: () => { + rendererCalls++; + return Promise.resolve(renderer); + }, + isInitialized: () => rendererCalls > 0, + get: () => renderer, + destroy: () => Promise.resolve(), + }); + + const result = await new ModuleHandler().handle( + new Request("https://tenant.example/_veryfront/pages/page.js"), + makeCtx({ + isLocalProject: false, + allowHostProjectCodeExecution: true, + projectSlug: "tenant", + proxyToken: "token", + adapter: { + fs: { + isMultiProjectMode: () => true, + runWithContext: (_s: string, _t: string, fn: () => Promise) => fn(), + exists: () => Promise.resolve(true), + readFile: () => Promise.resolve(""), + }, + } as unknown as HandlerContext["adapter"], + }), + ); + + assertNotEquals( + result.response?.status, + 503, + "a granted shared executor must not return project-execution-unavailable", + ); + assertEquals( + rendererCalls > 0, + true, + "the request must reach the host renderer instead of failing at the guard", + ); + }); }); describe("handle - page modules", () => { diff --git a/src/server/handlers/request/module/module.handler.ts b/src/server/handlers/request/module/module.handler.ts index 8ffbacc1af..f6fbb375b7 100644 --- a/src/server/handlers/request/module/module.handler.ts +++ b/src/server/handlers/request/module/module.handler.ts @@ -16,7 +16,7 @@ import { createErrorResponseFromDefinition, PROJECT_EXECUTION_UNAVAILABLE, } from "#veryfront/errors"; -import { isSharedProjectRuntime } from "#veryfront/security/project-locality.ts"; +import { requiresIsolatedProjectRuntime } from "#veryfront/security/project-locality.ts"; const MODULE_ENDPOINT_PREFIXES = [ "/_vf_modules/", @@ -69,11 +69,11 @@ export class ModuleHandler extends BaseHandler { } // These endpoints delegate to the legacy renderer, whose module loader - // imports page and layout code in the host process. Remote source must not - // reach that path until rendering has a generation-owned prepared module - // graph equivalent to isolated API routes. + // imports page and layout code in the host process. A shared runtime must + // not reach that path unless its host-owned entrypoint granted the + // host-execution capability. if ( - isSharedProjectRuntime(ctx) && + requiresIsolatedProjectRuntime(ctx) && HOST_RENDERER_ENDPOINT_PREFIXES.some((prefix) => pathname.startsWith(prefix)) ) { const problem = createErrorResponseFromDefinition( diff --git a/src/server/handlers/request/snippet.handler.test.ts b/src/server/handlers/request/snippet.handler.test.ts index 7b48344a47..cb37f1ca18 100644 --- a/src/server/handlers/request/snippet.handler.test.ts +++ b/src/server/handlers/request/snippet.handler.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertNotEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { validateLexicalPath } from "#veryfront/security"; import { SnippetHandler } from "./snippet.handler.ts"; @@ -94,7 +94,7 @@ Deno.test("SnippetHandler rejects shared rendering before proxy context or sourc projectDir: "/project", projectSlug: "project", proxyToken: "token", - isLocalProject: true, + isLocalProject: false, adapter: { fs }, } as unknown as HandlerContext; @@ -108,6 +108,57 @@ Deno.test("SnippetHandler rejects shared rendering before proxy context or sourc assertEquals(readPath, undefined); }); +Deno.test("SnippetHandler renders shared snippets once the host grants execution", async () => { + let contextCalls = 0; + let readPath: string | undefined; + const fs = { + symlinkSemantics: "none" as const, + isMultiProjectMode: () => true, + isContextualMode: () => true, + runWithContext: async ( + _slug: string, + _token: string, + fn: () => Promise, + ) => { + contextCalls++; + return await fn(); + }, + exists: () => Promise.resolve(true), + stat: () => + Promise.resolve({ + isFile: true, + isDirectory: false, + isSymlink: false, + size: 0, + mtime: new Date(), + }), + readFile: (path: string) => { + readPath = path; + return Promise.resolve(""); + }, + }; + const ctx = { + projectDir: "/project", + projectSlug: "project", + proxyToken: "token", + isLocalProject: false, + allowHostProjectCodeExecution: true, + adapter: { fs }, + } as unknown as HandlerContext; + + const result = await new SnippetHandler().handle( + new Request("http://localhost/@components/button"), + ctx, + ); + assertNotEquals( + result.response?.status, + 503, + "a granted shared executor must not return project-execution-unavailable", + ); + assertEquals(contextCalls, 1); + assertEquals(readPath, "/project/components/button.snippet.mdx"); +}); + Deno.test("SnippetHandler preserves dedicated local rendering", async () => { let readPath: string | undefined; const fs = { diff --git a/src/server/handlers/request/snippet.handler.ts b/src/server/handlers/request/snippet.handler.ts index 946576aff5..a888863d27 100644 --- a/src/server/handlers/request/snippet.handler.ts +++ b/src/server/handlers/request/snippet.handler.ts @@ -12,7 +12,7 @@ import { VeryfrontError, } from "#veryfront/errors"; import { validatePath, ValidationPresets } from "#veryfront/security"; -import { isSharedProjectRuntime } from "#veryfront/security/project-locality.ts"; +import { requiresIsolatedProjectRuntime } from "#veryfront/security/project-locality.ts"; import { createHandlerDependencyPinningSource, getHandlerDependencyPinningIdentity, @@ -37,7 +37,7 @@ export class SnippetHandler extends BaseHandler { return this.continue(); } - if (isSharedProjectRuntime(ctx)) { + if (requiresIsolatedProjectRuntime(ctx)) { const problem = createErrorResponseFromDefinition( PROJECT_EXECUTION_UNAVAILABLE, { diff --git a/src/server/handlers/response/cors.test.ts b/src/server/handlers/response/cors.test.ts index 7d99069095..bed5b6ec93 100644 --- a/src/server/handlers/response/cors.test.ts +++ b/src/server/handlers/response/cors.test.ts @@ -141,11 +141,61 @@ describe("server/handlers/response/cors", () => { prepareHostedConfigContext: (() => { throw new Error("shared preflight prepared project config"); }) as HandlerContext["prepareHostedConfigContext"], + securityConfig: { cors: { origin: ["https://app.example"] } } as never, }), ); assertEquals(result.response instanceof Response, true); assertEquals(routeResolutionCalls, 0); + assertEquals( + result.response?.headers.get("access-control-allow-methods"), + "GET, POST, PUT, PATCH, DELETE, OPTIONS", + ); + }); + + it("resolves project route methods once the host grants execution", async () => { + const dir = await Deno.makeTempDir({ prefix: "vf-cors-granted-" }); + const routeFile = `${dir}/route.ts`; + await Deno.writeTextFile( + routeFile, + "export function GET() {}\nexport function POST() {}\n", + ); + + let routeResolutionCalls = 0; + const handler = new CorsHandler({ + resolveAppRouteFile: () => { + routeResolutionCalls++; + return Promise.resolve({ file: routeFile } as never); + }, + }); + + try { + const result = await handler.handle( + new Request("https://tenant.example/api/private", { + method: "OPTIONS", + headers: { + Origin: "https://app.example", + "access-control-request-method": "POST", + }, + }), + makeCtx({ + allowHostProjectCodeExecution: true, + prepareHostedConfigContext: (() => + Promise.resolve( + undefined, + )) as unknown as HandlerContext["prepareHostedConfigContext"], + securityConfig: { cors: { origin: ["https://app.example"] } } as never, + }), + ); + + assertEquals(routeResolutionCalls, 1); + assertEquals( + result.response?.headers.get("access-control-allow-methods"), + "HEAD, GET, POST, OPTIONS", + ); + } finally { + await Deno.remove(dir, { recursive: true }); + } }); it("does not advertise infrastructure-only request headers", async () => { diff --git a/src/server/handlers/response/cors.ts b/src/server/handlers/response/cors.ts index 13259d1802..1bec4a7e2b 100644 --- a/src/server/handlers/response/cors.ts +++ b/src/server/handlers/response/cors.ts @@ -10,7 +10,7 @@ import { ResponseBuilder } from "#veryfront/security/index.ts"; import { getConfig } from "#veryfront/config"; import { PRIORITY_VERY_HIGH } from "#veryfront/utils/constants/index.ts"; import { resolveAppRouteFile } from "../request/api/app-router-resolver.ts"; -import { isSharedProjectRuntime } from "#veryfront/security/project-locality.ts"; +import { requiresIsolatedProjectRuntime } from "#veryfront/security/project-locality.ts"; import { isInfrastructureOnlyRequestHeader } from "#veryfront/security/http/application-request.ts"; type AppRouteResolver = typeof resolveAppRouteFile; @@ -50,13 +50,13 @@ export class CorsHandler extends BaseHandler { if (req.method.toUpperCase() !== "OPTIONS") return this.continue(); const pathname = new URL(req.url).pathname; - const isSharedRuntime = isSharedProjectRuntime(ctx); - const allowMethods = isSharedRuntime + const mustDenyProjectExecution = requiresIsolatedProjectRuntime(ctx); + const allowMethods = mustDenyProjectExecution ? CorsHandler.DEFAULT_METHODS : await this.resolveAllowedMethods(pathname, ctx); let corsConfig = ctx.securityConfig?.cors; - if (!isSharedRuntime) { + if (!mustDenyProjectExecution) { try { const cfg = await getConfig(ctx.projectDir, ctx.adapter); corsConfig = cfg?.security?.cors ?? corsConfig;