feat: add helpers and improve workspace auth flows

This commit is contained in:
2026-07-13 13:53:36 +05:30
parent 88e907783a
commit b4e5fade19
74 changed files with 853 additions and 131 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ai",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/authz",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"description": "@wrnexus/authz — part of the WrNexus framework.",
"license": "MIT",
+8
View File
@@ -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
+11 -11
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"description": "@wrnexus/cli — part of the WrNexus framework.",
"license": "MIT",
@@ -28,16 +28,16 @@
"wrnexus": "./dist/index.js"
},
"dependencies": {
"@wrnexus/core": "^0.2.16",
"@wrnexus/router": "^0.2.16",
"@wrnexus/csr": "^0.2.16",
"@wrnexus/compiler": "^0.2.16",
"@wrnexus/styles": "^0.2.16",
"@wrnexus/dev-server": "^0.2.16",
"@wrnexus/ui": "^0.2.16",
"@wrnexus/validation": "^0.2.16",
"@wrnexus/i18n": "^0.2.16",
"@wrnexus/db": "^0.2.16"
"@wrnexus/core": "^0.2.17",
"@wrnexus/router": "^0.2.17",
"@wrnexus/csr": "^0.2.17",
"@wrnexus/compiler": "^0.2.17",
"@wrnexus/styles": "^0.2.17",
"@wrnexus/dev-server": "^0.2.17",
"@wrnexus/ui": "^0.2.17",
"@wrnexus/validation": "^0.2.17",
"@wrnexus/i18n": "^0.2.17",
"@wrnexus/db": "^0.2.17"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/compiler",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"description": "@wrnexus/compiler — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/core",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"description": "@wrnexus/core — part of the WrNexus framework.",
"license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"description": "@wrnexus/csr — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.2.16"
"@wrnexus/core": "^0.2.17"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/db",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"description": "@wrnexus/db — part of the WrNexus framework.",
"license": "MIT",
+13
View File
@@ -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
+13 -13
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"description": "@wrnexus/dev-server — part of the WrNexus framework.",
"license": "MIT",
@@ -25,18 +25,18 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.2.16",
"@wrnexus/router": "^0.2.16",
"@wrnexus/ssr": "^0.2.16",
"@wrnexus/csr": "^0.2.16",
"@wrnexus/compiler": "^0.2.16",
"@wrnexus/styles": "^0.2.16",
"@wrnexus/ui": "^0.2.16",
"@wrnexus/validation": "^0.2.16",
"@wrnexus/i18n": "^0.2.16",
"@wrnexus/db": "^0.2.16",
"@wrnexus/pubsub": "^0.2.16",
"@wrnexus/uploader": "^0.2.16"
"@wrnexus/core": "^0.2.17",
"@wrnexus/router": "^0.2.17",
"@wrnexus/ssr": "^0.2.17",
"@wrnexus/csr": "^0.2.17",
"@wrnexus/compiler": "^0.2.17",
"@wrnexus/styles": "^0.2.17",
"@wrnexus/ui": "^0.2.17",
"@wrnexus/validation": "^0.2.17",
"@wrnexus/i18n": "^0.2.17",
"@wrnexus/db": "^0.2.17",
"@wrnexus/pubsub": "^0.2.17",
"@wrnexus/uploader": "^0.2.17"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/encryption",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"description": "@wrnexus/encryption — part of the WrNexus framework.",
"license": "MIT",
+60
View File
@@ -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`.
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@wrnexus/helpers",
"version": "0.2.17",
"type": "module",
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
"license": "MIT",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"engines": {
"bun": ">=1.1.0"
},
"publishConfig": {
"registry": "https://registry.npmjs.org/",
"access": "restricted"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"dependencies": {
"@wrnexus/core": "^0.2.17"
},
"files": [
"dist"
]
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/i18n",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"description": "@wrnexus/i18n — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.2.16"
"@wrnexus/core": "^0.2.17"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/jwt",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"description": "@wrnexus/jwt — part of the WrNexus framework.",
"license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/mobile",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"description": "@wrnexus/mobile — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/native": "^0.2.16"
"@wrnexus/native": "^0.2.17"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/native",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"description": "@wrnexus/native — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/oauth",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"description": "@wrnexus/oauth — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/pubsub",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"description": "@wrnexus/pubsub — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/queue",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"description": "@wrnexus/queue — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/reactive",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"description": "@wrnexus/reactive — part of the WrNexus framework.",
"license": "MIT",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/router",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"description": "@wrnexus/router — part of the WrNexus framework.",
"license": "MIT",
@@ -21,8 +21,8 @@
}
},
"dependencies": {
"@wrnexus/compiler": "^0.2.16",
"@wrnexus/core": "^0.2.16"
"@wrnexus/compiler": "^0.2.17",
"@wrnexus/core": "^0.2.17"
},
"files": [
"dist"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ssr",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"description": "@wrnexus/ssr — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.2.16"
"@wrnexus/core": "^0.2.17"
},
"files": [
"dist"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/styles",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"description": "@wrnexus/styles — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/uploader": "^0.2.16"
"@wrnexus/uploader": "^0.2.17"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/test",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"description": "@wrnexus/test — part of the WrNexus framework.",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/tracking",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"description": "@wrnexus/tracking — part of the WrNexus framework.",
"license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ui",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"description": "@wrnexus/ui — part of the WrNexus framework.",
"license": "MIT",
@@ -22,7 +22,7 @@
"./ui.css": "./ui.css"
},
"dependencies": {
"@wrnexus/core": "^0.2.16"
"@wrnexus/core": "^0.2.17"
},
"files": [
"dist",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/uploader",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"description": "@wrnexus/uploader — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
"@wrnexus/core": "^0.2.16"
"@wrnexus/core": "^0.2.17"
},
"files": [
"dist"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/validation",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"description": "@wrnexus/validation — part of the WrNexus framework.",
"license": "MIT",
+3 -1
View File
@@ -1384,10 +1384,11 @@ self-skips without `WRNEXUS_PG_URL` / `WRNEXUS_MYSQL_URL`.
## Optional packages
Opt-in, dependency-free packages (Bun/WebCrypto natives) — import only what you need:
Opt-in helpers and feature packages — import only what you need:
| Package | What it gives you |
| --------------------- | ------------------------------------------------------------------------------------- |
| `@wrnexus/helpers` | Context URL helpers and safe forward-auth `redirectToLogin` responses |
| `@wrnexus/jwt` | HS256 `signJwt`/`verifyJwt` + `jwtAuth` bearer middleware (stateless auth) |
| `@wrnexus/oauth` | OAuth 2.0 sign-in with PKCE — Google/GitHub/Discord presets + `defineProvider` |
| `@wrnexus/authz` | Authorization: RBAC (`defineRbac`), PBAC/ABAC policies (`any`/`all`/`attr`), guards |
@@ -1399,6 +1400,7 @@ Opt-in, dependency-free packages (Bun/WebCrypto natives) — import only what yo
```ts
import { signJwt, jwtAuth } from "@wrnexus/jwt";
import { getOriginalRequestUrl, redirectToLogin } from "@wrnexus/helpers";
import { google, startAuth, completeAuth } from "@wrnexus/oauth";
import { defineRbac, requirePermission, any, attr } from "@wrnexus/authz";
import { encrypt, decrypt, generateKey, sha256, hmacSign } from "@wrnexus/encryption";
+34 -25
View File
@@ -35,15 +35,15 @@
},
"packages/ai": {
"name": "@wrnexus/ai",
"version": "0.2.11",
"version": "0.2.17",
},
"packages/authz": {
"name": "@wrnexus/authz",
"version": "0.2.11",
"version": "0.2.17",
},
"packages/cli": {
"name": "@wrnexus/cli",
"version": "0.2.11",
"version": "0.2.17",
"bin": {
"wrnexus": "src/index.ts",
},
@@ -62,26 +62,26 @@
},
"packages/compiler": {
"name": "@wrnexus/compiler",
"version": "0.2.11",
"version": "0.2.17",
},
"packages/core": {
"name": "@wrnexus/core",
"version": "0.2.11",
"version": "0.2.17",
},
"packages/csr": {
"name": "@wrnexus/csr",
"version": "0.2.11",
"version": "0.2.17",
"dependencies": {
"@wrnexus/core": "workspace:*",
},
},
"packages/db": {
"name": "@wrnexus/db",
"version": "0.2.11",
"version": "0.2.17",
},
"packages/dev-server": {
"name": "@wrnexus/dev-server",
"version": "0.2.11",
"version": "0.2.17",
"dependencies": {
"@wrnexus/compiler": "workspace:*",
"@wrnexus/core": "workspace:*",
@@ -99,49 +99,56 @@
},
"packages/encryption": {
"name": "@wrnexus/encryption",
"version": "0.2.11",
"version": "0.2.17",
},
"packages/helpers": {
"name": "@wrnexus/helpers",
"version": "0.2.17",
"dependencies": {
"@wrnexus/core": "workspace:*",
},
},
"packages/i18n": {
"name": "@wrnexus/i18n",
"version": "0.2.11",
"version": "0.2.17",
"dependencies": {
"@wrnexus/core": "workspace:*",
},
},
"packages/jwt": {
"name": "@wrnexus/jwt",
"version": "0.2.11",
"version": "0.2.17",
},
"packages/mobile": {
"name": "@wrnexus/mobile",
"version": "0.2.11",
"version": "0.2.17",
"dependencies": {
"@wrnexus/native": "workspace:*",
},
},
"packages/native": {
"name": "@wrnexus/native",
"version": "0.2.11",
"version": "0.2.17",
},
"packages/oauth": {
"name": "@wrnexus/oauth",
"version": "0.2.11",
"version": "0.2.17",
},
"packages/pubsub": {
"name": "@wrnexus/pubsub",
"version": "0.2.11",
"version": "0.2.17",
},
"packages/queue": {
"name": "@wrnexus/queue",
"version": "0.2.11",
"version": "0.2.17",
},
"packages/reactive": {
"name": "@wrnexus/reactive",
"version": "0.2.11",
"version": "0.2.17",
},
"packages/router": {
"name": "@wrnexus/router",
"version": "0.2.11",
"version": "0.2.17",
"dependencies": {
"@wrnexus/compiler": "workspace:*",
"@wrnexus/core": "workspace:*",
@@ -149,43 +156,43 @@
},
"packages/ssr": {
"name": "@wrnexus/ssr",
"version": "0.2.11",
"version": "0.2.17",
"dependencies": {
"@wrnexus/core": "workspace:*",
},
},
"packages/styles": {
"name": "@wrnexus/styles",
"version": "0.2.11",
"version": "0.2.17",
"dependencies": {
"@wrnexus/uploader": "workspace:*",
},
},
"packages/test": {
"name": "@wrnexus/test",
"version": "0.2.11",
"version": "0.2.17",
},
"packages/tracking": {
"name": "@wrnexus/tracking",
"version": "0.2.11",
"version": "0.2.17",
},
"packages/ui": {
"name": "@wrnexus/ui",
"version": "0.2.11",
"version": "0.2.17",
"dependencies": {
"@wrnexus/core": "workspace:*",
},
},
"packages/uploader": {
"name": "@wrnexus/uploader",
"version": "0.2.11",
"version": "0.2.17",
"dependencies": {
"@wrnexus/core": "workspace:*",
},
},
"packages/validation": {
"name": "@wrnexus/validation",
"version": "0.2.11",
"version": "0.2.17",
},
},
"overrides": {
@@ -440,6 +447,8 @@
"@wrnexus/encryption": ["@wrnexus/encryption@workspace:packages/encryption"],
"@wrnexus/helpers": ["@wrnexus/helpers@workspace:packages/helpers"],
"@wrnexus/i18n": ["@wrnexus/i18n@workspace:packages/i18n"],
"@wrnexus/jwt": ["@wrnexus/jwt@workspace:packages/jwt"],
+6 -1
View File
@@ -1020,8 +1020,11 @@ page Users {
## 27. Optional packages
Dependencyfree (Bun/WebCrypto). Import only what you need.
Convenience helpers and dependency-free feature packages. Import only what you need.
- **`@wrnexus/helpers`** — context helpers including `getOriginalRequestUrl`,
`getOriginalRequestOrigin`, `getOriginalRequestPath`, `getOriginalRequestMethod`, and the safe
forward-auth `redirectToLogin(ctx, "/login", { allowedHosts })` response helper.
- **`@wrnexus/jwt`** — `signJwt(payload, secret, { expiresIn?, now? })`, `verifyJwt(token, secret)`
(throws `JwtError`), `jwtAuth({ secret, getToken?, required? })` middleware.
- **`@wrnexus/oauth`** — PKCE OAuth 2.0. Presets `google/github/discord(creds)` + `defineProvider`.
@@ -1226,6 +1229,7 @@ wrnexus dev [app-dir] [--port=3000] [--profile=<name>] Dev server (live reload
wrnexus build [app-dir] [--profile=<name>] Production bundle → dist/ (production profile by default)
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] [--prod] Serve every workspace app behind one port, routed by domain
wrnexus generate <type> <name> (alias: g) Scaffold: page | component | api | schema
wrnexus generate routes Regenerate app/routes.gen.ts
@@ -1353,6 +1357,7 @@ directive to `false`/`null` to remove it.
- `@wrnexus/dev-server` — `startServer`, `startGateway` (multi-app host router), prod handler.
- `@wrnexus/cli` — the `wrnexus` binary; subpath `/workspace` (`WorkspaceConfig`, `startGateway` wiring).
- `@wrnexus/validation` — `v`, `parseBody`, `parseEnv`, `invalid`, `renderSchemasScript`.
- `@wrnexus/helpers` — original gateway URL/path/method helpers and `redirectToLogin`.
- `@wrnexus/ui` — `uiComponentsDir`, `uiCssPath`, `uiCss`, `uiComponentNames`.
- `@wrnexus/i18n` — `loadLocales`, `resolveI18n`, `makeT`, `resolveLang`, `translateHtml`,
`formatNumber`, `formatCurrency`, `formatDate`, `formatRelativeTime`, `plural`.
+1 -1
View File
@@ -1,6 +1,6 @@
# Publishing WrNexus to npm
All 25 `@wrnexus/*` packages are built and published to the **public npm registry**
All 26 `@wrnexus/*` packages are built and published to the **public npm registry**
(`registry.npmjs.org`), then installed on any machine with **Bun**.
## Prerequisites
+17
View File
@@ -160,6 +160,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
@@ -243,7 +258,9 @@ export const POST = async (ctx) => {
```
wrnexus dev . # dev server + HMR
wrnexus build . # production build → dist/server.js
bun dist/server.js # run the production server (or npm start)
wrnexus create <name> # scaffold a new app
wrnexus update --latest # deps + syntax/config migrations + verification
wrnexus generate page <Name> # scaffold a page (aliases: g p)
wrnexus generate component <name> | api <path> | schema <name>
wrnexus db migrate | rollback | status | new [--from-models] | generate | seed
+1 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/authz",
"version": "0.2.16",
"version": "0.2.17",
"private": true,
"type": "module",
"main": "src/index.ts",
+8
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"main": "src/index.ts",
"exports": {
+15
View File
@@ -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
+2
View File
@@ -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
+8 -2
View File
@@ -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
View File
@@ -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.");
}
+2
View File
@@ -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"',
);
+49 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/compiler",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/core",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/db",
"version": "0.2.16",
"version": "0.2.17",
"private": true,
"type": "module",
"main": "src/index.ts",
+13
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"main": "src/index.ts",
"exports": {
+31 -8
View File
@@ -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 });
}
+40 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/encryption",
"version": "0.2.16",
"version": "0.2.17",
"private": true,
"type": "module",
"main": "src/index.ts",
+60
View File
@@ -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`.
+14
View File
@@ -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:*"
}
}
+131
View File
@@ -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);
}
+90
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/i18n",
"version": "0.2.16",
"version": "0.2.17",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/jwt",
"version": "0.2.16",
"version": "0.2.17",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/mobile",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/native",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/oauth",
"version": "0.2.16",
"version": "0.2.17",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/pubsub",
"version": "0.2.16",
"version": "0.2.17",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/queue",
"version": "0.2.16",
"version": "0.2.17",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/reactive",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/router",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ssr",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/styles",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/test",
"version": "0.2.16",
"version": "0.2.17",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/tracking",
"version": "0.2.16",
"version": "0.2.17",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ui",
"version": "0.2.16",
"version": "0.2.17",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/uploader",
"version": "0.2.16",
"version": "0.2.17",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/validation",
"version": "0.2.16",
"version": "0.2.17",
"private": true,
"type": "module",
"main": "src/index.ts",
+1
View File
@@ -30,6 +30,7 @@
"@wrnexus/jwt": ["./packages/jwt/src/index.ts"],
"@wrnexus/oauth": ["./packages/oauth/src/index.ts"],
"@wrnexus/authz": ["./packages/authz/src/index.ts"],
"@wrnexus/helpers": ["./packages/helpers/src/index.ts"],
"@wrnexus/encryption": ["./packages/encryption/src/index.ts"],
"@wrnexus/pubsub": ["./packages/pubsub/src/index.ts"],
"@wrnexus/queue": ["./packages/queue/src/index.ts"],