release: WRNexusJS 0.8.0
This commit is contained in:
@@ -8,6 +8,11 @@ export const GET = async () => {
|
||||
};
|
||||
|
||||
export const POST = async (ctx: Context) => {
|
||||
const body = await ctx.req.json();
|
||||
const body = await ctx.req.json().catch(() => undefined);
|
||||
if (body === undefined)
|
||||
return Response.json(
|
||||
{ error: "Request body must be valid JSON." },
|
||||
{ status: 400, headers: { "cache-control": "no-store" } },
|
||||
);
|
||||
return Response.json({ received: body });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createGraphqlHandler } from "@wrnexus/graphql";
|
||||
|
||||
const graphql = createGraphqlHandler({
|
||||
maxDepth: 8,
|
||||
maxAliases: 20,
|
||||
allowIntrospection: false,
|
||||
async execute(request) {
|
||||
return { data: { example: request.operationName ?? "anonymous" } };
|
||||
},
|
||||
});
|
||||
|
||||
export function POST(ctx: { req: Request }): Promise<Response> {
|
||||
return graphql(ctx.req);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineEndpoint } from "@wrnexus/core";
|
||||
import { CreateUserSchema } from "../schemas/create-user.ts";
|
||||
|
||||
/** Schema-derived request and response types are emitted into wrnexus.generated.d.ts. */
|
||||
export const POST = defineEndpoint({
|
||||
input: CreateUserSchema,
|
||||
output: CreateUserSchema,
|
||||
description: "Validate and echo a typed user payload.",
|
||||
handler(input) {
|
||||
return input;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
export const webhook = {
|
||||
event: "payment.completed",
|
||||
summary: "Payment completed",
|
||||
description: "Sent after a payment reaches its settled state.",
|
||||
payloadSchema: "#/components/schemas/PaymentCompleted",
|
||||
signatureHeader: "x-payment-signature",
|
||||
};
|
||||
|
||||
export async function POST(): Promise<Response> {
|
||||
return Response.json({ accepted: true });
|
||||
}
|
||||
@@ -73,4 +73,42 @@ describe("full app", () => {
|
||||
expect(typeof body.message).toBe("string");
|
||||
expect(body.lang).toBeTruthy();
|
||||
});
|
||||
|
||||
test("language-server playground renders its real interactive output", async () => {
|
||||
const res = await app.fetch("/language-tools");
|
||||
expect(res.status).toBe(200);
|
||||
const html = await res.text();
|
||||
expect(html).toContain("Language server playground");
|
||||
expect(html).toContain("computed double:");
|
||||
expect(html).toContain('data-text="doubled">0</span>');
|
||||
expect(html).toContain('aria-live="polite"');
|
||||
});
|
||||
|
||||
test("login renders real accessible fields without an accidental Card component", async () => {
|
||||
const res = await app.fetch("/login?next=/dashboard");
|
||||
expect(res.status).toBe(200);
|
||||
const html = await res.text();
|
||||
expect(html).toContain('name="email"');
|
||||
expect(html).toContain('name="password"');
|
||||
expect(html).toContain('autocomplete="current-password"');
|
||||
expect(html).not.toContain("Card title");
|
||||
});
|
||||
|
||||
test("platform showcase uses the public layout and styled reactive primitives", async () => {
|
||||
const res = await app.fetch("/platform-showcase");
|
||||
expect(res.status).toBe(200);
|
||||
const html = await res.text();
|
||||
expect(html).toContain("WrNexus");
|
||||
expect(html).toContain('data-wrn-dynamic-component="Admin"');
|
||||
expect(html).toContain('data-wrn-portal="#showcase-modal"');
|
||||
expect(html).toContain("platform-showcase");
|
||||
});
|
||||
|
||||
test("async data page renders its server loader without a cache failure", async () => {
|
||||
const res = await app.fetch("/async-data");
|
||||
expect(res.status).toBe(200);
|
||||
const html = await res.text();
|
||||
expect(html).toContain("Rendered by the server loader");
|
||||
expect(html).toContain("Loading the browser profile");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,9 +3,27 @@
|
||||
component AuthLayout {
|
||||
view {
|
||||
<div class="auth-wrap">
|
||||
<div data-component="card" class="auth-card">
|
||||
<section class="auth-card" aria-label="Authentication">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
.auth-wrap {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
place-items: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: min(100%, 28rem);
|
||||
padding: 2rem;
|
||||
border: 1px solid var(--wire-color-border);
|
||||
border-radius: var(--wire-radius-lg);
|
||||
background: var(--wire-color-surface);
|
||||
box-shadow: 0 1.25rem 3rem rgb(0 0 0 / 0.18);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ component DashboardLayout {
|
||||
<a href="/ui">Components</a>
|
||||
<a href="/">Home</a>
|
||||
</nav>
|
||||
<div data-component="theme-toggle" label="Toggle theme"></div>
|
||||
<button type="button" data-wire-theme-toggle aria-label="Toggle color theme">Theme</button>
|
||||
</aside>
|
||||
|
||||
<main class="dash-main">
|
||||
|
||||
@@ -11,7 +11,7 @@ component PublicLayout {
|
||||
<a href="/chat">{t:nav.chat}</a>
|
||||
<a href="/dashboard">{t:nav.dashboard}</a>
|
||||
<a href="/about">{t:nav.about}</a>
|
||||
<div data-component="theme-toggle" label="Theme"></div>
|
||||
<button type="button" data-wire-theme-toggle aria-label="Toggle color theme">Theme</button>
|
||||
<button class="wire-btn wire-btn--ghost wire-btn--sm" data-wire-lang-set="en">EN</button>
|
||||
<button class="wire-btn wire-btn--ghost wire-btn--sm" data-wire-lang-set="es">ES</button>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
page AsyncData {
|
||||
layout = "public"
|
||||
|
||||
load server summary {
|
||||
return { message: "Rendered by the server loader" }
|
||||
}
|
||||
|
||||
load client profile {
|
||||
return { name: "Ada", role: "Administrator" }
|
||||
}
|
||||
|
||||
view {
|
||||
<section class="stack">
|
||||
<h1>Async data boundaries</h1>
|
||||
|
||||
<Async source="summary">
|
||||
<Loading><p>Preparing the server summary…</p></Loading>
|
||||
<Success data="summary"><p>{summary.message}</p></Success>
|
||||
<Error error="error"><p>{error.message}</p></Error>
|
||||
</Async>
|
||||
|
||||
<Async source="profile" retries="2">
|
||||
<Loading><p>Loading the browser profile…</p></Loading>
|
||||
<Success data="profile"><p>Welcome {profile.name} — {profile.role}</p></Success>
|
||||
<Error error="error"><p>Profile failed: {error.message}</p></Error>
|
||||
</Async>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
page ClientOnly {
|
||||
layout = "public"
|
||||
render = "client"
|
||||
hydrate = "load"
|
||||
|
||||
state count: number = 0
|
||||
|
||||
view {
|
||||
<section class="wire-card stack">
|
||||
<p class="wire-badge">Mounted in the browser</p>
|
||||
<h1>Client-only page</h1>
|
||||
<button type="button" class="wire-btn" @click="count++">Count: {count}</button>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
@@ -27,10 +27,11 @@ page Home {
|
||||
restyles instantly when the theme changes. -->
|
||||
<div class="themed-box">
|
||||
This box uses theme tokens. Current theme controls its colors.
|
||||
<button data-wire-theme-toggle class="theme-toggle">Toggle theme</button>
|
||||
<button type="button" data-wire-theme-toggle class="theme-toggle">Toggle theme</button>
|
||||
</div>
|
||||
|
||||
<p><a class="text-red-600 hover:underline" href="/about">About</a></p>
|
||||
<p><a class="hover:underline" href="/language-tools">Language server playground</a></p>
|
||||
}
|
||||
|
||||
style {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// Real page for checking compiler, formatter, LSP navigation, rename, and reactivity.
|
||||
page LanguageTools {
|
||||
layout = "public"
|
||||
|
||||
state count: number = 0
|
||||
state message: string = "The shared language server is active."
|
||||
|
||||
computed doubled: number = count * 2
|
||||
|
||||
seo {
|
||||
title = "WRN language tools"
|
||||
description = "Interactive WRN page used to verify editor and compiler features."
|
||||
canonical = "/language-tools"
|
||||
}
|
||||
|
||||
functions {
|
||||
function increment() {
|
||||
count++
|
||||
message = `Renaming count updates all ${count} references safely.`
|
||||
}
|
||||
|
||||
function reset() {
|
||||
count = 0
|
||||
message = "State reset. Try hover, definition, references, and rename in your editor."
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<main class="language-tools">
|
||||
<header>
|
||||
<span class="eyebrow">Editor-neutral WRN tooling</span>
|
||||
<h1>Language server playground</h1>
|
||||
<p>{message}</p>
|
||||
</header>
|
||||
|
||||
<section aria-labelledby="counter-heading" class="demo-card">
|
||||
<h2 id="counter-heading">Reactive Data symbol test</h2>
|
||||
<p aria-live="polite">Count: {count}; computed double: {doubled}</p>
|
||||
<div class="actions">
|
||||
<button type="button" @click="increment()">Increment</button>
|
||||
<button type="button" class="secondary" @click="reset()">Reset</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="features-heading" class="demo-card">
|
||||
<h2 id="features-heading">Features exercised by this file</h2>
|
||||
<ul>
|
||||
<li>Compiler and accessibility diagnostics</li>
|
||||
<li>Canonical formatting and completion</li>
|
||||
<li>Hover, symbols, definition, references, and rename</li>
|
||||
<li>Typed state, computed values, and browser events</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
}
|
||||
|
||||
style {
|
||||
.language-tools {
|
||||
display: grid;
|
||||
max-width: 58rem;
|
||||
gap: 1.25rem;
|
||||
margin: 3rem auto;
|
||||
}
|
||||
|
||||
.language-tools header,
|
||||
.demo-card {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--wire-color-primary);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.demo-card {
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--wire-color-border);
|
||||
border-radius: var(--wire-radius-lg);
|
||||
background: var(--wire-color-surface);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
padding: 0.65rem 1rem;
|
||||
border: 1px solid var(--wire-color-primary);
|
||||
border-radius: var(--wire-radius-md);
|
||||
background: var(--wire-color-primary);
|
||||
color: var(--wire-color-primary-contrast);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.actions .secondary {
|
||||
background: transparent;
|
||||
color: var(--wire-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,24 +11,49 @@ page Login {
|
||||
|
||||
view {
|
||||
<form data-schema="login" method="post" action="/api/login" data-redirect="/dashboard">
|
||||
<div data-component="stack" gap="4">
|
||||
<div class="auth-stack">
|
||||
<h1>Sign in</h1>
|
||||
|
||||
<div class="wire-alert wire-alert--success" data-success="Signed in! The form posted JSON and was validated server-side." hidden></div>
|
||||
|
||||
<div>
|
||||
<div data-component="input" type="email" name="email" placeholder="you@example.com"></div>
|
||||
<label class="auth-field">
|
||||
<span>Email address</span>
|
||||
<input type="email" name="email" autocomplete="email" placeholder="you@example.com" required />
|
||||
<span class="wire-field-error" data-error="email"></span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<div data-component="input" type="password" name="password" placeholder="Password"></div>
|
||||
<label class="auth-field">
|
||||
<span>Password</span>
|
||||
<input type="password" name="password" autocomplete="current-password" placeholder="Password" required />
|
||||
<span class="wire-field-error" data-error="password"></span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<button type="submit" class="wire-btn wire-btn--primary wire-btn--lg">Sign in</button>
|
||||
<p><a href="/">Back home</a></p>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
style {
|
||||
form,
|
||||
.auth-stack,
|
||||
.auth-field {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
form,
|
||||
.auth-stack {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.auth-field input {
|
||||
min-height: 2.75rem;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border: 1px solid var(--wire-color-border);
|
||||
border-radius: var(--wire-radius-md);
|
||||
background: var(--wire-color-surface);
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
page PartialStatic {
|
||||
layout = "public"
|
||||
render = "partial-static"
|
||||
|
||||
seo {
|
||||
title = "Partial-static streaming"
|
||||
description = "A build-time WRNexus shell with request-streamed dynamic content."
|
||||
}
|
||||
|
||||
view {
|
||||
<Static>
|
||||
<section class="wire-card stack">
|
||||
<p class="wire-badge">Built once</p>
|
||||
<h1>Build-time static shell</h1>
|
||||
<p>This heading and page structure are emitted into dist/partial-shells.json.</p>
|
||||
</section>
|
||||
</Static>
|
||||
|
||||
<Dynamic>
|
||||
<section class="wire-card stack" aria-live="polite">
|
||||
<p class="wire-badge">Streamed per request</p>
|
||||
<h2>Dynamic account region</h2>
|
||||
<p>Personalized data belongs inside this boundary.</p>
|
||||
</section>
|
||||
</Dynamic>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
page PlatformShowcase {
|
||||
layout = "public"
|
||||
render = "hybrid"
|
||||
hydrate = "load"
|
||||
|
||||
state active = "Admin"
|
||||
|
||||
view {
|
||||
<main class="platform-showcase">
|
||||
<header>
|
||||
<span class="eyebrow">Reactive primitives</span>
|
||||
<h1>Reactive platform showcase</h1>
|
||||
<p>Switch a dynamic component and verify transition and portal rendering.</p>
|
||||
</header>
|
||||
<section class="showcase-card">
|
||||
<button type="button" @click='active = active === "Admin" ? "Guest" : "Admin"'>Switch component</button>
|
||||
<Transition name="fade">
|
||||
<Component is={active}>
|
||||
<section class="component-case" data-component-case="Admin"><strong>Administrator tools</strong><p>Manage users, permissions, and platform settings.</p></section>
|
||||
<section class="component-case" data-component-case="Guest"><strong>Guest dashboard</strong><p>Explore the public workspace and available resources.</p></section>
|
||||
</Component>
|
||||
</Transition>
|
||||
</section>
|
||||
<div id="showcase-modal" class="portal-target" aria-live="polite"></div>
|
||||
<Portal to="#showcase-modal"><p>Portaled content rendered in its target.</p></Portal>
|
||||
</main>
|
||||
}
|
||||
|
||||
style {
|
||||
.platform-showcase {
|
||||
display: grid;
|
||||
max-width: 58rem;
|
||||
gap: 1.25rem;
|
||||
margin: 3rem auto;
|
||||
}
|
||||
|
||||
.platform-showcase header,
|
||||
.showcase-card,
|
||||
.component-case {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--wire-color-primary);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.showcase-card,
|
||||
.portal-target {
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--wire-color-border);
|
||||
border-radius: var(--wire-radius-lg);
|
||||
background: var(--wire-color-surface);
|
||||
}
|
||||
|
||||
.showcase-card button {
|
||||
width: fit-content;
|
||||
padding: 0.7rem 1rem;
|
||||
border: 0;
|
||||
border-radius: var(--wire-radius-md);
|
||||
background: var(--wire-color-primary);
|
||||
color: var(--wire-color-primary-contrast);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fade-enter-active {
|
||||
animation: platform-fade 180ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes platform-fade {
|
||||
from { opacity: 0; transform: translateY(0.25rem); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { CreateUserSchema } from "../schemas/create-user";
|
||||
|
||||
page ServerActions {
|
||||
seo {
|
||||
title = "Typed server actions"
|
||||
description = "Schema validation, CSRF, invalidation and progressive enhancement"
|
||||
}
|
||||
|
||||
action createUser using CreateUserSchema {
|
||||
invalidate("users")
|
||||
return { id: crypto.randomUUID(), name: input.name, email: input.email }
|
||||
}
|
||||
|
||||
view {
|
||||
<main>
|
||||
<h1>Typed server actions</h1>
|
||||
<p>The form works with or without JavaScript.</p>
|
||||
<form @submit="createUser">
|
||||
<label for="action-name">Name</label>
|
||||
<input id="action-name" name="name" autocomplete="name" required />
|
||||
<label for="action-email">Email</label>
|
||||
<input id="action-email" name="email" type="email" autocomplete="email" required />
|
||||
<button type="submit">Create user</button>
|
||||
</form>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineJob } from "@wrnexus/queue";
|
||||
|
||||
export interface WelcomeEmailPayload {
|
||||
userId: number;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export default defineJob<WelcomeEmailPayload>({
|
||||
name: "welcome-email",
|
||||
async run(job) {
|
||||
console.log(`Welcome email queued for ${job.data.email}`);
|
||||
},
|
||||
});
|
||||
@@ -4,16 +4,50 @@
|
||||
export interface Routes {
|
||||
"/": Record<string, never>;
|
||||
"/about": Record<string, never>;
|
||||
"/async-data": Record<string, never>;
|
||||
"/chat": Record<string, never>;
|
||||
"/client-only": Record<string, never>;
|
||||
"/dashboard": Record<string, never>;
|
||||
"/hello": Record<string, never>;
|
||||
"/language-tools": Record<string, never>;
|
||||
"/login": Record<string, never>;
|
||||
"/modal": Record<string, never>;
|
||||
"/partial-static": Record<string, never>;
|
||||
"/platform-showcase": Record<string, never>;
|
||||
"/reactive": Record<string, never>;
|
||||
"/server-actions": Record<string, never>;
|
||||
"/test": Record<string, never>;
|
||||
"/ui": Record<string, never>;
|
||||
}
|
||||
|
||||
export interface RouteNames {
|
||||
"index": "/";
|
||||
"about": "/about";
|
||||
"async.data": "/async-data";
|
||||
"chat": "/chat";
|
||||
"client.only": "/client-only";
|
||||
"dashboard": "/dashboard";
|
||||
"hello": "/hello";
|
||||
"language.tools": "/language-tools";
|
||||
"login": "/login";
|
||||
"modal": "/modal";
|
||||
"partial.static": "/partial-static";
|
||||
"platform.showcase": "/platform-showcase";
|
||||
"reactive": "/reactive";
|
||||
"server.actions": "/server-actions";
|
||||
"test": "/test";
|
||||
"ui": "/ui";
|
||||
}
|
||||
|
||||
export interface RouteQueries {
|
||||
[path: string]: Record<string, string | number | boolean | null | undefined>;
|
||||
}
|
||||
|
||||
export type RoutePath = keyof Routes;
|
||||
export type RouteName = keyof RouteNames;
|
||||
export type RouteQuery<P extends RoutePath> = P extends keyof RouteQueries
|
||||
? RouteQueries[P]
|
||||
: Record<string, string | number | boolean | null | undefined>;
|
||||
type RouteValue = string | readonly string[] | undefined;
|
||||
|
||||
function encodeRouteValue(value: RouteValue, catchAll: boolean): string {
|
||||
@@ -22,17 +56,9 @@ function encodeRouteValue(value: RouteValue, catchAll: boolean): string {
|
||||
return values.map((part) => encodeURIComponent(part)).join("/");
|
||||
}
|
||||
|
||||
export function href<P extends RoutePath>(
|
||||
path: P,
|
||||
...args: keyof Routes[P] extends never
|
||||
? []
|
||||
: Record<string, never> extends Routes[P]
|
||||
? [params?: Routes[P]]
|
||||
: [params: Routes[P]]
|
||||
): string {
|
||||
const params = (args[0] ?? {}) as Record<string, RouteValue>;
|
||||
function buildHref(path: string, params: Record<string, RouteValue> = {}): string {
|
||||
const output: string[] = [];
|
||||
for (const segment of String(path).split("/").filter(Boolean)) {
|
||||
for (const segment of path.split("/").filter(Boolean)) {
|
||||
let name: string | undefined;
|
||||
let optional = false;
|
||||
let catchAll = false;
|
||||
@@ -61,3 +87,51 @@ export function href<P extends RoutePath>(
|
||||
}
|
||||
return "/" + output.filter(Boolean).join("/");
|
||||
}
|
||||
|
||||
export function href<P extends RoutePath>(
|
||||
path: P,
|
||||
...args: keyof Routes[P] extends never
|
||||
? []
|
||||
: Record<string, never> extends Routes[P]
|
||||
? [params?: Routes[P]]
|
||||
: [params: Routes[P]]
|
||||
): string {
|
||||
const params = (args[0] ?? {}) as Record<string, RouteValue>;
|
||||
return buildHref(String(path), params);
|
||||
}
|
||||
|
||||
export function route<N extends RouteName>(
|
||||
name: N,
|
||||
...args: keyof Routes[RouteNames[N]] extends never
|
||||
? [params?: Routes[RouteNames[N]], query?: RouteQuery<RouteNames[N]>]
|
||||
: Record<string, never> extends Routes[RouteNames[N]]
|
||||
? [params?: Routes[RouteNames[N]], query?: RouteQuery<RouteNames[N]>]
|
||||
: [params: Routes[RouteNames[N]], query?: RouteQuery<RouteNames[N]>]
|
||||
): string {
|
||||
const paths: Record<RouteName, RoutePath> = {
|
||||
"index": "/",
|
||||
"about": "/about",
|
||||
"async.data": "/async-data",
|
||||
"chat": "/chat",
|
||||
"client.only": "/client-only",
|
||||
"dashboard": "/dashboard",
|
||||
"hello": "/hello",
|
||||
"language.tools": "/language-tools",
|
||||
"login": "/login",
|
||||
"modal": "/modal",
|
||||
"partial.static": "/partial-static",
|
||||
"platform.showcase": "/platform-showcase",
|
||||
"reactive": "/reactive",
|
||||
"server.actions": "/server-actions",
|
||||
"test": "/test",
|
||||
"ui": "/ui"
|
||||
} as Record<RouteName, RoutePath>;
|
||||
const output = buildHref(paths[name], (args[0] ?? {}) as Record<string, RouteValue>);
|
||||
const query = args[1];
|
||||
if (!query) return output;
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query))
|
||||
if (value !== undefined && value !== null) search.set(key, String(value));
|
||||
const text = search.toString();
|
||||
return text ? `${output}?${text}` : output;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { v } from "@wrnexus/validation";
|
||||
|
||||
export const CreateUserSchema = v.object({
|
||||
name: v.string().trim().min(2),
|
||||
email: v.string().trim().email(),
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
createHarness,
|
||||
describe,
|
||||
expect,
|
||||
test,
|
||||
type Harness,
|
||||
} from "@wrnexus/test";
|
||||
|
||||
describe("real application security abuse checks", () => {
|
||||
let app: Harness;
|
||||
beforeAll(async () => {
|
||||
app = await createHarness(import.meta.dir + "/..");
|
||||
});
|
||||
afterAll(() => app?.close());
|
||||
|
||||
test("sets browser hardening headers on rendered pages", async () => {
|
||||
const response = await app.fetch("/");
|
||||
expect(response.headers.get("content-security-policy")).toContain("default-src");
|
||||
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
expect(response.headers.get("x-frame-options")).toBeTruthy();
|
||||
expect(response.headers.get("referrer-policy")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("does not reflect script payloads into HTML", async () => {
|
||||
const payload = `<script>globalThis.__attacked=true</script>`;
|
||||
const response = await app.fetch(`/?search=${encodeURIComponent(payload)}`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).not.toContain(payload);
|
||||
});
|
||||
|
||||
test("rejects traversal attempts without exposing source files", async () => {
|
||||
for (const path of ["/../../package.json", "/%2e%2e/%2e%2e/package.json", "/..%5c..%5c.env"]) {
|
||||
const response = await app.fetch(path);
|
||||
expect([400, 404]).toContain(response.status);
|
||||
const body = await response.text();
|
||||
expect(body).not.toContain("DATABASE_URL");
|
||||
expect(body).not.toContain('"workspaces"');
|
||||
}
|
||||
});
|
||||
|
||||
test("does not grant CORS credentials to an untrusted origin", async () => {
|
||||
const response = await app.fetch("/api/hello", {
|
||||
headers: { origin: "https://evil.example" },
|
||||
});
|
||||
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
||||
expect(response.headers.get("access-control-allow-credentials")).toBeNull();
|
||||
});
|
||||
|
||||
test("requires CSRF for login and avoids credential oracle details", async () => {
|
||||
const response = await app.fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ email: "victim@example.com", password: "wrong-password" }),
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.text()).not.toContain("passwordHash");
|
||||
});
|
||||
|
||||
test("handles malformed JSON without a stack trace", async () => {
|
||||
const response = await app.fetch("/api/echo", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "{broken",
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
const body = await response.text();
|
||||
expect(body).not.toContain(" at ");
|
||||
expect(body).not.toContain("node_modules");
|
||||
});
|
||||
|
||||
test("refuses actual request bodies over the configured limit", async () => {
|
||||
try {
|
||||
const response = await app.fetch("/api/echo", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: `"${"x".repeat(10 * 1024 * 1024)}"`,
|
||||
});
|
||||
expect(response.status).toBe(413);
|
||||
} catch (error) {
|
||||
// Bun rejects the oversized socket before application dispatch on some versions.
|
||||
expect(String(error)).toMatch(/ECONNRESET|socket connection was closed/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Context } from "@wrnexus/core";
|
||||
|
||||
const apiInput: WRNexusGenerated.ApiContracts["/api/typed-user"]["POST"]["input"] = {
|
||||
name: "Ada",
|
||||
email: "ada@example.test",
|
||||
};
|
||||
const queuePayload: WRNexusGenerated.QueuePayloads["welcome-email"] = {
|
||||
userId: 42,
|
||||
email: "ada@example.test",
|
||||
};
|
||||
const queryArgs: WRNexusGenerated.DatabaseQueries["GetUserByEmail"]["args"] = {
|
||||
email: "ada@example.test",
|
||||
};
|
||||
const realtimeMessage: WRNexusGenerated.RealtimeMessages["/realtime/chat"] = {
|
||||
user: "ada",
|
||||
text: "hello",
|
||||
};
|
||||
const middlewareContext: WRNexusGenerated.MiddlewareContexts["auth"] = {} as Context;
|
||||
|
||||
void [apiInput, queuePayload, queryArgs, realtimeMessage, middlewareContext];
|
||||
|
||||
// @ts-expect-error schema-derived input requires an email.
|
||||
const invalidApiInput: WRNexusGenerated.ApiContracts["/api/typed-user"]["POST"]["input"] = {
|
||||
name: "Missing email",
|
||||
};
|
||||
void invalidApiInput;
|
||||
|
||||
// @ts-expect-error generated SQL query arguments require an email string.
|
||||
const invalidQuery: WRNexusGenerated.DatabaseQueries["GetUserByEmail"]["args"] = {};
|
||||
void invalidQuery;
|
||||
@@ -0,0 +1,59 @@
|
||||
// AUTO-GENERATED by `wrnexus generate types` - do not edit.
|
||||
declare namespace WRNexusGenerated {
|
||||
type ApiContract<T> = T extends import("@wrnexus/core").DefinedEndpoint<infer I, infer O>
|
||||
? { input: I; output: O }
|
||||
: T extends (...args: infer A) => infer R
|
||||
? { input: A extends [any, infer I, ...any[]] ? I : unknown; output: Awaited<R> }
|
||||
: { input: unknown; output: unknown };
|
||||
type MiddlewareContext<T> = T extends (ctx: infer C, ...args: any[]) => any ? C : never;
|
||||
type QueryContract<T> = T extends (db: any, args: infer A, ...rest: any[]) => infer R
|
||||
? { args: A; result: Awaited<R> }
|
||||
: T extends (db: any, ...rest: any[]) => infer R
|
||||
? { args: Record<string, never>; result: Awaited<R> }
|
||||
: never;
|
||||
type RealtimeMessage<T> = T extends import("@wrnexus/core").RoomDefinition<any, infer M> ? M : unknown;
|
||||
type QueuePayload<T> = T extends import("@wrnexus/queue").JobDefinition<infer I> ? I : unknown;
|
||||
type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "language.tools" | "login" | "modal" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "test" | "ui";
|
||||
type ApiRoute = "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment";
|
||||
type RealtimeRoute = "/realtime/chat" | "/realtime/hello";
|
||||
type EnvironmentKey = "APP_LABEL" | "DATABASE_URL" | "DEMO_SHARED" | "HOST" | "NODE_ENV" | "PORT" | "SESSION_SECRET" | "UAT_ONLY";
|
||||
type TranslationKey = "api.greeting" | "home.intro" | "home.title" | "nav.about" | "nav.chat" | "nav.dashboard" | "nav.home" | "nav.ui";
|
||||
type QueueName = "welcome-email";
|
||||
type CacheKey = "users";
|
||||
interface Components {
|
||||
"Modal": { props: Record<string, never>; outputs: Record<string, never> };
|
||||
"Counter": { props: { "start"?: unknown; "label"?: unknown }; outputs: Record<string, never> };
|
||||
}
|
||||
interface ApiContracts {
|
||||
"/api/webhooks/payment": { POST: ApiContract<typeof import("../api/webhooks/payment.ts")["POST"]> };
|
||||
"/api/users/csr": { GET: ApiContract<typeof import("../api/users/csr.ts")["GET"]> };
|
||||
"/api/users/ssr": { GET: ApiContract<typeof import("../api/users/ssr.ts")["GET"]> };
|
||||
"/api/graphql-example": { POST: ApiContract<typeof import("../api/graphql-example.ts")["POST"]> };
|
||||
"/api/typed-user": { POST: ApiContract<typeof import("../api/typed-user.ts")["POST"]> };
|
||||
"/api/logout": { POST: ApiContract<typeof import("../api/logout.ts")["POST"]> };
|
||||
"/api/hello": { GET: ApiContract<typeof import("../api/hello.ts")["GET"]> };
|
||||
"/api/login": { POST: ApiContract<typeof import("../api/login.ts")["POST"]> };
|
||||
"/api/echo": { GET: ApiContract<typeof import("../api/echo.ts")["GET"]>; POST: ApiContract<typeof import("../api/echo.ts")["POST"]> };
|
||||
"/api/me": { GET: ApiContract<typeof import("../api/me.ts")["GET"]> };
|
||||
}
|
||||
interface MiddlewareContexts {
|
||||
"auth": MiddlewareContext<(typeof import("../middleware/auth.ts"))["default"]>;
|
||||
"logger": MiddlewareContext<(typeof import("../middleware/logger.ts"))["default"]>;
|
||||
"ratelimit": MiddlewareContext<(typeof import("../middleware/ratelimit.ts"))["default"]>;
|
||||
}
|
||||
interface DatabaseQueries {
|
||||
"GetUserByEmail": QueryContract<typeof import("../db/queries.gen.ts")["GetUserByEmail"]>;
|
||||
"ListUsers": QueryContract<typeof import("../db/queries.gen.ts")["ListUsers"]>;
|
||||
"CountActive": QueryContract<typeof import("../db/queries.gen.ts")["CountActive"]>;
|
||||
"CreateUser": QueryContract<typeof import("../db/queries.gen.ts")["CreateUser"]>;
|
||||
"DeactivateUser": QueryContract<typeof import("../db/queries.gen.ts")["DeactivateUser"]>;
|
||||
}
|
||||
interface RealtimeMessages {
|
||||
"/realtime/hello": RealtimeMessage<(typeof import("../pages/hello.wrn"))["default"]>;
|
||||
"/realtime/chat": RealtimeMessage<(typeof import("../realtime/chat.ts"))["default"]>;
|
||||
}
|
||||
interface QueuePayloads {
|
||||
"welcome-email": QueuePayload<(typeof import("../queues/welcome-email.ts"))["default"]>;
|
||||
}
|
||||
type ApplicationConfig = (typeof import("../../wrnexus.config.ts"))["default"];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// AUTO-GENERATED plugin type aggregation - do not edit.
|
||||
Reference in New Issue
Block a user