Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions docs/provider-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
24 changes: 23 additions & 1 deletion src/generate/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
return {
...Object.fromEntries(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"];
Expand Down
12 changes: 11 additions & 1 deletion templates/full-stack/server.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import { fileURLToPath } from "node:url";
import { posix } from "node:path";
import { serve } from "@askrjs/node";
import app from "virtual:askr-server";

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}/`),
);
},
},
});
4 changes: 4 additions & 0 deletions templates/ssr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
15 changes: 14 additions & 1 deletion templates/ssr/server.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
import { fileURLToPath } from 'node:url';
import { posix } from 'node:path';
import { serve } from '@askrjs/node';
import app from 'virtual:askr-server';

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}`);
76 changes: 76 additions & 0 deletions tests/provider-auth-generator.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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);
}
},
);
Loading