--- 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 { + // Validate the same schema used by . + // 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 {
} + view { +
+ +
+ } } --- /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 + +``` + +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 { + 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 {
-

{title}

{description}

-
- - - {#if showPhone}{/if} - {#if showUsername}{/if} - - {#if requireConsent}{/if} +
+

{title}

+

{description}

+
+ + +
+ + +

+
+ +
+ + +

+
+ + {#if showPhone} +
+ + +

+
+ {/if} + + {#if showUsername} +
+ + +

+
+ {/if} + +
+ + +

At least 12 characters with uppercase, lowercase, and a number.

+

+
+ + {#if requireConsent} +
+ +

+
+ {/if} +
- + + + + +
+

Already have an account? Sign in

} --- 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> { const type = request.headers.get("content-type") ?? ""; @@ -34,7 +46,9 @@ const engine = options.engine; return { async register(ctx: Context): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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; + 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=""]` 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 { - 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=""]` 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 { + 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();