release: WRNexusJS 0.5.0

This commit is contained in:
2026-07-29 12:51:10 +05:30
parent 76c768099d
commit 6afe32f63f
456 changed files with 40879 additions and 8850 deletions
+971
View File
@@ -0,0 +1,971 @@
--- a/examples/auth-showcase/app/api/auth/login.ts
+++ b/examples/auth-showcase/app/api/auth/login.ts
@@ -1,2 +1,2 @@
import { handlers } from "../../lib/auth.ts";
-export default handlers.login;
+export const POST = handlers.login;
--- a/examples/auth-showcase/app/api/auth/logout.ts
+++ b/examples/auth-showcase/app/api/auth/logout.ts
@@ -1,2 +1,2 @@
import { handlers } from "../../lib/auth.ts";
-export default handlers.logout;
+export const POST = handlers.logout;
--- a/examples/auth-showcase/app/api/auth/mfa/complete.ts
+++ b/examples/auth-showcase/app/api/auth/mfa/complete.ts
@@ -1,2 +1,2 @@
import { handlers } from "../../../lib/auth.ts";
-export default handlers.completeMfa;
+export const POST = handlers.completeMfa;
--- a/examples/auth-showcase/app/api/auth/mfa/otp.ts
+++ b/examples/auth-showcase/app/api/auth/mfa/otp.ts
@@ -1,2 +1,2 @@
import { handlers } from "../../../lib/auth.ts";
-export default handlers.beginMfaOtp;
+export const POST = handlers.beginMfaOtp;
--- a/examples/auth-showcase/app/api/auth/otp/issue.ts
+++ b/examples/auth-showcase/app/api/auth/otp/issue.ts
@@ -1,2 +1,2 @@
import { handlers } from "../../../lib/auth.ts";
-export default handlers.issueOtp;
+export const POST = handlers.issueOtp;
--- a/examples/auth-showcase/app/api/auth/otp/verify.ts
+++ b/examples/auth-showcase/app/api/auth/otp/verify.ts
@@ -1,2 +1,2 @@
import { handlers } from "../../../lib/auth.ts";
-export default handlers.verifyOtp;
+export const POST = handlers.verifyOtp;
--- a/examples/auth-showcase/app/api/auth/password/request.ts
+++ b/examples/auth-showcase/app/api/auth/password/request.ts
@@ -1,2 +1,2 @@
import { handlers } from "../../../lib/auth.ts";
-export default handlers.requestPasswordReset;
+export const POST = handlers.requestPasswordReset;
--- a/examples/auth-showcase/app/api/auth/password/reset.ts
+++ b/examples/auth-showcase/app/api/auth/password/reset.ts
@@ -1,2 +1,2 @@
import { handlers } from "../../../lib/auth.ts";
-export default handlers.resetPassword;
+export const POST = handlers.resetPassword;
--- a/examples/auth-showcase/app/api/auth/register.ts
+++ b/examples/auth-showcase/app/api/auth/register.ts
@@ -1,2 +1,13 @@
+import type { Context } from "@wrnexus/core";
+import { signUpSchema } from "@wrnexus/auth";
+import { parseBody } from "@wrnexus/validation";
import { handlers } from "../../lib/auth.ts";
-export default handlers.register;
+
+export async function POST(ctx: Context): Promise<Response> {
+ // Validate the same schema used by <SignUp data-schema="auth-register">.
+ // Clone the request because the shared auth handler also reads the body using
+ // the generic package registration schema.
+ const validation = await parseBody(signUpSchema, ctx.req.clone());
+ if (!validation.ok) return validation.response;
+ return handlers.register(ctx);
+}
--- a/examples/auth-showcase/app/api/auth/sessions/index.ts
+++ b/examples/auth-showcase/app/api/auth/sessions/index.ts
@@ -1,2 +1,2 @@
import { handlers } from "../../../lib/auth.ts";
-export default handlers.sessions;
+export const GET = handlers.sessions;
--- a/examples/auth-showcase/app/api/auth/sessions/revoke.ts
+++ b/examples/auth-showcase/app/api/auth/sessions/revoke.ts
@@ -1,2 +1,2 @@
import { handlers } from "../../../lib/auth.ts";
-export default handlers.revokeSession;
+export const POST = handlers.revokeSession;
--- a/examples/auth-showcase/app/api/auth/verify/email.ts
+++ b/examples/auth-showcase/app/api/auth/verify/email.ts
@@ -1,2 +1,3 @@
import { handlers } from "../../../lib/auth.ts";
-export default handlers.verifyEmail;
+export const GET = handlers.verifyEmail;
+export const POST = handlers.verifyEmail;
--- a/examples/auth-showcase/app/api/auth/verify/phone.ts
+++ b/examples/auth-showcase/app/api/auth/verify/phone.ts
@@ -1,2 +1,2 @@
import { handlers } from "../../../lib/auth.ts";
-export default handlers.verifyPhone;
+export const POST = handlers.verifyPhone;
--- a/examples/auth-showcase/app/pages/sign-up.wrn
+++ b/examples/auth-showcase/app/pages/sign-up.wrn
@@ -1,4 +1,8 @@
page SignUpPage {
seo { title = "Create account" }
- view { <main class="flex min-h-screen items-center justify-center bg-[var(--wire-color-bg)] p-4"><SignUp action="/api/auth/register" /></main> }
+ view {
+ <main class="flex min-h-screen items-center justify-center bg-[var(--wire-color-bg)] p-4">
+ <SignUp action="/api/auth/register" schema="auth-register" redirect="/sign-in" />
+ </main>
+ }
}
--- /dev/null
+++ b/examples/auth-showcase/app/schemas/auth-register.ts
@@ -0,0 +1,3 @@
+import { signUpSchema } from "@wrnexus/auth";
+
+export default signUpSchema;
--- a/examples/auth-showcase/package.json
+++ b/examples/auth-showcase/package.json
@@ -12,7 +12,8 @@
},
"dependencies": {
"@wrnexus/auth": "workspace:*",
- "@wrnexus/core": "workspace:*"
+ "@wrnexus/core": "workspace:*",
+ "@wrnexus/validation": "workspace:*"
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.0",
--- a/examples/auth-showcase/test/showcase.test.ts
+++ b/examples/auth-showcase/test/showcase.test.ts
@@ -33,3 +33,15 @@
expect(readFileSync(join(root, "app", "pages", "magic-link.wrn"), "utf8")).toContain("MagicLinkSignIn");
expect(readFileSync(join(root, "app", "pages", "impersonation.wrn"), "utf8")).toContain("ImpersonationBanner");
});
+
+
+test("registration uses one @wrnexus/validation schema on the client and server", () => {
+ const schema = readFileSync(join(root, "app", "schemas", "auth-register.ts"), "utf8");
+ const route = readFileSync(join(root, "app", "api", "auth", "register.ts"), "utf8");
+ const page = readFileSync(join(root, "app", "pages", "sign-up.wrn"), "utf8");
+
+ expect(schema).toContain("signUpSchema");
+ expect(route).toContain("parseBody(signUpSchema");
+ expect(route).toContain("export async function POST");
+ expect(page).toContain('schema="auth-register"');
+});
--- a/examples/auth-showcase/wrnexus.config.ts
+++ b/examples/auth-showcase/wrnexus.config.ts
@@ -1,7 +1,9 @@
+import { authPlugin } from "@wrnexus/auth/plugin";
import type { AppConfig } from "@wrnexus/styles";
import { auth } from "./app/lib/auth.ts";
const config: AppConfig & { auth: { engine: typeof auth } } = {
+ plugins: [authPlugin({ includeMigrations: false })],
seo: {
title: "WRNexus Auth Showcase",
description: "Complete authentication, MFA, passkey, recovery, session, and security examples.",
--- a/packages/auth/README.md
+++ b/packages/auth/README.md
@@ -203,10 +203,36 @@
## Validation
-Reusable schemas are exported for registration, login, reset, OTP, MFA, invitation acceptance, magic links, and password changes.
+Reusable `@wrnexus/validation` schemas are exported for registration, sign-up forms, login, reset, OTP, MFA, invitation acceptance, magic links, and password changes. The HTTP handlers validate request bodies before calling the auth engine.
+
+Expose the same schema to the browser through the application's conventional `app/schemas/` directory:
+
+```ts
+// app/schemas/auth-register.ts
+import { signUpSchema } from "@wrnexus/auth";
+
+export default signUpSchema;
+```
+
+Then use the matching schema name on the packaged component:
+
+```wrn
+<SignUp schema="auth-register" action="/api/auth/register" />
+```
+
+An application-owned route can enforce the exact same form schema before delegating to the shared handler:
```ts
-import { loginSchema, invitationAcceptSchema } from "@wrnexus/auth";
+import type { Context } from "@wrnexus/core";
+import { signUpSchema } from "@wrnexus/auth";
+import { parseBody } from "@wrnexus/validation";
+import { handlers } from "../../lib/auth.ts";
+
+export async function POST(ctx: Context): Promise<Response> {
+ const validation = await parseBody(signUpSchema, ctx.req.clone());
+ if (!validation.ok) return validation.response;
+ return handlers.register(ctx);
+}
```
## Development
--- a/packages/auth/components/SignUp.wrn
+++ b/packages/auth/components/SignUp.wrn
@@ -1,6 +1,8 @@
component SignUp {
props {
action = "/api/auth/register"
+ schema = "auth-register"
+ redirect = ""
title = "Create your account"
description = "Use a strong password and verify your contact details."
submitLabel = "Create account"
@@ -14,17 +16,71 @@
}
view {
<section {...attrs} class='w-full max-w-lg rounded-[var(--wire-radius-lg)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-6 text-[var(--wire-color-text)] shadow-[var(--wire-shadow-2)] {class}'>
- <header class="mb-6 space-y-1.5"><h1 class="m-0 text-2xl font-semibold">{title}</h1><p class="m-0 text-sm text-[var(--wire-color-muted)]">{description}</p></header>
- <form method="post" action='{action}' class="grid gap-4 sm:grid-cols-2">
- <label class="space-y-1.5 sm:col-span-2"><span class="text-sm font-medium">Full name</span><input name="displayName" autocomplete="name" required class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3" /></label>
- <label class="space-y-1.5"><span class="text-sm font-medium">Email</span><input name="email" type="email" autocomplete="email" required class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3" /></label>
- {#if showPhone}<label class="space-y-1.5"><span class="text-sm font-medium">Phone</span><input name="phone" type="tel" autocomplete="tel" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3" /></label>{/if}
- {#if showUsername}<label class="space-y-1.5 sm:col-span-2"><span class="text-sm font-medium">Username</span><input name="username" autocomplete="username" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3" /></label>{/if}
- <label class="space-y-1.5 sm:col-span-2"><span class="text-sm font-medium">Password</span><input name="password" type="password" autocomplete="new-password" minlength="12" required class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3" /><span class="text-xs text-[var(--wire-color-muted)]">At least 12 characters with uppercase, lowercase, and a number.</span></label>
- {#if requireConsent}<label class="flex items-start gap-2 text-sm text-[var(--wire-color-muted)] sm:col-span-2"><input name="consent" type="checkbox" required class="mt-0.5 size-4" /><span>I agree to the terms and privacy policy.</span></label>{/if}
+ <header class="mb-6 space-y-1.5">
+ <h1 class="m-0 text-2xl font-semibold">{title}</h1>
+ <p class="m-0 text-sm text-[var(--wire-color-muted)]">{description}</p>
+ </header>
+
+ <form
+ method="post"
+ action='{action}'
+ data-schema='{schema}'
+ data-redirect='{redirect}'
+ class="grid gap-4 sm:grid-cols-2"
+ >
+ <div class="space-y-1.5 sm:col-span-2">
+ <label for="auth-sign-up-name" class="text-sm font-medium">Full name</label>
+ <input id="auth-sign-up-name" name="displayName" autocomplete="name" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 outline-none focus:border-[var(--wire-color-primary)]" />
+ <p data-error="displayName" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
+ </div>
+
+ <div class="space-y-1.5">
+ <label for="auth-sign-up-email" class="text-sm font-medium">Email</label>
+ <input id="auth-sign-up-email" name="email" type="email" autocomplete="email" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 outline-none focus:border-[var(--wire-color-primary)]" />
+ <p data-error="email" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
+ </div>
+
+ {#if showPhone}
+ <div class="space-y-1.5">
+ <label for="auth-sign-up-phone" class="text-sm font-medium">Phone</label>
+ <input id="auth-sign-up-phone" name="phone" type="tel" autocomplete="tel" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 outline-none focus:border-[var(--wire-color-primary)]" />
+ <p data-error="phone" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
+ </div>
+ {/if}
+
+ {#if showUsername}
+ <div class="space-y-1.5 sm:col-span-2">
+ <label for="auth-sign-up-username" class="text-sm font-medium">Username</label>
+ <input id="auth-sign-up-username" name="username" autocomplete="username" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 outline-none focus:border-[var(--wire-color-primary)]" />
+ <p data-error="username" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
+ </div>
+ {/if}
+
+ <div class="space-y-1.5 sm:col-span-2">
+ <label for="auth-sign-up-password" class="text-sm font-medium">Password</label>
+ <input id="auth-sign-up-password" name="password" type="password" autocomplete="new-password" class="h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-3 outline-none focus:border-[var(--wire-color-primary)]" />
+ <p class="m-0 text-xs text-[var(--wire-color-muted)]">At least 12 characters with uppercase, lowercase, and a number.</p>
+ <p data-error="password" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
+ </div>
+
+ {#if requireConsent}
+ <div class="space-y-1 sm:col-span-2">
+ <label class="flex items-start gap-2 text-sm text-[var(--wire-color-muted)]">
+ <input name="consent" type="checkbox" class="mt-0.5 size-4" />
+ <span>I agree to the terms and privacy policy.</span>
+ </label>
+ <p data-error="consent" class="m-0 min-h-4 text-xs text-[var(--wire-color-danger)]"></p>
+ </div>
+ {/if}
+
<div class="sm:col-span-2"><slot></slot></div>
- <button type="submit" class="h-11 rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 text-sm font-semibold text-white sm:col-span-2">{submitLabel}</button>
+
+ <p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wire-color-danger)] sm:col-span-2"></p>
+ <p data-success="Account created successfully." role="status" hidden class="m-0 rounded-[var(--wire-radius-sm)] bg-[color-mix(in_srgb,var(--wire-color-success)_10%,transparent)] p-3 text-sm text-[var(--wire-color-success)] sm:col-span-2"></p>
+
+ <button type="submit" class="h-11 rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-primary)] px-4 text-sm font-semibold text-white sm:col-span-2 disabled:cursor-not-allowed disabled:opacity-60">{submitLabel}</button>
</form>
+
<p class="mb-0 mt-5 text-center text-sm text-[var(--wire-color-muted)]">Already have an account? <a href='{signInHref}' class="font-semibold text-[var(--wire-color-primary)] hover:underline">Sign in</a></p>
</section>
}
--- a/packages/auth/src/http/index.ts
+++ b/packages/auth/src/http/index.ts
@@ -1,6 +1,18 @@
import type { Context } from "@wrnexus/core";
+import { parseBody } from "@wrnexus/validation";
import type { AuthEngine } from "../engine.ts";
import { clearAuthSession, establishAuthSession, getAuthSession, getAuthUser } from "../middleware.ts";
+import {
+ invitationAcceptSchema,
+ loginSchema,
+ magicLinkRequestSchema,
+ mfaSchema,
+ otpLoginRequestSchema,
+ otpSchema,
+ passwordResetRequestSchema,
+ passwordResetSchema,
+ registerSchema,
+} from "../validation.ts";
async function body(request: Request): Promise<Record<string, unknown>> {
const type = request.headers.get("content-type") ?? "";
@@ -34,7 +46,9 @@
const engine = options.engine;
return {
async register(ctx: Context): Promise<Response> {
- const input = await body(ctx.req);
+ const validation = await parseBody(registerSchema, ctx.req);
+ if (!validation.ok) return validation.response;
+ const input = validation.value;
const result = await engine.register({
email: text(input.email) || undefined,
phone: text(input.phone) || undefined,
@@ -48,7 +62,9 @@
},
async login(ctx: Context): Promise<Response> {
- const input = await body(ctx.req);
+ const validation = await parseBody(loginSchema, ctx.req);
+ if (!validation.ok) return validation.response;
+ const input = validation.value;
const result = await engine.login({
identifier: text(input.identifier),
password: text(input.password),
@@ -87,19 +103,25 @@
},
async requestPasswordReset(ctx: Context): Promise<Response> {
- const input = await body(ctx.req);
+ const validation = await parseBody(passwordResetRequestSchema, ctx.req);
+ if (!validation.ok) return validation.response;
+ const input = validation.value;
await engine.requestPasswordReset(text(input.identifier), options.baseUrl ?? ctx.url.origin);
return json({ ok: true });
},
async resetPassword(ctx: Context): Promise<Response> {
- const input = await body(ctx.req);
+ const validation = await parseBody(passwordResetSchema, ctx.req);
+ if (!validation.ok) return validation.response;
+ const input = validation.value;
const result = await engine.resetPassword(text(input.token), text(input.password));
return json(result, result.ok ? 200 : 400);
},
async acceptInvitation(ctx: Context): Promise<Response> {
- const input = await body(ctx.req);
+ const validation = await parseBody(invitationAcceptSchema, ctx.req);
+ if (!validation.ok) return validation.response;
+ const input = validation.value;
const result = await engine.acceptInvitation(text(input.token), {
password: text(input.password) || undefined,
displayName: text(input.displayName) || undefined,
@@ -108,7 +130,9 @@
},
async requestMagicLink(ctx: Context): Promise<Response> {
- const input = await body(ctx.req);
+ const validation = await parseBody(magicLinkRequestSchema, ctx.req);
+ if (!validation.ok) return validation.response;
+ const input = validation.value;
await engine.requestMagicLink(text(input.identifier), options.baseUrl ?? ctx.url.origin);
return json({ ok: true });
},
@@ -138,7 +162,9 @@
},
async requestOtpLogin(ctx: Context): Promise<Response> {
- const input = await body(ctx.req);
+ const validation = await parseBody(otpLoginRequestSchema, ctx.req);
+ if (!validation.ok) return validation.response;
+ const input = validation.value;
const method = text(input.method) === "sms-otp" ? "sms-otp" : "email-otp";
const challenge = await engine.requestOtpLogin(text(input.identifier), method);
return json({ ok: true, challenge: challenge ?? null });
@@ -165,7 +191,9 @@
},
async verifyOtp(ctx: Context): Promise<Response> {
- const input = await body(ctx.req);
+ const validation = await parseBody(otpSchema, ctx.req);
+ if (!validation.ok) return validation.response;
+ const input = validation.value;
const result = await engine.verifyOtp(text(input.challengeId), text(input.code));
return json(result, result.ok ? 200 : 400);
},
@@ -178,7 +206,9 @@
},
async completeMfa(ctx: Context): Promise<Response> {
- const input = await body(ctx.req);
+ const validation = await parseBody(mfaSchema, ctx.req);
+ if (!validation.ok) return validation.response;
+ const input = validation.value;
const methodValue = text(input.method);
const method = ["totp", "recovery-code", "email-otp", "sms-otp"].includes(methodValue)
? methodValue as "totp" | "recovery-code" | "email-otp" | "sms-otp"
--- a/packages/auth/src/index.ts
+++ b/packages/auth/src/index.ts
@@ -22,4 +22,4 @@
export { MemoryPasskeyChallengeStore, assertPasskeyProvider } from "./passkeys/index.ts";
export * from "./types.ts";
-export { registerSchema, loginSchema, passwordResetRequestSchema, passwordResetSchema, otpSchema, mfaSchema, otpLoginRequestSchema, magicLinkRequestSchema, invitationAcceptSchema, changePasswordSchema } from "./validation.ts";
+export { registerSchema, signUpSchema, loginSchema, passwordResetRequestSchema, passwordResetSchema, otpSchema, mfaSchema, otpLoginRequestSchema, magicLinkRequestSchema, invitationAcceptSchema, changePasswordSchema } from "./validation.ts";
--- a/packages/auth/src/routes/api.ts
+++ b/packages/auth/src/routes/api.ts
@@ -55,3 +55,8 @@
return Response.json({ ok: false, error: "Not Found" }, { status: 404 });
}
+
+// Package-contributed API entries are loaded by HTTP method. Export both
+// supported methods while retaining the default export for compatibility.
+export const GET = authApi;
+export const POST = authApi;
--- a/packages/auth/src/server/index.ts
+++ b/packages/auth/src/server/index.ts
@@ -15,6 +15,6 @@
export { SqlAuthStore } from "../stores/sql.ts";
export * from "../types.ts";
-export { registerSchema, loginSchema, passwordResetRequestSchema, passwordResetSchema, otpSchema, mfaSchema, otpLoginRequestSchema, magicLinkRequestSchema, invitationAcceptSchema, changePasswordSchema } from "../validation.ts";
+export { registerSchema, signUpSchema, loginSchema, passwordResetRequestSchema, passwordResetSchema, otpSchema, mfaSchema, otpLoginRequestSchema, magicLinkRequestSchema, invitationAcceptSchema, changePasswordSchema } from "../validation.ts";
export { createAuthSecretProtector } from "../protector.ts";
--- a/packages/auth/src/validation.ts
+++ b/packages/auth/src/validation.ts
@@ -1,11 +1,46 @@
import { v } from "@wrnexus/validation";
+const strongPassword = () =>
+ v
+ .string()
+ .min(12, "Password must be at least 12 characters")
+ .max(256, "Password must be at most 256 characters")
+ .pattern(
+ /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/,
+ "Password must include uppercase, lowercase, and a number",
+ );
+
+/** Generic registration API schema. At least one identity is enforced by the auth engine. */
export const registerSchema = v.object({
displayName: v.string().trim().min(2).max(120),
email: v.string().trim().email().optional(),
phone: v.string().trim().min(7).max(24).optional(),
- username: v.string().trim().min(3).max(64).pattern(/^[a-zA-Z0-9._-]+$/).optional(),
- password: v.string().min(12).max(256),
+ username: v
+ .string()
+ .trim()
+ .min(3)
+ .max(64)
+ .pattern(/^[a-zA-Z0-9._-]+$/, "Use only letters, numbers, dots, underscores, or hyphens")
+ .optional(),
+ password: strongPassword(),
+ locale: v.string().max(32).optional(),
+ timezone: v.string().max(64).optional(),
+});
+
+/** Browser sign-up form schema used by the packaged SignUp component. */
+export const signUpSchema = v.object({
+ displayName: v.string().trim().min(2, "Enter your full name").max(120),
+ email: v.string().trim().email("Enter a valid email address"),
+ phone: v.string().trim().min(7).max(24).optional(),
+ username: v
+ .string()
+ .trim()
+ .min(3)
+ .max(64)
+ .pattern(/^[a-zA-Z0-9._-]+$/, "Use only letters, numbers, dots, underscores, or hyphens")
+ .optional(),
+ password: strongPassword(),
+ consent: v.boolean().required("Accept the terms and privacy policy to continue"),
locale: v.string().max(32).optional(),
timezone: v.string().max(64).optional(),
});
@@ -25,7 +60,7 @@
export const passwordResetSchema = v.object({
token: v.string().min(20).max(512),
- password: v.string().min(12).max(256),
+ password: strongPassword(),
});
export const otpSchema = v.object({
@@ -38,6 +73,7 @@
method: v.string().oneOf(["totp", "recovery-code", "email-otp", "sms-otp"]),
challengeId: v.string().max(191).optional(),
code: v.string().trim().min(6).max(32),
+ returnTo: v.string().max(2048).optional(),
});
export const otpLoginRequestSchema = v.object({
@@ -52,10 +88,10 @@
export const invitationAcceptSchema = v.object({
token: v.string().min(20).max(512),
displayName: v.string().trim().min(2).max(120).optional(),
- password: v.string().min(12).max(256).optional(),
+ password: strongPassword().optional(),
});
export const changePasswordSchema = v.object({
currentPassword: v.string().min(1).max(256),
- nextPassword: v.string().min(12).max(256),
+ nextPassword: strongPassword(),
});
--- a/packages/auth/test/http.test.ts
+++ b/packages/auth/test/http.test.ts
@@ -69,3 +69,34 @@
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ ok: true });
});
+
+
+test("register handler returns validation field errors before calling the engine", async () => {
+ let registerCalls = 0;
+ const engine = {
+ register: async () => {
+ registerCalls += 1;
+ return { ok: true };
+ },
+ } as unknown as ReturnType<typeof createAuthEngine>;
+ const handlers = createAuthHttpHandlers({ engine });
+ const ctx = context(
+ new Request("https://example.test/api/auth/register", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ displayName: "A", email: "bad", password: "short" }),
+ }),
+ );
+
+ const response = await handlers.register(ctx);
+ expect(response.status).toBe(400);
+ expect(await response.json()).toMatchObject({
+ ok: false,
+ errors: {
+ displayName: expect.any(String),
+ email: expect.any(String),
+ password: expect.any(String),
+ },
+ });
+ expect(registerCalls).toBe(0);
+});
--- a/packages/auth/test/validation.test.ts
+++ b/packages/auth/test/validation.test.ts
@@ -5,6 +5,7 @@
mfaSchema,
otpLoginRequestSchema,
registerSchema,
+ signUpSchema,
} from "../src/validation.ts";
test("authentication schemas reject malformed input", () => {
@@ -14,3 +15,28 @@
expect(invitationAcceptSchema.parse({ token: "short" }).ok).toBe(false);
expect(mfaSchema.parse({ mfaToken: "short", method: "unknown", code: "1" }).ok).toBe(false);
});
+
+
+test("sign-up schema is shared by browser and server registration", () => {
+ const invalid = signUpSchema.parse({
+ displayName: "A",
+ email: "bad",
+ password: "weak",
+ consent: false,
+ });
+ expect(invalid.ok).toBe(false);
+ expect(invalid.errors).toMatchObject({
+ displayName: "Enter your full name",
+ email: "Enter a valid email address",
+ consent: "Accept the terms and privacy policy to continue",
+ });
+
+ expect(
+ signUpSchema.parse({
+ displayName: "Ada Lovelace",
+ email: "ada@example.com",
+ password: "StrongPassword123",
+ consent: true,
+ }).ok,
+ ).toBe(true);
+});
--- a/packages/validation/src/runtime.ts
+++ b/packages/validation/src/runtime.ts
@@ -1,178 +1,205 @@
-/**
- * Client-side validation. `renderSchemasScript` bakes the discovered schema
- * descriptors into `window.__wireSchemas`; `VALIDATE_RUNTIME` is a generic,
- * eval-free validator that reads them and validates every `form[data-schema]`
- * on submit and blur, writing messages into `[data-error="<field>"]` elements.
- * The rule logic mirrors `checkField`/`applyRule` in index.ts.
- */
-
-import type { SchemaDescriptor } from "./index.ts";
-
-/** `window.__wireSchemas = { name: descriptor, ... }` for the client validator. */
-export function renderSchemasScript(descriptors: Record<string, SchemaDescriptor>): string {
- return `window.__wireSchemas=${JSON.stringify(descriptors)};`;
-}
-
-export const VALIDATE_RUNTIME = String.raw`
-(function () {
- var EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
- var URL_RE = /^https?:\/\/[^\s/$.?#][^\s]*$/i;
- var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
-
- function applyRule(type, r, value) {
- if (r.kind === "min") {
- if (type === "string") return String(value).length < r.n ? (r.message || ("Must be at least " + r.n + " characters")) : null;
- return value < r.n ? (r.message || ("Must be at least " + r.n)) : null;
- }
- if (r.kind === "max") {
- if (type === "string") return String(value).length > r.n ? (r.message || ("Must be at most " + r.n + " characters")) : null;
- return value > r.n ? (r.message || ("Must be at most " + r.n)) : null;
- }
- if (r.kind === "length") return String(value).length !== r.n ? (r.message || ("Must be exactly " + r.n + " characters")) : null;
- if (r.kind === "email") return EMAIL.test(String(value)) ? null : (r.message || "Must be a valid email");
- if (r.kind === "url") return URL_RE.test(String(value)) ? null : (r.message || "Must be a valid URL");
- if (r.kind === "uuid") return UUID_RE.test(String(value)) ? null : (r.message || "Must be a valid UUID");
- if (r.kind === "date") return isNaN(Date.parse(String(value))) ? (r.message || "Must be a valid date") : null;
- if (r.kind === "oneOf") return r.values.indexOf(value) !== -1 ? null : (r.message || ("Must be one of: " + r.values.join(", ")));
- if (r.kind === "pattern") { try { return new RegExp(r.source, r.flags || "").test(String(value)) ? null : (r.message || "Invalid format"); } catch (e) { return null; } }
- if (r.kind === "integer") return Number.isInteger(value) ? null : (r.message || "Must be a whole number");
- return null;
- }
-
- function checkField(desc, raw) {
- if (desc.type === "boolean") {
- var b = raw === true || raw === "true" || raw === "on";
- return (!desc.optional && !b) ? (desc.requiredMessage || "Required") : null;
- }
- var pre = desc.trim && typeof raw === "string" ? raw.trim() : raw;
- var empty = pre === undefined || pre === null || pre === "";
- if (empty) return desc.optional ? null : (desc.requiredMessage || "Required");
- var value;
- if (desc.type === "number") { value = Number(pre); if (isNaN(value)) return "Must be a number"; }
- else value = String(pre);
- for (var i = 0; i < desc.rules.length; i++) {
- var err = applyRule(desc.type, desc.rules[i], value);
- if (err) return err;
- }
- return null;
- }
-
- function rawValue(form, name) {
- var el = form.elements[name];
- if (!el) return undefined;
- return el.type === "checkbox" ? el.checked : el.value;
- }
-
- function showError(form, name, err) {
- var box = form.querySelector('[data-error="' + name + '"]');
- if (box) box.textContent = err || "";
- var el = form.elements[name];
- if (el && el.setAttribute) {
- if (err) { el.setAttribute("aria-invalid", "true"); if (el.classList) el.classList.add("wire-invalid"); }
- else { el.removeAttribute("aria-invalid"); if (el.classList) el.classList.remove("wire-invalid"); }
- }
- }
-
- function validateForm(form, schema) {
- var errors = 0;
- Object.keys(schema.fields).forEach(function (name) {
- var err = checkField(schema.fields[name], rawValue(form, name));
- if (err) errors++;
- showError(form, name, err);
- });
- return errors;
- }
-
- function collect(form) {
- var out = {};
- for (var i = 0; i < form.elements.length; i++) {
- var el = form.elements[i];
- if (!el.name) continue;
- if (el.type === "checkbox") out[el.name] = el.checked;
- else if (el.type === "radio") { if (el.checked) out[el.name] = el.value; }
- else out[el.name] = el.value;
- }
- return out;
- }
-
- function onSuccess(form, data) {
- var redirect = form.getAttribute("data-redirect") || (data && data.redirect);
- if (redirect) {
- // Prefer client-side navigation (no full reload) when it is available.
- if (window.__wrnexusNavigate) window.__wrnexusNavigate(redirect);
- else location.assign(redirect);
- return;
- }
- var box = form.querySelector("[data-success]");
- if (box) { box.textContent = box.getAttribute("data-success") || "Success"; box.hidden = false; }
- form.reset();
- form.dispatchEvent(new CustomEvent("wire:success", { detail: data, bubbles: true }));
- }
-
- function csrfHeader() {
- var m = document.cookie.match(/(?:^|;\s*)wire-csrf=([^;]+)/);
- return m ? { "x-csrf-token": decodeURIComponent(m[1]) } : {};
- }
-
- function submitForm(form, schema) {
- var method = (form.getAttribute("method") || "post").toUpperCase();
- var action = form.getAttribute("action") || location.pathname;
- var btns = form.querySelectorAll("[type=submit]");
- btns.forEach(function (b) { b.disabled = true; });
- var headers = { "content-type": "application/json", accept: "application/json" };
- var csrf = csrfHeader();
- for (var k in csrf) headers[k] = csrf[k];
- fetch(action, {
- method: method,
- headers: headers,
- credentials: "same-origin",
- body: JSON.stringify(collect(form)),
- })
- .then(function (res) {
- return res.json().catch(function () { return {}; }).then(function (data) { return { res: res, data: data }; });
- })
- .then(function (r) {
- if (r.res.ok) { onSuccess(form, r.data); return; }
- // Surface server-side field errors (e.g. "email already taken").
- var errors = r.data && r.data.errors;
- if (errors) Object.keys(errors).forEach(function (f) { showError(form, f, errors[f]); });
- form.dispatchEvent(new CustomEvent("wire:error", { detail: r.data, bubbles: true }));
- })
- .catch(function () {
- form.dispatchEvent(new CustomEvent("wire:error", { detail: { network: true }, bubbles: true }));
- })
- .then(function () { btns.forEach(function (b) { b.disabled = false; }); });
- }
-
- function bind(form) {
- if (form.__wireValidateBound) return;
- form.__wireValidateBound = 1;
- var name = form.getAttribute("data-schema");
- var schema = (window.__wireSchemas || {})[name];
- if (!schema) return;
- // Schema-backed forms use WRNexus messages instead of the browser's
- // non-themeable native validation bubbles.
- form.noValidate = true;
- form.setAttribute("novalidate", "");
- form.addEventListener("submit", function (e) {
- e.preventDefault();
- if (validateForm(form, schema) > 0) return; // client-invalid: stay put, errors shown
- submitForm(form, schema);
- });
- form.addEventListener("blur", function (e) {
- var t = e.target;
- if (t && t.name && schema.fields[t.name]) {
- showError(form, t.name, checkField(schema.fields[t.name], t.type === "checkbox" ? t.checked : t.value));
- }
- }, true);
- }
-
- function init(root) {
- (root || document).querySelectorAll("form[data-schema]").forEach(bind);
- }
-
- window.__wireValidate = { init: init };
- if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", function () { init(document); });
- else init(document);
-})();
-`.trim();
+/**
+ * Client-side validation. `renderSchemasScript` bakes the discovered schema
+ * descriptors into `window.__wireSchemas`; `VALIDATE_RUNTIME` is a generic,
+ * eval-free validator that reads them and validates every `form[data-schema]`
+ * on submit and blur, writing messages into `[data-error="<field>"]` elements.
+ * The rule logic mirrors `checkField`/`applyRule` in index.ts.
+ */
+
+import type { SchemaDescriptor } from "./index.ts";
+
+/** `window.__wireSchemas = { name: descriptor, ... }` for the client validator. */
+export function renderSchemasScript(descriptors: Record<string, SchemaDescriptor>): string {
+ return `window.__wireSchemas=${JSON.stringify(descriptors)};`;
+}
+
+export const VALIDATE_RUNTIME = String.raw`
+(function () {
+ var EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ var URL_RE = /^https?:\/\/[^\s/$.?#][^\s]*$/i;
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+
+ function applyRule(type, r, value) {
+ if (r.kind === "min") {
+ if (type === "string") return String(value).length < r.n ? (r.message || ("Must be at least " + r.n + " characters")) : null;
+ return value < r.n ? (r.message || ("Must be at least " + r.n)) : null;
+ }
+ if (r.kind === "max") {
+ if (type === "string") return String(value).length > r.n ? (r.message || ("Must be at most " + r.n + " characters")) : null;
+ return value > r.n ? (r.message || ("Must be at most " + r.n)) : null;
+ }
+ if (r.kind === "length") return String(value).length !== r.n ? (r.message || ("Must be exactly " + r.n + " characters")) : null;
+ if (r.kind === "email") return EMAIL.test(String(value)) ? null : (r.message || "Must be a valid email");
+ if (r.kind === "url") return URL_RE.test(String(value)) ? null : (r.message || "Must be a valid URL");
+ if (r.kind === "uuid") return UUID_RE.test(String(value)) ? null : (r.message || "Must be a valid UUID");
+ if (r.kind === "date") return isNaN(Date.parse(String(value))) ? (r.message || "Must be a valid date") : null;
+ if (r.kind === "oneOf") return r.values.indexOf(value) !== -1 ? null : (r.message || ("Must be one of: " + r.values.join(", ")));
+ if (r.kind === "pattern") { try { return new RegExp(r.source, r.flags || "").test(String(value)) ? null : (r.message || "Invalid format"); } catch (e) { return null; } }
+ if (r.kind === "integer") return Number.isInteger(value) ? null : (r.message || "Must be a whole number");
+ return null;
+ }
+
+ function checkField(desc, raw) {
+ if (desc.type === "boolean") {
+ var b = raw === true || raw === "true" || raw === "on";
+ return (!desc.optional && !b) ? (desc.requiredMessage || "Required") : null;
+ }
+ var pre = desc.trim && typeof raw === "string" ? raw.trim() : raw;
+ var empty = pre === undefined || pre === null || pre === "";
+ if (empty) return desc.optional ? null : (desc.requiredMessage || "Required");
+ var value;
+ if (desc.type === "number") { value = Number(pre); if (isNaN(value)) return "Must be a number"; }
+ else value = String(pre);
+ for (var i = 0; i < desc.rules.length; i++) {
+ var err = applyRule(desc.type, desc.rules[i], value);
+ if (err) return err;
+ }
+ return null;
+ }
+
+ function rawValue(form, name) {
+ var el = form.elements[name];
+ if (!el) return undefined;
+ return el.type === "checkbox" ? el.checked : el.value;
+ }
+
+ function showError(form, name, err) {
+ var box = form.querySelector('[data-error="' + name + '"]');
+ if (box) box.textContent = err || "";
+ var el = form.elements[name];
+ if (el && el.setAttribute) {
+ if (err) { el.setAttribute("aria-invalid", "true"); if (el.classList) el.classList.add("wire-invalid"); }
+ else { el.removeAttribute("aria-invalid"); if (el.classList) el.classList.remove("wire-invalid"); }
+ }
+ }
+
+ function showFormError(form, message) {
+ var box = form.querySelector('[data-error="_form"]');
+ if (!box) return;
+ box.textContent = message || "";
+ box.hidden = !message;
+ if (box.classList) box.classList.toggle("hidden", !message);
+ }
+
+ function validateForm(form, schema) {
+ var errors = 0;
+ Object.keys(schema.fields).forEach(function (name) {
+ var err = checkField(schema.fields[name], rawValue(form, name));
+ if (err) errors++;
+ showError(form, name, err);
+ });
+ return errors;
+ }
+
+ function collect(form) {
+ var out = {};
+ for (var i = 0; i < form.elements.length; i++) {
+ var el = form.elements[i];
+ if (!el.name) continue;
+ if (el.type === "checkbox") out[el.name] = el.checked;
+ else if (el.type === "radio") { if (el.checked) out[el.name] = el.value; }
+ else out[el.name] = el.value;
+ }
+ return out;
+ }
+
+ function onSuccess(form, data, responseRedirect) {
+ showFormError(form, "");
+ form.dispatchEvent(new CustomEvent("wire:success", { detail: data, bubbles: true }));
+ var redirect = responseRedirect || form.getAttribute("data-redirect") || (data && data.redirect);
+ if (redirect) {
+ // Prefer client-side navigation for same-origin application redirects.
+ try {
+ var target = new URL(redirect, location.href);
+ if (target.origin === location.origin && window.__wrnexusNavigate) {
+ window.__wrnexusNavigate(target.pathname + target.search + target.hash);
+ } else location.assign(target.href);
+ } catch (e) {
+ location.assign(redirect);
+ }
+ return;
+ }
+ var box = form.querySelector("[data-success]");
+ if (box) { box.textContent = box.getAttribute("data-success") || "Success"; box.hidden = false; }
+ form.reset();
+ }
+
+ function csrfHeader() {
+ var m = document.cookie.match(/(?:^|;\s*)wire-csrf=([^;]+)/);
+ return m ? { "x-csrf-token": decodeURIComponent(m[1]) } : {};
+ }
+
+ function submitForm(form, schema) {
+ var method = (form.getAttribute("method") || "post").toUpperCase();
+ var action = form.getAttribute("action") || location.pathname;
+ var btns = form.querySelectorAll("[type=submit]");
+ showFormError(form, "");
+ btns.forEach(function (b) { b.disabled = true; });
+ var headers = { "content-type": "application/json", accept: "application/json" };
+ var csrf = csrfHeader();
+ for (var k in csrf) headers[k] = csrf[k];
+ fetch(action, {
+ method: method,
+ headers: headers,
+ credentials: "same-origin",
+ body: JSON.stringify(collect(form)),
+ })
+ .then(function (res) {
+ var responseRedirect = res.redirected && res.url ? res.url : "";
+ return res.json().catch(function () { return {}; }).then(function (data) {
+ return { res: res, data: data, responseRedirect: responseRedirect };
+ });
+ })
+ .then(function (r) {
+ if (r.res.ok && (!r.data || r.data.ok !== false)) {
+ onSuccess(form, r.data, r.responseRedirect);
+ return;
+ }
+ // Surface server-side field errors and a form-level API message.
+ var errors = r.data && r.data.errors;
+ if (errors) Object.keys(errors).forEach(function (f) { showError(form, f, errors[f]); });
+ var message = r.data && (r.data.message || r.data.error);
+ showFormError(form, message || ("Request failed (" + r.res.status + ")"));
+ form.dispatchEvent(new CustomEvent("wire:error", { detail: r.data, bubbles: true }));
+ })
+ .catch(function (error) {
+ var detail = { network: true, message: error && error.message ? error.message : "Network request failed" };
+ showFormError(form, detail.message);
+ form.dispatchEvent(new CustomEvent("wire:error", { detail: detail, bubbles: true }));
+ })
+ .then(function () { btns.forEach(function (b) { b.disabled = false; }); });
+ }
+
+ function bind(form) {
+ if (form.__wireValidateBound) return;
+ form.__wireValidateBound = 1;
+ var name = form.getAttribute("data-schema");
+ var schema = (window.__wireSchemas || {})[name];
+ if (!schema) return;
+ // Schema-backed forms use WRNexus messages instead of the browser's
+ // non-themeable native validation bubbles.
+ form.noValidate = true;
+ form.setAttribute("novalidate", "");
+ form.addEventListener("submit", function (e) {
+ e.preventDefault();
+ showFormError(form, "");
+ if (validateForm(form, schema) > 0) return; // client-invalid: stay put, errors shown
+ submitForm(form, schema);
+ });
+ form.addEventListener("blur", function (e) {
+ var t = e.target;
+ if (t && t.name && schema.fields[t.name]) {
+ showError(form, t.name, checkField(schema.fields[t.name], t.type === "checkbox" ? t.checked : t.value));
+ }
+ }, true);
+ }
+
+ function init(root) {
+ (root || document).querySelectorAll("form[data-schema]").forEach(bind);
+ }
+
+ window.__wireValidate = { init: init };
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", function () { init(document); });
+ else init(document);
+})();
+`.trim();