diff --git a/CHANGELOG.md b/CHANGELOG.md index a3d4b90..acef125 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Wire generated provider launchers to the configured application origin and + allowed host, serve browser assets from the production build directory while + excluding server bundles and Vite manifests, and include Node types in + provider application type checking. + - Dispose rejected or redirected OpenAPI response bodies, including header validation and redirect-limit failures. Stop streaming unused bodies after reporting an error. Keep normal complete responses and load limits unchanged. diff --git a/docs/provider-auth.md b/docs/provider-auth.md index d283862..b499343 100644 --- a/docs/provider-auth.md +++ b/docs/provider-auth.md @@ -87,3 +87,8 @@ explicit operations, subject to the provider's capabilities. After filling server configuration, run the generated Vite+ type, lint, test, and build commands. Release qualification uses simulated signed providers; live provider-account compatibility remains a separate check. + +The production launcher passes `APP_ORIGIN` to the Node transport as its canonical +base URL and allowed host, including an explicit port. Set it to the same HTTPS +origin used by the provider session configuration. Generated provider projects +include Node types for their server code. diff --git a/src/generate/auth.ts b/src/generate/auth.ts index eaf2bdf..b1dbef4 100644 --- a/src/generate/auth.ts +++ b/src/generate/auth.ts @@ -173,6 +173,17 @@ function providerFile(provider: Provider): string { ].join("\n"); } +function withProviderOrigin(content: string): string { + if (content.split("const port =").length !== 2 || content.split(" port,").length !== 2) + throw new Error("The owned server template requires explicit provider origin wiring."); + return ( + 'import { requiredEnv } from "./src/auth/env";\n' + + content + .replace("const port =", 'const origin = requiredEnv("APP_ORIGIN");\nconst port =') + .replace(" port,", " port,\n baseUrl: origin,\n allowedHosts: [new URL(origin).host],") + ); +} + function generated(state: AuthState): Record { return { ...Object.fromEntries( @@ -458,7 +469,9 @@ export async function planProviderAuth( ); await append( "server.ts", - await fs.readFile(path.join(await findTemplateDir("ssr"), "server.ts"), "utf8"), + withProviderOrigin( + await fs.readFile(path.join(await findTemplateDir("ssr"), "server.ts"), "utf8"), + ), ); await replace("src/main.tsx", (content) => content @@ -487,6 +500,15 @@ export async function planProviderAuth( .replace("build: {", "build: {\n manifest: true,"), ); } + if (template === "ssr" || template === "full-stack") { + await replace("server.ts", withProviderOrigin); + } + await replace("tsconfig.json", (content) => { + const config = JSON.parse(content); + if (config.compilerOptions.types.includes("node")) return content; + config.compilerOptions.types.push("node"); + return `${JSON.stringify(config, null, 2)}\n`; + }); await replace("package.json", (content) => { const manifest = JSON.parse(content); const range = manifest.dependencies["@askrjs/askr"]; diff --git a/templates/full-stack/server.ts b/templates/full-stack/server.ts index 389a04f..d786aaf 100644 --- a/templates/full-stack/server.ts +++ b/templates/full-stack/server.ts @@ -1,3 +1,5 @@ +import { fileURLToPath } from "node:url"; +import { posix } from "node:path"; import { serve } from "@askrjs/node"; import app from "virtual:askr-server"; @@ -5,5 +7,13 @@ const port = Number(process.env.PORT ?? 3000); await serve(app, { host: process.env.HOST ?? "127.0.0.1", port, - assets: { root: new URL("./dist", import.meta.url).pathname }, + assets: { + root: fileURLToPath(new URL("../", import.meta.url)), + exclude: (pathname) => { + const normalized = posix.normalize(pathname.replaceAll("\\", "/")).toLowerCase(); + return ["/server", "/.vite"].some( + (directory) => normalized === directory || normalized.startsWith(`${directory}/`), + ); + }, + }, }); diff --git a/templates/ssr/README.md b/templates/ssr/README.md index 64b3215..432dbdc 100644 --- a/templates/ssr/README.md +++ b/templates/ssr/README.md @@ -36,3 +36,7 @@ src/ ``` The application source does not construct document markup or expose a `render(url)` callback. Document ownership stays in `index.html` and `@askrjs/vite/server`. + +The production launcher runs from `dist/server` and serves browser assets from +`dist`. Server bundles under `dist/server` and Vite manifests under `dist/.vite` +are excluded from public asset responses. diff --git a/templates/ssr/server.ts b/templates/ssr/server.ts index a78535c..4d0be7c 100644 --- a/templates/ssr/server.ts +++ b/templates/ssr/server.ts @@ -1,3 +1,5 @@ +import { fileURLToPath } from 'node:url'; +import { posix } from 'node:path'; import { serve } from '@askrjs/node'; import app from 'virtual:askr-server'; @@ -5,6 +7,17 @@ const port = Number(process.env.PORT ?? 3000); const running = await serve(app, { host: process.env.HOST ?? '127.0.0.1', port, - assets: { root: new URL('./dist', import.meta.url).pathname }, + assets: { + root: fileURLToPath(new URL('../', import.meta.url)), + exclude: (pathname) => { + const normalized = posix + .normalize(pathname.replaceAll('\\', '/')) + .toLowerCase(); + return ['/server', '/.vite'].some( + (directory) => + normalized === directory || normalized.startsWith(`${directory}/`) + ); + }, + }, }); console.log(`Server started at ${running.url}`); diff --git a/tests/provider-auth-generator.test.ts b/tests/provider-auth-generator.test.ts index 3b5d2bd..49c0491 100644 --- a/tests/provider-auth-generator.test.ts +++ b/tests/provider-auth-generator.test.ts @@ -1,12 +1,15 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import ts from "typescript"; import { afterEach, describe, expect, test } from "vite-plus/test"; import { runAddCli } from "../src/bin/add"; import { runCreateCli } from "../src/bin/create"; import { writeFileChanges } from "../src/file-changes"; +const execFileAsync = promisify(execFile); const roots: string[] = []; const providers = ["auth0", "microsoft", "google", "apple"] as const; const names = { @@ -330,3 +333,76 @@ test("refuses symlinked output ownership without touching the outside target", a expect(await fs.readdir(outside)).toEqual(["sentinel"]); expect(await fs.readFile(path.join(outside, "sentinel"), "utf8")).toBe("preserve outside bytes"); }); + +test.each(["ssr", "full-stack", "spa", "startkit"])( + "generated %s provider launcher preserves the canonical origin and built assets", + async (template) => { + const root = await scaffold(template); + expect( + await runAddCli( + ["auth", "google", "--cwd", root, "--origin", "https://app.example:8443"], + capture().io, + ), + ).toBe(0); + const files = await contents(root); + const config = JSON.parse(files["tsconfig.json"]); + expect(config.compilerOptions.types).toContain("node"); + expect(config.compilerOptions.types.filter((name: string) => name === "node")).toHaveLength(1); + + // Execute the generated launcher from its production build location. Only + // the external transport and Vite virtual app are replaced by fixture modules. + const output = path.join(root, "dist", "server"); + await fs.mkdir(output, { recursive: true }); + const privatePaths = [ + "/server", + "/server/server.js", + "/.vite/manifest.json", + "/SERVER/secret.js", + "/assets/../server/secret.js", + "\\server\\secret.js", + ]; + const publicPaths = [ + "/assets/main.js", + "/favicon.svg", + "/server-status", + "/.vite-public/logo.svg", + ]; + await fs.writeFile( + path.join(output, "transport.mjs"), + `import { writeFile } from "node:fs/promises"; +export async function serve(app, options) { + await writeFile(new URL("./result.json", import.meta.url), JSON.stringify({ + baseUrl: options.baseUrl, + allowedHosts: options.allowedHosts, + assetsRoot: options.assets.root, + exclusions: Object.fromEntries(${JSON.stringify([...privatePaths, ...publicPaths])}.map(pathname => [pathname, options.assets.exclude?.(pathname)])), + })); + return { url: "http://127.0.0.1" }; +}`, + ); + await fs.writeFile(path.join(output, "app.mjs"), "export default {};\n"); + const transpile = (source: string) => + ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext }, + }).outputText; + await fs.writeFile(path.join(output, "env.mjs"), transpile(files["src/auth/env.ts"])); + const source = files["server.ts"] + .replace(/(["'])@askrjs\/node\1/, '"./transport.mjs"') + .replace(/(["'])virtual:askr-server\1/, '"./app.mjs"') + .replace('"./src/auth/env"', '"./env.mjs"'); + await fs.writeFile(path.join(output, "server.mjs"), transpile(source)); + await execFileAsync(process.execPath, [path.join(output, "server.mjs")], { + env: { ...process.env, APP_ORIGIN: "https://app.example:8443", PORT: "0" }, + }); + const options = JSON.parse(await fs.readFile(path.join(output, "result.json"), "utf8")); + expect(options.baseUrl).toBe("https://app.example:8443"); + expect(options.allowedHosts).toEqual(["app.example:8443"]); + expect(await fs.realpath(options.assetsRoot)).toBe(await fs.realpath(path.join(root, "dist"))); + for (const pathname of privatePaths) { + expect(options.exclusions[pathname], pathname).toBe(true); + } + for (const pathname of publicPaths) { + expect(options.exclusions[pathname], pathname).toBe(false); + } + }, +);