feat: add helpers and improve workspace auth flows
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ai",
|
||||
"version": "0.2.16",
|
||||
"version": "0.2.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/authz",
|
||||
"version": "0.2.16",
|
||||
"version": "0.2.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -34,6 +34,7 @@ Every command accepts an optional `[app-dir]` (defaults to `.`). Commands that r
|
||||
| `wrnexus build [app-dir]` | Build a self-contained production server bundle + assets into `dist/`. |
|
||||
| `wrnexus create <app-name>` | Scaffold a new single app from an inline template. |
|
||||
| `wrnexus workspace <name>` | Scaffold a monorepo (`apps/*` + shared `packages/*`). |
|
||||
| `wrnexus workspace add <name>` | Add and register an app in the current workspace. |
|
||||
| `wrnexus gateway [--port=3000]` | Serve every workspace app behind one port, routed by domain. |
|
||||
| `wrnexus generate <type> <name>` | Scaffold a `page` \| `component` \| `api` \| `schema`. |
|
||||
| `wrnexus generate routes` | Regenerate the typed routes file (`app/routes.gen.ts`). |
|
||||
@@ -167,6 +168,13 @@ wrnexus workspace acme
|
||||
wrnexus gateway --port=3000
|
||||
```
|
||||
|
||||
From a workspace root, add and register another app in one command:
|
||||
|
||||
```bash
|
||||
wrnexus workspace add reports --domain=reports.localhost
|
||||
bun install
|
||||
```
|
||||
|
||||
Development gateways bind to `127.0.0.1` by default for reliable access on Windows,
|
||||
macOS, and Linux. Open the configured app domain on the gateway port (for example
|
||||
`http://localhost:3000` or `http://admin.localhost:3000`), not the internal child ports
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.2.16",
|
||||
"version": "0.2.17",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -167,6 +167,21 @@ export const POST = async (ctx) => {
|
||||
\`req: Request\`, \`url: URL\`, \`params: Record<string,string>\` (dynamic route params, e.g. \`/users/[id]\` → \`ctx.params.id\`),
|
||||
\`lang: string\`, \`t(key, params?)\` (i18n), \`cookies\` (get/set), \`session\` (get/set). Auth: \`getUser(ctx)\` after \`sessionAuth\`/\`logIn\`.
|
||||
|
||||
When an SSO forward-auth verifier needs the URL that originally reached the gateway, use
|
||||
\`@wrnexus/helpers\` instead of constructing it from untrusted headers:
|
||||
|
||||
\`\`\`ts
|
||||
import { redirectToLogin } from "@wrnexus/helpers";
|
||||
|
||||
return redirectToLogin(ctx, "/login", {
|
||||
allowedHosts: ["admin.localhost:3000", "reports.localhost:3000"],
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
The package also exports \`getOriginalRequestUrl\`, \`getOriginalRequestOrigin\`,
|
||||
\`getOriginalRequestPath\`, and \`getOriginalRequestMethod\`. Always pass \`allowedHosts\` when
|
||||
using forwarded gateway URLs; the helper rejects untrusted redirect destinations.
|
||||
|
||||
## Middleware & realtime
|
||||
|
||||
\`\`\`ts
|
||||
|
||||
@@ -84,6 +84,7 @@ Thumbs.db
|
||||
"dependencies": {
|
||||
"@wrnexus/ai": "${frameworkVersion}",
|
||||
"@wrnexus/core": "${frameworkVersion}",
|
||||
"@wrnexus/helpers": "${frameworkVersion}",
|
||||
"@wrnexus/styles": "${frameworkVersion}",
|
||||
"@wrnexus/validation": "${frameworkVersion}",
|
||||
"@wrnexus/db": "${frameworkVersion}"
|
||||
@@ -175,6 +176,7 @@ dist/
|
||||
.wrnexus/
|
||||
**/.wrnexus/
|
||||
*.log
|
||||
CLAUDE.md
|
||||
`,
|
||||
".editorconfig": `root = true
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ Usage:
|
||||
wrnexus build [app-dir] Build a production server bundle + assets
|
||||
wrnexus create <app-name> Scaffold a new app
|
||||
wrnexus workspace <name> Scaffold a monorepo (apps/* + shared packages/*)
|
||||
wrnexus workspace add <name> [--domain=name.localhost]
|
||||
Add an app to the current workspace
|
||||
wrnexus gateway [--port=3000] Serve every workspace app behind one port, routed by domain
|
||||
wrnexus generate <type> <name> Scaffold a page | component | api | schema
|
||||
wrnexus generate routes | docker | mobile
|
||||
@@ -92,8 +94,12 @@ async function main(): Promise<void> {
|
||||
createApp(rest[0] ?? "");
|
||||
break;
|
||||
case "workspace": {
|
||||
const { createWorkspace } = await import("./workspace.ts");
|
||||
createWorkspace(rest.find((a) => !a.startsWith("--")) ?? "");
|
||||
const { addWorkspaceApp, createWorkspace } = await import("./workspace.ts");
|
||||
if (rest[0] === "add") {
|
||||
await addWorkspaceApp(".", rest[1] ?? "", rest.slice(2));
|
||||
} else {
|
||||
createWorkspace(rest.find((a) => !a.startsWith("--")) ?? "");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "gateway": {
|
||||
|
||||
+137
-10
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Monorepo support:
|
||||
* - `wrnexus workspace <name>` scaffolds a multi-app workspace (apps/* + shared packages/*)
|
||||
* - `wrnexus workspace add <name> --domain=<host>` adds an app to the current workspace
|
||||
* - `wrnexus gateway [--port]` serves every app behind one port, routed by domain
|
||||
*
|
||||
* A workspace holds several WrNexus apps under `apps/*` and shared libraries under
|
||||
@@ -9,7 +10,7 @@
|
||||
* cross-process). `wrnexus.workspace.ts` maps each app to the domains it serves.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { scaffoldApp } from "./create.ts";
|
||||
@@ -151,12 +152,139 @@ apps are internal gateway targets, not public workspace URLs.
|
||||
## Add another app
|
||||
|
||||
\`\`\`bash
|
||||
wrnexus create apps/reports
|
||||
# then add it to wrnexus.workspace.ts with its domains
|
||||
wrnexus workspace add reports --domain=reports.localhost
|
||||
\`\`\`
|
||||
`,
|
||||
});
|
||||
|
||||
const workspaceConfigNames = [
|
||||
"wrnexus.workspace.ts",
|
||||
"wrnexus.workspace.js",
|
||||
"wrnexus.workspace.mjs",
|
||||
] as const;
|
||||
|
||||
function workspaceConfigPath(root: string): string | null {
|
||||
for (const file of workspaceConfigNames) {
|
||||
const path = join(root, file);
|
||||
if (existsSync(path)) return path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Insert an app entry into the top-level `apps: [...]` array. */
|
||||
export function insertWorkspaceApp(source: string, app: WorkspaceApp): string {
|
||||
const declaration = /\bapps\s*:\s*\[/.exec(source);
|
||||
if (!declaration) throw new Error("Workspace config has no `apps: [...]` array.");
|
||||
const start = source.indexOf("[", declaration.index);
|
||||
let depth = 0;
|
||||
let quote = "";
|
||||
let escaped = false;
|
||||
let lineComment = false;
|
||||
let blockComment = false;
|
||||
let end = -1;
|
||||
|
||||
for (let i = start; i < source.length; i++) {
|
||||
const char = source[i]!;
|
||||
const next = source[i + 1] ?? "";
|
||||
if (lineComment) {
|
||||
if (char === "\n") lineComment = false;
|
||||
continue;
|
||||
}
|
||||
if (blockComment) {
|
||||
if (char === "*" && next === "/") {
|
||||
blockComment = false;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (escaped) escaped = false;
|
||||
else if (char === "\\") escaped = true;
|
||||
else if (char === quote) quote = "";
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && next === "/") {
|
||||
lineComment = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && next === "*") {
|
||||
blockComment = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'" || char === "`") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
if (char === "[") depth++;
|
||||
if (char === "]" && --depth === 0) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (end < 0) throw new Error("Workspace `apps` array is not closed.");
|
||||
|
||||
const serialized = `{ name: ${JSON.stringify(app.name)}, dir: ${JSON.stringify(app.dir)}, domains: ${JSON.stringify(app.domains)}${app.port ? `, port: ${app.port}` : ""} }`;
|
||||
if (!source.slice(start, end).includes("\n")) {
|
||||
const contents = source.slice(start + 1, end).trimEnd();
|
||||
const separator = contents && !contents.endsWith(",") ? "," : "";
|
||||
return source.slice(0, end) + `${separator} ${serialized}` + source.slice(end);
|
||||
}
|
||||
|
||||
const lineStart = source.lastIndexOf("\n", end - 1) + 1;
|
||||
const closeIndent = /^\s*/.exec(source.slice(lineStart, end))?.[0] ?? "";
|
||||
const entry = `${closeIndent} ${serialized},\n`;
|
||||
return source.slice(0, lineStart) + entry + source.slice(lineStart);
|
||||
}
|
||||
|
||||
/** Add a scaffolded app and register its domain in the current workspace. */
|
||||
export async function addWorkspaceApp(root: string, name: string, args: string[]): Promise<void> {
|
||||
if (!/^[a-z][a-z0-9-]*$/.test(name)) {
|
||||
throw new Error(
|
||||
"App name must start with a letter and contain only lowercase letters, digits, and hyphens.",
|
||||
);
|
||||
}
|
||||
const resolvedRoot = resolve(root);
|
||||
const configPath = workspaceConfigPath(resolvedRoot);
|
||||
if (!configPath) throw new Error("No wrnexus.workspace.ts found in the current directory.");
|
||||
|
||||
const domain =
|
||||
args.find((arg) => arg.startsWith("--domain="))?.split("=")[1] || `${name}.localhost`;
|
||||
if (!/^[a-z0-9.-]+$/.test(domain)) throw new Error(`Invalid workspace domain: ${domain}`);
|
||||
const portValue = args.find((arg) => arg.startsWith("--port="))?.split("=")[1];
|
||||
const port = portValue ? Number(portValue) : undefined;
|
||||
if (portValue && (!Number.isInteger(port) || port! < 1 || port! > 65535)) {
|
||||
throw new Error(`Invalid app port: ${portValue}`);
|
||||
}
|
||||
|
||||
const config = await loadWorkspaceConfig(resolvedRoot);
|
||||
const dir = `apps/${name}`;
|
||||
if (config.apps.some((app) => app.name === name || app.dir.replace(/\\/g, "/") === dir)) {
|
||||
throw new Error(`Workspace app '${name}' already exists.`);
|
||||
}
|
||||
if (config.apps.some((app) => app.domains.includes(domain))) {
|
||||
throw new Error(`Workspace domain '${domain}' is already assigned.`);
|
||||
}
|
||||
const appRoot = join(resolvedRoot, "apps", name);
|
||||
if (existsSync(appRoot)) throw new Error(`Refusing to overwrite existing directory: ${appRoot}`);
|
||||
|
||||
const original = readFileSync(configPath, "utf8");
|
||||
const next = insertWorkspaceApp(original, { name, dir, domains: [domain], port });
|
||||
scaffoldApp(appRoot, name);
|
||||
try {
|
||||
writeFileSync(configPath, next, "utf8");
|
||||
} catch (error) {
|
||||
rmSync(appRoot, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log(`✓ Added app ${name}`);
|
||||
console.log(` directory: ${dir}`);
|
||||
console.log(` domain: http://${domain}:3000`);
|
||||
console.log("\nRun `bun install`, then `bun run dev`.");
|
||||
}
|
||||
|
||||
/** Scaffold a monorepo workspace with two starter apps + a shared package. */
|
||||
export function createWorkspace(name: string): void {
|
||||
if (!name) {
|
||||
@@ -187,14 +315,13 @@ export function createWorkspace(name: string): void {
|
||||
|
||||
/** Load `wrnexus.workspace.ts` from a directory. */
|
||||
export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig> {
|
||||
for (const file of ["wrnexus.workspace.ts", "wrnexus.workspace.js", "wrnexus.workspace.mjs"]) {
|
||||
const path = join(root, file);
|
||||
if (existsSync(path)) {
|
||||
const mod = (await import(pathToFileURL(path).href)) as { default?: WorkspaceConfig };
|
||||
if (!mod.default?.apps?.length)
|
||||
throw new Error(`${file} must default-export { apps: [...] }`);
|
||||
return mod.default;
|
||||
const path = workspaceConfigPath(root);
|
||||
if (path) {
|
||||
const mod = (await import(pathToFileURL(path).href)) as { default?: WorkspaceConfig };
|
||||
if (!mod.default?.apps?.length) {
|
||||
throw new Error(`${path.split(/[\\/]/).at(-1)} must default-export { apps: [...] }`);
|
||||
}
|
||||
return mod.default;
|
||||
}
|
||||
throw new Error("No wrnexus.workspace.ts found. Run `wrnexus workspace <name>` to scaffold one.");
|
||||
}
|
||||
|
||||
@@ -61,12 +61,14 @@ test("scaffoldApp pins the current framework and exposes llms.txt publicly", ()
|
||||
const version = currentCliVersion();
|
||||
|
||||
expect(pkg.wrnexus.version).toBe(version);
|
||||
expect(pkg.dependencies["@wrnexus/helpers"]).toBe(version);
|
||||
for (const dependency of Object.values(pkg.dependencies) as string[]) {
|
||||
expect(dependency).toBe(version);
|
||||
}
|
||||
expect(pkg.devDependencies["@wrnexus/cli"]).toBe(version);
|
||||
expect(existsSync(join(root, "public", "llms.txt"))).toBe(true);
|
||||
expect(existsSync(join(root, "llms.txt"))).toBe(false);
|
||||
expect(readFileSync(join(root, ".prettierignore"), "utf8")).toContain("CLAUDE.md");
|
||||
expect(readFileSync(join(root, "app", "pages", "index.wrn"), "utf8")).toContain(
|
||||
'href="/api/hello"',
|
||||
);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { currentCliVersion } from "../src/update-notifier.ts";
|
||||
import { workspaceFiles } from "../src/workspace.ts";
|
||||
import { addWorkspaceApp, insertWorkspaceApp, workspaceFiles } from "../src/workspace.ts";
|
||||
|
||||
test("workspace templates pin the running framework release", () => {
|
||||
const files = workspaceFiles("acme");
|
||||
@@ -13,3 +16,48 @@ test("workspace templates pin the running framework release", () => {
|
||||
expect(files["README.md"]).toContain("http://127.0.0.1:3000");
|
||||
expect(files["README.md"]).toContain("internal gateway targets");
|
||||
});
|
||||
|
||||
test("insertWorkspaceApp handles nested arrays and comments", () => {
|
||||
const source = `const config = {
|
||||
apps: [
|
||||
{ name: "web", dir: "apps/web", domains: ["localhost"] },
|
||||
{ name: "admin", dir: "apps/admin", domains: ["admin.localhost"], auth: { allowIps: ["::1"] } }, // ]
|
||||
],
|
||||
};
|
||||
export default config;
|
||||
`;
|
||||
const result = insertWorkspaceApp(source, {
|
||||
name: "reports",
|
||||
dir: "apps/reports",
|
||||
domains: ["reports.localhost"],
|
||||
});
|
||||
|
||||
expect(result).toContain(
|
||||
'{ name: "reports", dir: "apps/reports", domains: ["reports.localhost"] },',
|
||||
);
|
||||
expect(result.indexOf('name: "reports"')).toBeLessThan(result.indexOf("\n ],"));
|
||||
});
|
||||
|
||||
test("workspace add scaffolds and registers an app", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-workspace-add-"));
|
||||
writeFileSync(
|
||||
join(root, "wrnexus.workspace.ts"),
|
||||
'export default { apps: [{ name: "web", dir: "apps/web", domains: ["localhost"] }] };\n',
|
||||
);
|
||||
|
||||
try {
|
||||
await addWorkspaceApp(root, "reports", ["--domain=reports.localhost"]);
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(join(root, "apps", "reports", "package.json"), "utf8"),
|
||||
);
|
||||
const config = readFileSync(join(root, "wrnexus.workspace.ts"), "utf8");
|
||||
|
||||
expect(manifest.name).toBe("reports");
|
||||
expect(manifest.wrnexus.version).toBe(currentCliVersion());
|
||||
expect(config).toContain('name: "reports"');
|
||||
expect(config).toContain('domains: ["reports.localhost"]');
|
||||
expect(existsSync(join(root, "apps", "reports", "public", "llms.txt"))).toBe(true);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/compiler",
|
||||
"version": "0.2.16",
|
||||
"version": "0.2.17",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/core",
|
||||
"version": "0.2.16",
|
||||
"version": "0.2.17",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.2.16",
|
||||
"version": "0.2.17",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/db",
|
||||
"version": "0.2.16",
|
||||
"version": "0.2.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -176,6 +176,19 @@ interface GatewaySecurity {
|
||||
}
|
||||
```
|
||||
|
||||
Forward auth is a verification hook, not a login page. Configure `forward.url` with a
|
||||
dedicated endpoint such as `http://sso.localhost:3000/api/verify`. The gateway forwards
|
||||
the request's `Cookie` and `Authorization` headers plus `X-Forwarded-Host`,
|
||||
`X-Forwarded-Proto`, `X-Original-Method`, and `X-Original-Uri` (including its query
|
||||
string). The verifier must return 2xx only for an authenticated session and 401/403
|
||||
otherwise. Pointing forward auth at an SSO home page that always returns 200 allows
|
||||
every request and does not implement SSO.
|
||||
|
||||
For browser SSO, the verifier may return a `302`/`303`/`307`/`308` with a `Location`
|
||||
header pointing to its login page. The gateway passes that redirect to the browser. The
|
||||
login flow should validate a signed `returnTo` value before redirecting back; API clients
|
||||
should receive `401`/`403` instead of an HTML login redirect.
|
||||
|
||||
Open the gateway URL (normally `http://127.0.0.1:3000`), not an app's internal
|
||||
port. The gateway exposes `/__gateway/health` (JSON list of routed apps) and returns a
|
||||
`RunningGateway` (`{ port, url, stop() }`). Use `--host=0.0.0.0` when other devices need
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.2.16",
|
||||
"version": "0.2.17",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -103,6 +103,35 @@ function timingSafeEqual(a: string, b: string): boolean {
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
/** Preserve an intentional verifier redirect while keeping other failures opaque. */
|
||||
export function forwardAuthFailure(res: Response, verifierUrl: string): Response {
|
||||
const location = res.headers.get("location");
|
||||
if (res.status >= 300 && res.status < 400 && location) {
|
||||
try {
|
||||
const redirect = new URL(location, verifierUrl);
|
||||
if (redirect.protocol === "http:" || redirect.protocol === "https:") {
|
||||
return new Response(null, { status: res.status, headers: { location: redirect.href } });
|
||||
}
|
||||
} catch {
|
||||
// Malformed or unsafe redirects fail closed below.
|
||||
}
|
||||
}
|
||||
return new Response("Unauthorized", { status: res.status === 200 ? 401 : res.status });
|
||||
}
|
||||
|
||||
/** Describe the original gateway request to a forward-auth verifier. */
|
||||
export function forwardAuthHeaders(req: Request): Headers {
|
||||
const original = new URL(req.url);
|
||||
return new Headers({
|
||||
cookie: req.headers.get("cookie") ?? "",
|
||||
authorization: req.headers.get("authorization") ?? "",
|
||||
"x-forwarded-host": req.headers.get("host") ?? original.host,
|
||||
"x-forwarded-proto": original.protocol.replace(":", ""),
|
||||
"x-original-method": req.method,
|
||||
"x-original-uri": `${original.pathname}${original.search}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce a per-app auth policy. Returns a Response to block, or null to allow.
|
||||
* `ip` is the client address (for the IP allowlist).
|
||||
@@ -140,16 +169,10 @@ async function checkAuth(
|
||||
if (auth.forward) {
|
||||
try {
|
||||
const res = await fetch(auth.forward.url, {
|
||||
headers: {
|
||||
cookie: req.headers.get("cookie") ?? "",
|
||||
authorization: req.headers.get("authorization") ?? "",
|
||||
"x-forwarded-host": req.headers.get("host") ?? "",
|
||||
"x-original-uri": new URL(req.url).pathname,
|
||||
},
|
||||
headers: forwardAuthHeaders(req),
|
||||
redirect: "manual",
|
||||
});
|
||||
if (!res.ok)
|
||||
return new Response("Unauthorized", { status: res.status === 200 ? 401 : res.status });
|
||||
if (!res.ok) return forwardAuthFailure(res, auth.forward.url);
|
||||
} catch {
|
||||
return new Response("Auth service unavailable", { status: 503 });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { defaultGatewayHostname, gatewayProxyHeaders } from "../src/gateway.ts";
|
||||
import {
|
||||
defaultGatewayHostname,
|
||||
forwardAuthFailure,
|
||||
forwardAuthHeaders,
|
||||
gatewayProxyHeaders,
|
||||
} from "../src/gateway.ts";
|
||||
|
||||
test("gateway uses platform-safe hostname defaults", () => {
|
||||
expect(defaultGatewayHostname("development")).toBe("127.0.0.1");
|
||||
@@ -17,3 +22,37 @@ test("gateway disables compression for its internal proxy hop", () => {
|
||||
expect(headers.get("x-forwarded-proto")).toBe("http");
|
||||
expect(headers.get("x-forwarded-for")).toBe("127.0.0.1");
|
||||
});
|
||||
|
||||
test("forward auth preserves intentional verifier redirects", () => {
|
||||
const redirected = forwardAuthFailure(
|
||||
new Response(null, { status: 302, headers: { location: "/login?returnTo=%2Fadmin" } }),
|
||||
"http://sso.localhost:3000/api/verify",
|
||||
);
|
||||
const denied = forwardAuthFailure(new Response(null, { status: 401 }), "http://sso.localhost");
|
||||
|
||||
expect(redirected.status).toBe(302);
|
||||
expect(redirected.headers.get("location")).toBe(
|
||||
"http://sso.localhost:3000/login?returnTo=%2Fadmin",
|
||||
);
|
||||
expect(denied.status).toBe(401);
|
||||
expect(denied.headers.has("location")).toBe(false);
|
||||
});
|
||||
|
||||
test("forward auth describes the original gateway request", () => {
|
||||
const headers = forwardAuthHeaders(
|
||||
new Request("https://admin.example.test/settings?tab=security", {
|
||||
headers: {
|
||||
host: "admin.example.test",
|
||||
cookie: "session=abc",
|
||||
authorization: "Bearer token",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(headers.get("x-forwarded-host")).toBe("admin.example.test");
|
||||
expect(headers.get("x-forwarded-proto")).toBe("https");
|
||||
expect(headers.get("x-original-method")).toBe("GET");
|
||||
expect(headers.get("x-original-uri")).toBe("/settings?tab=security");
|
||||
expect(headers.get("cookie")).toBe("session=abc");
|
||||
expect(headers.get("authorization")).toBe("Bearer token");
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/encryption",
|
||||
"version": "0.2.16",
|
||||
"version": "0.2.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# @wrnexus/helpers
|
||||
|
||||
Safe convenience helpers for common WrNexus application flows. The package uses
|
||||
standard `Context`, `URL`, and `Response` values and has no runtime dependency beyond
|
||||
`@wrnexus/core`.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/helpers
|
||||
```
|
||||
|
||||
The package is private, so the machine must be authenticated to the `wrnexus` npm
|
||||
organization.
|
||||
|
||||
## Forward-auth login redirects
|
||||
|
||||
The gateway calls an SSO verifier on a different URL from the original application.
|
||||
These helpers reconstruct the original URL from the gateway headers and safely place it
|
||||
in the login redirect:
|
||||
|
||||
```ts
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import { redirectToLogin } from "@wrnexus/helpers";
|
||||
|
||||
export const GET = async (ctx: Context) => {
|
||||
if (await hasValidSession(ctx)) {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
return redirectToLogin(ctx, "/login", {
|
||||
allowedHosts: ["admin.localhost:3000", "reports.localhost:3000"],
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
This creates a response such as:
|
||||
|
||||
```text
|
||||
Location: http://sso.localhost:3000/login?returnTo=http%3A%2F%2Fadmin.localhost%3A3000%2F
|
||||
```
|
||||
|
||||
Always list the application hosts that are valid redirect destinations. Forwarded host
|
||||
headers are rejected when `allowedHosts` is absent or does not match, preventing an open
|
||||
redirect. A callback can support dynamic tenant domains:
|
||||
|
||||
```ts
|
||||
allowedHosts: (host) => host.endsWith(".example.test");
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
- `getOriginalRequestUrl(ctx, options): URL` — reconstruct the gateway URL.
|
||||
- `getOriginalRequestOrigin(ctx, options): string` — return only its origin.
|
||||
- `getOriginalRequestPath(ctx): string` — return its path and query string.
|
||||
- `getOriginalRequestMethod(ctx): string` — return its HTTP method.
|
||||
- `redirectToLogin(ctx, loginUrl, options): Response` — create a login redirect with an
|
||||
encoded `returnTo` parameter.
|
||||
|
||||
For direct requests without gateway headers, URL helpers use `ctx.url`.
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@wrnexus/helpers",
|
||||
"version": "0.2.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* @wrnexus/helpers — safe conveniences for common WrNexus application flows.
|
||||
*
|
||||
* Helpers stay small and composable. They accept the standard WrNexus Context
|
||||
* and return web-platform values such as URL and Response.
|
||||
*/
|
||||
|
||||
import type { Context } from "@wrnexus/core";
|
||||
|
||||
export type RequestContext = Pick<Context, "req" | "url">;
|
||||
|
||||
export type AllowedHosts =
|
||||
readonly string[] | ReadonlySet<string> | ((host: string, ctx: RequestContext) => boolean);
|
||||
|
||||
export interface OriginalRequestOptions {
|
||||
/**
|
||||
* Hosts that the application permits as redirect destinations. This is
|
||||
* required when a proxy supplied X-Forwarded-Host is present.
|
||||
*/
|
||||
allowedHosts?: AllowedHosts;
|
||||
}
|
||||
|
||||
export interface LoginRedirectOptions extends OriginalRequestOptions {
|
||||
/** Query parameter that receives the original absolute URL. */
|
||||
returnToParam?: string;
|
||||
/** Browser redirect status. Defaults to 302. */
|
||||
status?: 301 | 302 | 303 | 307 | 308;
|
||||
}
|
||||
|
||||
function forwardedValue(ctx: RequestContext, name: string): string | null {
|
||||
const value = ctx.req.headers.get(name)?.trim();
|
||||
if (!value) return null;
|
||||
if (value.includes(",")) {
|
||||
throw new TypeError(`${name} must contain exactly one value`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function hostIsAllowed(host: string, ctx: RequestContext, allowedHosts?: AllowedHosts): boolean {
|
||||
if (!allowedHosts) return false;
|
||||
if (typeof allowedHosts === "function") return allowedHosts(host, ctx);
|
||||
|
||||
const normalized = host.toLowerCase();
|
||||
for (const allowed of allowedHosts) {
|
||||
if (allowed.toLowerCase() === normalized) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Get the original path and query string seen by the gateway. */
|
||||
export function getOriginalRequestPath(ctx: RequestContext): string {
|
||||
const value = forwardedValue(ctx, "x-original-uri") ?? `${ctx.url.pathname}${ctx.url.search}`;
|
||||
if (
|
||||
!value.startsWith("/") ||
|
||||
value.startsWith("//") ||
|
||||
value.includes("\\") ||
|
||||
value.includes("#")
|
||||
) {
|
||||
throw new TypeError("x-original-uri must be an absolute request path");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Get the original HTTP method seen by the gateway. */
|
||||
export function getOriginalRequestMethod(ctx: RequestContext): string {
|
||||
const method = forwardedValue(ctx, "x-original-method") ?? ctx.req.method;
|
||||
if (!/^[A-Za-z]+$/.test(method)) throw new TypeError("x-original-method is invalid");
|
||||
return method.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct the absolute URL that reached the gateway.
|
||||
*
|
||||
* Forwarded hosts are never trusted implicitly: pass allowedHosts when this is
|
||||
* used behind the WrNexus gateway. Direct requests fall back to ctx.url.
|
||||
*/
|
||||
export function getOriginalRequestUrl(
|
||||
ctx: RequestContext,
|
||||
options: OriginalRequestOptions = {},
|
||||
): URL {
|
||||
const host = forwardedValue(ctx, "x-forwarded-host");
|
||||
const path = getOriginalRequestPath(ctx);
|
||||
|
||||
if (!host) return new URL(path, ctx.url.origin);
|
||||
if (!hostIsAllowed(host, ctx, options.allowedHosts)) {
|
||||
throw new TypeError(`Untrusted forwarded host: ${host}`);
|
||||
}
|
||||
|
||||
const protocol = (forwardedValue(ctx, "x-forwarded-proto") ?? ctx.url.protocol).replace(/:$/, "");
|
||||
if (protocol !== "http" && protocol !== "https") {
|
||||
throw new TypeError(`Unsupported forwarded protocol: ${protocol}`);
|
||||
}
|
||||
|
||||
const origin = new URL(`${protocol}://${host}`);
|
||||
if (origin.username || origin.password || origin.pathname !== "/") {
|
||||
throw new TypeError(`Invalid forwarded host: ${host}`);
|
||||
}
|
||||
|
||||
const original = new URL(path, origin);
|
||||
if (original.origin !== origin.origin) {
|
||||
throw new TypeError("Original request URL must stay on the forwarded origin");
|
||||
}
|
||||
return original;
|
||||
}
|
||||
|
||||
/** Get the original request origin, for example http://admin.localhost:3000. */
|
||||
export function getOriginalRequestOrigin(
|
||||
ctx: RequestContext,
|
||||
options: OriginalRequestOptions = {},
|
||||
): string {
|
||||
return getOriginalRequestUrl(ctx, options).origin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to a login page with the original absolute URL encoded as returnTo.
|
||||
* Relative login URLs resolve against the current app (normally the SSO app).
|
||||
*/
|
||||
export function redirectToLogin(
|
||||
ctx: RequestContext,
|
||||
loginUrl: string | URL,
|
||||
options: LoginRedirectOptions = {},
|
||||
): Response {
|
||||
const target = new URL(loginUrl, ctx.url.origin);
|
||||
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
||||
throw new TypeError("Login URL must use http or https");
|
||||
}
|
||||
|
||||
const original = getOriginalRequestUrl(ctx, options);
|
||||
target.searchParams.set(options.returnToParam ?? "returnTo", original.href);
|
||||
return Response.redirect(target, options.status ?? 302);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createContext } from "@wrnexus/core";
|
||||
import {
|
||||
getOriginalRequestMethod,
|
||||
getOriginalRequestOrigin,
|
||||
getOriginalRequestPath,
|
||||
getOriginalRequestUrl,
|
||||
redirectToLogin,
|
||||
} from "../src/index.ts";
|
||||
|
||||
function context(url: string, headers: HeadersInit = {}) {
|
||||
const parsed = new URL(url);
|
||||
return createContext(new Request(parsed, { headers }), parsed);
|
||||
}
|
||||
|
||||
test("uses the direct context URL when no gateway headers exist", () => {
|
||||
const ctx = context("https://app.example.test/account?tab=security");
|
||||
|
||||
expect(getOriginalRequestUrl(ctx).href).toBe("https://app.example.test/account?tab=security");
|
||||
expect(getOriginalRequestOrigin(ctx)).toBe("https://app.example.test");
|
||||
expect(getOriginalRequestPath(ctx)).toBe("/account?tab=security");
|
||||
expect(getOriginalRequestMethod(ctx)).toBe("GET");
|
||||
});
|
||||
|
||||
test("reconstructs an allowed original gateway URL", () => {
|
||||
const ctx = context("http://sso.localhost:3000/api/verify", {
|
||||
"x-forwarded-host": "admin.localhost:3000",
|
||||
"x-forwarded-proto": "http",
|
||||
"x-original-method": "GET",
|
||||
"x-original-uri": "/users?page=2",
|
||||
});
|
||||
|
||||
const url = getOriginalRequestUrl(ctx, { allowedHosts: ["admin.localhost:3000"] });
|
||||
expect(url.href).toBe("http://admin.localhost:3000/users?page=2");
|
||||
expect(getOriginalRequestMethod(ctx)).toBe("GET");
|
||||
});
|
||||
|
||||
test("rejects untrusted hosts and unsafe request paths", () => {
|
||||
const untrusted = context("http://sso.localhost/api/verify", {
|
||||
"x-forwarded-host": "evil.example",
|
||||
"x-original-uri": "/",
|
||||
});
|
||||
const unsafePath = context("http://sso.localhost/api/verify", {
|
||||
"x-forwarded-host": "admin.localhost",
|
||||
"x-original-uri": "//evil.example/steal",
|
||||
});
|
||||
|
||||
expect(() => getOriginalRequestUrl(untrusted)).toThrow("Untrusted forwarded host");
|
||||
expect(() => getOriginalRequestUrl(unsafePath, { allowedHosts: ["admin.localhost"] })).toThrow(
|
||||
"absolute request path",
|
||||
);
|
||||
});
|
||||
|
||||
test("creates a safe login redirect with an encoded returnTo URL", () => {
|
||||
const ctx = context("http://sso.localhost:3000/api/verify", {
|
||||
"x-forwarded-host": "admin.localhost:3000",
|
||||
"x-forwarded-proto": "http",
|
||||
"x-original-uri": "/reports?range=week",
|
||||
});
|
||||
|
||||
const response = redirectToLogin(ctx, "/login", {
|
||||
allowedHosts: new Set(["admin.localhost:3000"]),
|
||||
});
|
||||
const location = new URL(response.headers.get("location")!);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(location.origin).toBe("http://sso.localhost:3000");
|
||||
expect(location.pathname).toBe("/login");
|
||||
expect(location.searchParams.get("returnTo")).toBe(
|
||||
"http://admin.localhost:3000/reports?range=week",
|
||||
);
|
||||
});
|
||||
|
||||
test("supports an allowed-host callback and custom response options", () => {
|
||||
const ctx = context("https://login.example.test/api/verify", {
|
||||
"x-forwarded-host": "reports.example.test",
|
||||
"x-forwarded-proto": "https",
|
||||
"x-original-uri": "/",
|
||||
});
|
||||
const response = redirectToLogin(ctx, "https://login.example.test/sign-in?tenant=acme", {
|
||||
allowedHosts: (host) => host.endsWith(".example.test"),
|
||||
returnToParam: "next",
|
||||
status: 303,
|
||||
});
|
||||
const location = new URL(response.headers.get("location")!);
|
||||
|
||||
expect(response.status).toBe(303);
|
||||
expect(location.searchParams.get("tenant")).toBe("acme");
|
||||
expect(location.searchParams.get("next")).toBe("https://reports.example.test/");
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/i18n",
|
||||
"version": "0.2.16",
|
||||
"version": "0.2.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/jwt",
|
||||
"version": "0.2.16",
|
||||
"version": "0.2.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/mobile",
|
||||
"version": "0.2.16",
|
||||
"version": "0.2.17",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/native",
|
||||
"version": "0.2.16",
|
||||
"version": "0.2.17",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/oauth",
|
||||
"version": "0.2.16",
|
||||
"version": "0.2.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/pubsub",
|
||||
"version": "0.2.16",
|
||||
"version": "0.2.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/queue",
|
||||
"version": "0.2.16",
|
||||
"version": "0.2.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/reactive",
|
||||
"version": "0.2.16",
|
||||
"version": "0.2.17",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/router",
|
||||
"version": "0.2.16",
|
||||
"version": "0.2.17",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ssr",
|
||||
"version": "0.2.16",
|
||||
"version": "0.2.17",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/styles",
|
||||
"version": "0.2.16",
|
||||
"version": "0.2.17",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/test",
|
||||
"version": "0.2.16",
|
||||
"version": "0.2.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/tracking",
|
||||
"version": "0.2.16",
|
||||
"version": "0.2.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ui",
|
||||
"version": "0.2.16",
|
||||
"version": "0.2.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/uploader",
|
||||
"version": "0.2.16",
|
||||
"version": "0.2.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/validation",
|
||||
"version": "0.2.16",
|
||||
"version": "0.2.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
Reference in New Issue
Block a user