From b4e5fade195a14b78abd35cb649692cf4569a418 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Mon, 13 Jul 2026 13:53:36 +0530 Subject: [PATCH] feat: add helpers and improve workspace auth flows --- .publish/ai/package.json | 2 +- .publish/authz/package.json | 2 +- .publish/cli/README.md | 8 ++ .publish/cli/package.json | 22 ++-- .publish/compiler/package.json | 2 +- .publish/core/package.json | 2 +- .publish/csr/package.json | 4 +- .publish/db/package.json | 2 +- .publish/dev-server/README.md | 13 ++ .publish/dev-server/package.json | 26 ++-- .publish/encryption/package.json | 2 +- .publish/helpers/README.md | 60 +++++++++ .publish/helpers/package.json | 29 +++++ .publish/i18n/package.json | 4 +- .publish/jwt/package.json | 2 +- .publish/mobile/package.json | 4 +- .publish/native/package.json | 2 +- .publish/oauth/package.json | 2 +- .publish/pubsub/package.json | 2 +- .publish/queue/package.json | 2 +- .publish/reactive/package.json | 2 +- .publish/router/package.json | 6 +- .publish/ssr/package.json | 4 +- .publish/styles/package.json | 4 +- .publish/test/package.json | 2 +- .publish/tracking/package.json | 2 +- .publish/ui/package.json | 4 +- .publish/uploader/package.json | 4 +- .publish/validation/package.json | 2 +- README.md | 4 +- bun.lock | 59 +++++---- docs/GUIDE.md | 7 +- docs/PUBLISHING.md | 2 +- llms.txt | 17 +++ packages/ai/package.json | 2 +- packages/authz/package.json | 2 +- packages/cli/README.md | 8 ++ packages/cli/package.json | 2 +- packages/cli/src/ai-guide.ts | 15 +++ packages/cli/src/create.ts | 2 + packages/cli/src/index.ts | 10 +- packages/cli/src/workspace.ts | 147 +++++++++++++++++++++-- packages/cli/test/create.test.ts | 2 + packages/cli/test/workspace.test.ts | 50 +++++++- packages/compiler/package.json | 2 +- packages/core/package.json | 2 +- packages/csr/package.json | 2 +- packages/db/package.json | 2 +- packages/dev-server/README.md | 13 ++ packages/dev-server/package.json | 2 +- packages/dev-server/src/gateway.ts | 39 ++++-- packages/dev-server/test/gateway.test.ts | 41 ++++++- packages/encryption/package.json | 2 +- packages/helpers/README.md | 60 +++++++++ packages/helpers/package.json | 14 +++ packages/helpers/src/index.ts | 131 ++++++++++++++++++++ packages/helpers/test/helpers.test.ts | 90 ++++++++++++++ packages/i18n/package.json | 2 +- packages/jwt/package.json | 2 +- packages/mobile/package.json | 2 +- packages/native/package.json | 2 +- packages/oauth/package.json | 2 +- packages/pubsub/package.json | 2 +- packages/queue/package.json | 2 +- packages/reactive/package.json | 2 +- packages/router/package.json | 2 +- packages/ssr/package.json | 2 +- packages/styles/package.json | 2 +- packages/test/package.json | 2 +- packages/tracking/package.json | 2 +- packages/ui/package.json | 2 +- packages/uploader/package.json | 2 +- packages/validation/package.json | 2 +- tsconfig.json | 1 + 74 files changed, 853 insertions(+), 131 deletions(-) create mode 100644 .publish/helpers/README.md create mode 100644 .publish/helpers/package.json create mode 100644 packages/helpers/README.md create mode 100644 packages/helpers/package.json create mode 100644 packages/helpers/src/index.ts create mode 100644 packages/helpers/test/helpers.test.ts diff --git a/.publish/ai/package.json b/.publish/ai/package.json index 886cf6d9..e3d44324 100644 --- a/.publish/ai/package.json +++ b/.publish/ai/package.json @@ -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", diff --git a/.publish/authz/package.json b/.publish/authz/package.json index 4bc17d4e..6a8e772e 100644 --- a/.publish/authz/package.json +++ b/.publish/authz/package.json @@ -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", diff --git a/.publish/cli/README.md b/.publish/cli/README.md index a28f6407..f1db00b8 100644 --- a/.publish/cli/README.md +++ b/.publish/cli/README.md @@ -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 ` | Scaffold a new single app from an inline template. | | `wrnexus workspace ` | Scaffold a monorepo (`apps/*` + shared `packages/*`). | +| `wrnexus workspace add ` | 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 ` | 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 diff --git a/.publish/cli/package.json b/.publish/cli/package.json index e5718608..26dd58d0 100644 --- a/.publish/cli/package.json +++ b/.publish/cli/package.json @@ -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" diff --git a/.publish/compiler/package.json b/.publish/compiler/package.json index 9da5719d..4e07ba73 100644 --- a/.publish/compiler/package.json +++ b/.publish/compiler/package.json @@ -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", diff --git a/.publish/core/package.json b/.publish/core/package.json index abe6c683..3cf50c51 100644 --- a/.publish/core/package.json +++ b/.publish/core/package.json @@ -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", diff --git a/.publish/csr/package.json b/.publish/csr/package.json index 3d25a8ba..a6b9ad2c 100644 --- a/.publish/csr/package.json +++ b/.publish/csr/package.json @@ -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" diff --git a/.publish/db/package.json b/.publish/db/package.json index 669c5341..ea2bb3e1 100644 --- a/.publish/db/package.json +++ b/.publish/db/package.json @@ -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", diff --git a/.publish/dev-server/README.md b/.publish/dev-server/README.md index cfe004d0..105e193f 100644 --- a/.publish/dev-server/README.md +++ b/.publish/dev-server/README.md @@ -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 diff --git a/.publish/dev-server/package.json b/.publish/dev-server/package.json index c00d362d..3a7c4b3c 100644 --- a/.publish/dev-server/package.json +++ b/.publish/dev-server/package.json @@ -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" diff --git a/.publish/encryption/package.json b/.publish/encryption/package.json index 678299df..88919843 100644 --- a/.publish/encryption/package.json +++ b/.publish/encryption/package.json @@ -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", diff --git a/.publish/helpers/README.md b/.publish/helpers/README.md new file mode 100644 index 00000000..b796b19a --- /dev/null +++ b/.publish/helpers/README.md @@ -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`. diff --git a/.publish/helpers/package.json b/.publish/helpers/package.json new file mode 100644 index 00000000..b93c830d --- /dev/null +++ b/.publish/helpers/package.json @@ -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" + ] +} diff --git a/.publish/i18n/package.json b/.publish/i18n/package.json index b43ac999..64a1e5ca 100644 --- a/.publish/i18n/package.json +++ b/.publish/i18n/package.json @@ -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" diff --git a/.publish/jwt/package.json b/.publish/jwt/package.json index 00659bc3..16ba3517 100644 --- a/.publish/jwt/package.json +++ b/.publish/jwt/package.json @@ -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", diff --git a/.publish/mobile/package.json b/.publish/mobile/package.json index e837a1de..a46842e3 100644 --- a/.publish/mobile/package.json +++ b/.publish/mobile/package.json @@ -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" diff --git a/.publish/native/package.json b/.publish/native/package.json index 8b480a5a..76a27870 100644 --- a/.publish/native/package.json +++ b/.publish/native/package.json @@ -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", diff --git a/.publish/oauth/package.json b/.publish/oauth/package.json index 0a315895..d128dcf8 100644 --- a/.publish/oauth/package.json +++ b/.publish/oauth/package.json @@ -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", diff --git a/.publish/pubsub/package.json b/.publish/pubsub/package.json index 252f9b37..001a2a20 100644 --- a/.publish/pubsub/package.json +++ b/.publish/pubsub/package.json @@ -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", diff --git a/.publish/queue/package.json b/.publish/queue/package.json index 0ab7eafe..0f9bde00 100644 --- a/.publish/queue/package.json +++ b/.publish/queue/package.json @@ -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", diff --git a/.publish/reactive/package.json b/.publish/reactive/package.json index a0e1978c..5b4d7912 100644 --- a/.publish/reactive/package.json +++ b/.publish/reactive/package.json @@ -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", diff --git a/.publish/router/package.json b/.publish/router/package.json index f2782c23..7f7471b2 100644 --- a/.publish/router/package.json +++ b/.publish/router/package.json @@ -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" diff --git a/.publish/ssr/package.json b/.publish/ssr/package.json index 6fcfaf71..81768fdf 100644 --- a/.publish/ssr/package.json +++ b/.publish/ssr/package.json @@ -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" diff --git a/.publish/styles/package.json b/.publish/styles/package.json index 566680eb..4a83b780 100644 --- a/.publish/styles/package.json +++ b/.publish/styles/package.json @@ -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" diff --git a/.publish/test/package.json b/.publish/test/package.json index 47ed475e..f11afbf7 100644 --- a/.publish/test/package.json +++ b/.publish/test/package.json @@ -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", diff --git a/.publish/tracking/package.json b/.publish/tracking/package.json index 3080fcc4..8db57371 100644 --- a/.publish/tracking/package.json +++ b/.publish/tracking/package.json @@ -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", diff --git a/.publish/ui/package.json b/.publish/ui/package.json index 2962a96c..b58089b5 100644 --- a/.publish/ui/package.json +++ b/.publish/ui/package.json @@ -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", diff --git a/.publish/uploader/package.json b/.publish/uploader/package.json index 1ea2cda7..37218bc8 100644 --- a/.publish/uploader/package.json +++ b/.publish/uploader/package.json @@ -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" diff --git a/.publish/validation/package.json b/.publish/validation/package.json index 752fb34b..8fc78f86 100644 --- a/.publish/validation/package.json +++ b/.publish/validation/package.json @@ -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", diff --git a/README.md b/README.md index c6b6b8cc..423aa502 100644 --- a/README.md +++ b/README.md @@ -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"; diff --git a/bun.lock b/bun.lock index 4d4711bb..07149327 100644 --- a/bun.lock +++ b/bun.lock @@ -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"], diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 0029b4c0..0c45d378 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -1020,8 +1020,11 @@ page Users { ## 27. Optional packages -Dependency‑free (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=] Dev server (live reload wrnexus build [app-dir] [--profile=] Production bundle → dist/ (production profile by default) wrnexus create Scaffold a new app wrnexus workspace Scaffold a monorepo (apps/* + shared packages/*) +wrnexus workspace add [--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 (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`. diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md index 409210f0..fe373a92 100644 --- a/docs/PUBLISHING.md +++ b/docs/PUBLISHING.md @@ -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 diff --git a/llms.txt b/llms.txt index 6ce52319..34305ccc 100644 --- a/llms.txt +++ b/llms.txt @@ -160,6 +160,21 @@ export const POST = async (ctx) => { `req: Request`, `url: URL`, `params: Record` (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 # scaffold a new app +wrnexus update --latest # deps + syntax/config migrations + verification wrnexus generate page # scaffold a page (aliases: g p) wrnexus generate component | api | schema wrnexus db migrate | rollback | status | new [--from-models] | generate | seed diff --git a/packages/ai/package.json b/packages/ai/package.json index cd6d5deb..831f8ade 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -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.", diff --git a/packages/authz/package.json b/packages/authz/package.json index 034d7c76..cecbdb8e 100644 --- a/packages/authz/package.json +++ b/packages/authz/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/authz", - "version": "0.2.16", + "version": "0.2.17", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/cli/README.md b/packages/cli/README.md index a28f6407..f1db00b8 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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 ` | Scaffold a new single app from an inline template. | | `wrnexus workspace ` | Scaffold a monorepo (`apps/*` + shared `packages/*`). | +| `wrnexus workspace add ` | 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 ` | 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 diff --git a/packages/cli/package.json b/packages/cli/package.json index 772cb02f..f86daf7b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/cli", - "version": "0.2.16", + "version": "0.2.17", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/cli/src/ai-guide.ts b/packages/cli/src/ai-guide.ts index 26a79e83..fc5648dd 100644 --- a/packages/cli/src/ai-guide.ts +++ b/packages/cli/src/ai-guide.ts @@ -167,6 +167,21 @@ export const POST = async (ctx) => { \`req: Request\`, \`url: URL\`, \`params: Record\` (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 diff --git a/packages/cli/src/create.ts b/packages/cli/src/create.ts index 9d8c379b..ba81269e 100644 --- a/packages/cli/src/create.ts +++ b/packages/cli/src/create.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 diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 34e369d0..11074134 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -43,6 +43,8 @@ Usage: wrnexus build [app-dir] Build a production server bundle + assets wrnexus create Scaffold a new app wrnexus workspace Scaffold a monorepo (apps/* + shared packages/*) + wrnexus workspace add [--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 Scaffold a page | component | api | schema wrnexus generate routes | docker | mobile @@ -92,8 +94,12 @@ async function main(): Promise { 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": { diff --git a/packages/cli/src/workspace.ts b/packages/cli/src/workspace.ts index 1223cefe..7f7dd3d9 100644 --- a/packages/cli/src/workspace.ts +++ b/packages/cli/src/workspace.ts @@ -1,6 +1,7 @@ /** * Monorepo support: * - `wrnexus workspace ` scaffolds a multi-app workspace (apps/* + shared packages/*) + * - `wrnexus workspace add --domain=` 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 { + 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 { - 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 ` to scaffold one."); } diff --git a/packages/cli/test/create.test.ts b/packages/cli/test/create.test.ts index 3fe7f47d..4f3fffe7 100644 --- a/packages/cli/test/create.test.ts +++ b/packages/cli/test/create.test.ts @@ -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"', ); diff --git a/packages/cli/test/workspace.test.ts b/packages/cli/test/workspace.test.ts index b8cfbc72..9cdb6d33 100644 --- a/packages/cli/test/workspace.test.ts +++ b/packages/cli/test/workspace.test.ts @@ -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 }); + } +}); diff --git a/packages/compiler/package.json b/packages/compiler/package.json index 3853cf0d..04e5a7ee 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/compiler", - "version": "0.2.16", + "version": "0.2.17", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/core/package.json b/packages/core/package.json index a72a3a30..bdfaf7e2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/core", - "version": "0.2.16", + "version": "0.2.17", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/csr/package.json b/packages/csr/package.json index 7d26c228..1228a92a 100644 --- a/packages/csr/package.json +++ b/packages/csr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/csr", - "version": "0.2.16", + "version": "0.2.17", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/db/package.json b/packages/db/package.json index 31c3019d..0129a34b 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/db", - "version": "0.2.16", + "version": "0.2.17", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/dev-server/README.md b/packages/dev-server/README.md index cfe004d0..105e193f 100644 --- a/packages/dev-server/README.md +++ b/packages/dev-server/README.md @@ -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 diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index 9f019b79..4c6dcbec 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-server", - "version": "0.2.16", + "version": "0.2.17", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/dev-server/src/gateway.ts b/packages/dev-server/src/gateway.ts index 1bcb23c8..a40f4bf1 100644 --- a/packages/dev-server/src/gateway.ts +++ b/packages/dev-server/src/gateway.ts @@ -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 }); } diff --git a/packages/dev-server/test/gateway.test.ts b/packages/dev-server/test/gateway.test.ts index 40e5d00c..81983401 100644 --- a/packages/dev-server/test/gateway.test.ts +++ b/packages/dev-server/test/gateway.test.ts @@ -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"); +}); diff --git a/packages/encryption/package.json b/packages/encryption/package.json index 0a5681bf..fa8d17c7 100644 --- a/packages/encryption/package.json +++ b/packages/encryption/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/encryption", - "version": "0.2.16", + "version": "0.2.17", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/helpers/README.md b/packages/helpers/README.md new file mode 100644 index 00000000..b796b19a --- /dev/null +++ b/packages/helpers/README.md @@ -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`. diff --git a/packages/helpers/package.json b/packages/helpers/package.json new file mode 100644 index 00000000..3dec7d23 --- /dev/null +++ b/packages/helpers/package.json @@ -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:*" + } +} diff --git a/packages/helpers/src/index.ts b/packages/helpers/src/index.ts new file mode 100644 index 00000000..a1412bfc --- /dev/null +++ b/packages/helpers/src/index.ts @@ -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; + +export type AllowedHosts = + readonly string[] | ReadonlySet | ((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); +} diff --git a/packages/helpers/test/helpers.test.ts b/packages/helpers/test/helpers.test.ts new file mode 100644 index 00000000..863a246c --- /dev/null +++ b/packages/helpers/test/helpers.test.ts @@ -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/"); +}); diff --git a/packages/i18n/package.json b/packages/i18n/package.json index ba4a2bd2..49aa007c 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/i18n", - "version": "0.2.16", + "version": "0.2.17", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/jwt/package.json b/packages/jwt/package.json index e8df7950..f2898640 100644 --- a/packages/jwt/package.json +++ b/packages/jwt/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/jwt", - "version": "0.2.16", + "version": "0.2.17", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index edeb61e5..ae6ffddd 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/mobile", - "version": "0.2.16", + "version": "0.2.17", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/native/package.json b/packages/native/package.json index 18f721f6..fa4f5ed9 100644 --- a/packages/native/package.json +++ b/packages/native/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/native", - "version": "0.2.16", + "version": "0.2.17", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/oauth/package.json b/packages/oauth/package.json index c18e2347..6b2b28f0 100644 --- a/packages/oauth/package.json +++ b/packages/oauth/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/oauth", - "version": "0.2.16", + "version": "0.2.17", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/pubsub/package.json b/packages/pubsub/package.json index 2dd25558..0effae08 100644 --- a/packages/pubsub/package.json +++ b/packages/pubsub/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/pubsub", - "version": "0.2.16", + "version": "0.2.17", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/queue/package.json b/packages/queue/package.json index 443a772e..344e5358 100644 --- a/packages/queue/package.json +++ b/packages/queue/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/queue", - "version": "0.2.16", + "version": "0.2.17", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/reactive/package.json b/packages/reactive/package.json index ec2bc2d1..13b4c48a 100644 --- a/packages/reactive/package.json +++ b/packages/reactive/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/reactive", - "version": "0.2.16", + "version": "0.2.17", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/router/package.json b/packages/router/package.json index db1b98a2..89e9d77f 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/router", - "version": "0.2.16", + "version": "0.2.17", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/ssr/package.json b/packages/ssr/package.json index 7ec46fb2..da2e12e3 100644 --- a/packages/ssr/package.json +++ b/packages/ssr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ssr", - "version": "0.2.16", + "version": "0.2.17", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/styles/package.json b/packages/styles/package.json index 7fde77ef..7aec660c 100644 --- a/packages/styles/package.json +++ b/packages/styles/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/styles", - "version": "0.2.16", + "version": "0.2.17", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/test/package.json b/packages/test/package.json index f6681e04..bd4a7508 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/test", - "version": "0.2.16", + "version": "0.2.17", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/tracking/package.json b/packages/tracking/package.json index 5351dc4a..7843eff7 100644 --- a/packages/tracking/package.json +++ b/packages/tracking/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/tracking", - "version": "0.2.16", + "version": "0.2.17", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/ui/package.json b/packages/ui/package.json index fe45ea85..5f7cef63 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ui", - "version": "0.2.16", + "version": "0.2.17", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/uploader/package.json b/packages/uploader/package.json index 1a851e32..d907a8d1 100644 --- a/packages/uploader/package.json +++ b/packages/uploader/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/uploader", - "version": "0.2.16", + "version": "0.2.17", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/validation/package.json b/packages/validation/package.json index ce761b84..f0f040a1 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/validation", - "version": "0.2.16", + "version": "0.2.17", "private": true, "type": "module", "main": "src/index.ts", diff --git a/tsconfig.json b/tsconfig.json index 5177b560..aea584d1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"],