diff --git a/AGENTS.md b/AGENTS.md index d5ca7399..ff6afca8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -302,7 +302,7 @@ The server-only-utility row (`.server.ts`, no `'use server'`) is a runtime trap: Server actions export named async functions whose args + returns round-trip through the serializer. **Importing from a client component IS the API** (rewritten to an RPC stub; never hand-write `fetch()`). **REST over HTTP is a `route.ts`** that imports and calls the action (optionally via the `route(action, opts?)` adapter from `@webjsdev/server`, which merges query + route params + JSON body into one input object and JSON-responds). **Input validation (#245)** is declared via the `export const validate` config export (read on the RPC boundary); a `route()` endpoint passes the same validator as its `{ validate }` option. The framework only CALLS the validator (ships no validation library) and reads its return (`{ success: true, data? }` runs the action, `{ success: false, fieldErrors }` returns a `422` without running the body, a THROW is a sanitized error, any other value is transformed input). The validator stays server-side and receives the action's first argument. Full reference in `agent-docs/recipes.md`. -**HTTP-verb actions via config exports (#488).** A `'use server'` action declares its HTTP semantics through RESERVED sibling exports the framework reads statically, the same way a page declares `export const revalidate`: `export const method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'` (absent = POST, so existing actions are unchanged), `export const cache = 60` (seconds, or `{ maxAge, swr, public }`, default `private`; **`public: true` SHARES the response across users keyed only by URL + args, so use it ONLY for data identical for every visitor, never for a session / per-user read, the same safety rule as a page's `export const revalidate`**), `export const tags = (id) => [\`user:${id}\`]` (a GET's cache tags), `export const invalidates = (id) => [...]` (a mutation's tags to evict), `export const validate = (input) => ...` (the boundary validator), and `export const middleware = [mw1, mw2]` (#490: per-action middleware, each `async (ctx, next) => result`, run around the action on BOTH the RPC and `route.ts` (including the `route()` adapter) boundaries; a middleware short-circuits by returning an `ActionResult` instead of calling `next()`, and accumulates context the action reads via `actionContext()` from `@webjsdev/server`, no signature change). The function stays a plain `export async function`; **one function per file** (a configured file with more than one callable function is a `webjs check` error). The call site never changes (`await getUser(7)`); the verb only changes the transport: a **GET** rides args in the URL (`?a=`, with a POST fallback over a 4KB cap), is CSRF-exempt, carries `Cache-Control` + a weak ETag (answering `If-None-Match` with a 304) and `X-Webjs-Tags`, and reads the SSR seed (#472) first; a **mutation** (POST/PUT/PATCH/DELETE) sends the rich body (DELETE rides the URL), is CSRF-protected, and on completion (the action did not throw) evicts its `invalidates` tags from the server `cache()` (`revalidateTags`) and reports them via `X-Webjs-Invalidate` so the client browser-cache coordinator revalidates a later read. A mismatched request method is a `405` + `Allow`. Why WebJs needs this and Next does not: WebJs has no RSC server/client split, so reads and writes both flow through the one action mechanism (Next's reads are Server Component fetches, so its actions stay POST-only). `validate` is a BOUNDARY concern (the RPC endpoint and a `route.ts`), not a direct server-to-server call. A public REST endpoint is a `route.ts` that imports and calls the action (optionally via the `route()` adapter). **Cancellation (#492):** an action reads the request's `AbortSignal` via `actionSignal()` (from `@webjsdev/server`) to stop work on a client disconnect / abort (a never-aborting signal outside an action keeps the line safe server-to-server); on the client, a superseded `async render()` automatically ABORTS the previous render's in-flight action fetch (not just drops it), via a per-render `AbortController` the stub binds each fetch to. **Streaming results (#489):** an action that RETURNS a `ReadableStream` / async iterable / async generator (any verb) streams its chunks over the single RPC response instead of buffering; the call site does `for await (const chunk of await streamTokens(8))` and each rich-serialized chunk arrives as it is yielded (back-pressure respected, the source generator cancelled on a client disconnect / superseded render). Detection is purely on the return value (no config export); a streamed result is never cached / ETagged / seeded (a mutation still emits `X-Webjs-Invalidate`). A mid-stream throw surfaces as an error from the iterable (the HTTP status is already 200), the author message in prod. +**HTTP-verb actions via config exports (#488).** A `'use server'` action declares its HTTP semantics through RESERVED sibling exports the framework reads statically, the same way a page declares `export const revalidate`: `export const method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'` (absent = POST, so existing actions are unchanged), `export const cache = 60` (seconds, or `{ maxAge, swr, public }`, default `private`; **`public: true` SHARES the response across users keyed only by URL + args, so use it ONLY for data identical for every visitor, never for a session / per-user read, the same safety rule as a page's `export const revalidate`**), `export const tags = (id) => [\`user:${id}\`]` (a GET's cache tags), `export const invalidates = (id) => [...]` (a mutation's tags to evict), `export const validate = (input) => ...` (the boundary validator), and `export const middleware = [mw1, mw2]` (#490: per-action middleware, each `async (ctx, next) => result`; a middleware short-circuits by returning an `ActionResult` instead of calling `next()`, and accumulates context the action reads via `actionContext()` from `@webjsdev/server`, no signature change). The declared middleware runs automatically on the RPC boundary. On the `route.ts` boundary it applies when the `route()` adapter is given the action's MODULE NAMESPACE (`import * as postActions from '...'; export const POST = route(postActions)`), which reads the declared `middleware` + `validate`; the bare-function form (importing just the function, `import { createPost }` then `route(createPost)`) runs only what you pass in `route(fn, { middleware })`, because a function reference cannot reach its sibling config exports (#876). The function stays a plain `export async function`; **one function per file** (a configured file with more than one callable function is a `webjs check` error). The call site never changes (`await getUser(7)`); the verb only changes the transport: a **GET** rides args in the URL (`?a=`, with a POST fallback over a 4KB cap), is CSRF-exempt, carries `Cache-Control` + a weak ETag (answering `If-None-Match` with a 304) and `X-Webjs-Tags`, and reads the SSR seed (#472) first; a **mutation** (POST/PUT/PATCH/DELETE) sends the rich body (DELETE rides the URL), is CSRF-protected, and on completion (the action did not throw) evicts its `invalidates` tags from the server `cache()` (`revalidateTags`) and reports them via `X-Webjs-Invalidate` so the client browser-cache coordinator revalidates a later read. A mismatched request method is a `405` + `Allow`. Why WebJs needs this and Next does not: WebJs has no RSC server/client split, so reads and writes both flow through the one action mechanism (Next's reads are Server Component fetches, so its actions stay POST-only). `validate` is a BOUNDARY concern (the RPC endpoint and a `route.ts`), not a direct server-to-server call. A public REST endpoint is a `route.ts` that imports and calls the action (optionally via the `route()` adapter). **Cancellation (#492):** an action reads the request's `AbortSignal` via `actionSignal()` (from `@webjsdev/server`) to stop work on a client disconnect / abort (a never-aborting signal outside an action keeps the line safe server-to-server); on the client, a superseded `async render()` automatically ABORTS the previous render's in-flight action fetch (not just drops it), via a per-render `AbortController` the stub binds each fetch to. **Streaming results (#489):** an action that RETURNS a `ReadableStream` / async iterable / async generator (any verb) streams its chunks over the single RPC response instead of buffering; the call site does `for await (const chunk of await streamTokens(8))` and each rich-serialized chunk arrives as it is yielded (back-pressure respected, the source generator cancelled on a client disconnect / superseded render). Detection is purely on the return value (no config export); a streamed result is never cached / ETagged / seeded (a mutation still emits `X-Webjs-Invalidate`). A mid-stream throw surfaces as an error from the iterable (the HTTP status is already 200), the author message in prod. ### RPC + REST endpoint security diff --git a/agent-docs/recipes.md b/agent-docs/recipes.md index 247bd030..14d2c434 100644 --- a/agent-docs/recipes.md +++ b/agent-docs/recipes.md @@ -200,6 +200,16 @@ option: `export const POST = route(createPost, { validate })`. A validator that THROWS (the classic `Schema.parse` style) becomes a 400, and a non-envelope return transforms the input. +To reuse the action's OWN declared `middleware` / `validate` config on the REST +boundary without re-listing it, pass the MODULE NAMESPACE to `route()` instead of +the function (#876): `import * as postActions from './create-post.server.ts'; +export const POST = route(postActions)`. The adapter finds the single action +function and applies its declared `middleware` and `validate`, so a guard +declared once next to the action protects the RPC and REST boundaries alike. +Passing the imported function itself (`import { createPost }` then +`route(createPost)`) cannot see sibling config exports, so it applies only what +you pass in `route(fn, { middleware, validate })`. + ## HTTP verbs, caching, streaming, and cancellation (#488, #489, #490, #492) A `'use server'` action is a POST by default. Reserved sibling exports, read diff --git a/blog/per-action-middleware.md b/blog/per-action-middleware.md index 079f56c9..f83c93be 100644 --- a/blog/per-action-middleware.md +++ b/blog/per-action-middleware.md @@ -70,22 +70,22 @@ First, the middleware short-circuits by returning an `ActionResult` instead of c Second, it accumulates context. Anything the middleware writes onto `ctx` is readable inside the action via `actionContext()` (imported from `@webjsdev/server`). The action's own signature never changes. `deletePost(id)` still takes one argument. The user arrives out of band, through the context the middleware built, so the calling code stays exactly as clean as it was. -# Where it runs, and the one place you wire it through by hand +# Where it runs on every entry point This is the part that made me want the feature. A WebJs action is reachable two ways. A client component imports it and the import becomes a typed RPC (Remote Procedure Call) stub. Or a `route.ts` REST endpoint imports and calls it, often through the `route()` adapter from `@webjsdev/server`. On the RPC boundary the declared middleware runs automatically. When the browser calls `deletePost` over RPC, `requireUser` runs first, because the framework reads the `export const middleware` config off the action and wraps the chain around it for you. That is the primary path for a WebJs app, since components import actions directly, and it needs no wiring at all. -The `route()` adapter is deliberately lower level, and here you pass the same chain explicitly: +The `route()` adapter picks the declared chain up too, as long as you hand it the action's module namespace rather than the bare function: ```ts // app/api/posts/[id]/route.ts import { route } from '@webjsdev/server'; -import { deletePost, middleware } from '#modules/posts/actions/delete-post.server.ts'; -export const DELETE = route(deletePost, { middleware }); +import * as postActions from '#modules/posts/actions/delete-post.server.ts'; +export const DELETE = route(postActions); // its declared middleware + validate apply ``` -I would honestly prefer `route(deletePost)` to pick the action's own middleware up on its own, the way the RPC boundary does. Today it does not, so you re-export the `middleware` array and hand it to the adapter. It is one line, and the guard still lives next to the action as its single source of truth, but it is a real seam to remember rather than an automatic guarantee. Verified by dogfooding: `route(action)` with no `middleware` option runs the body without the declared guards. +Passing the whole module lets the adapter read the `export const middleware` (and `export const validate`) sitting next to the action, so the guard you declared once protects the REST boundary automatically, the same way it does on the RPC one. The guard stays a property of the action, not of a single transport. If you instead import just the function (`import { deletePost }` then `route(deletePost)`), the adapter has no way to reach its sibling config, so there you pass the chain explicitly with `route(deletePost, { middleware })`. Verified by dogfooding: the module form applies the declared guards, the bare-function form runs only what you pass it. # Compose several, in order @@ -111,4 +111,4 @@ One rule ties this together. A configured action file holds exactly one callable # The takeaway -Auth, rate-limit checks, logging, and tenant resolution are cross-cutting, so they do not belong copy-pasted into the top of every action body. WebJs lets a `'use server'` action declare `export const middleware = [mw1, mw2]`, an array of `async (ctx, next) => result` functions that wrap the action. On the RPC boundary they run automatically; a `route.ts` REST endpoint built with the `route()` adapter takes the same array through its `middleware` option. A middleware short-circuits by returning an `ActionResult` before the body runs, and feeds context the action reads through `actionContext()` with no change to its signature. You write the guard once, next to the function, and reuse that one array on every path into the action. Next.js has no first-class primitive for this (you wrap manually or lean on route middleware that cannot even see the action), which is exactly the gap this closes. +Auth, rate-limit checks, logging, and tenant resolution are cross-cutting, so they do not belong copy-pasted into the top of every action body. WebJs lets a `'use server'` action declare `export const middleware = [mw1, mw2]`, an array of `async (ctx, next) => result` functions that wrap the action. On the RPC boundary they run automatically, and a `route.ts` REST endpoint built with the `route()` adapter picks them up automatically too when you hand it the action's module namespace (or takes the array through its `middleware` option if you pass the bare function). A middleware short-circuits by returning an `ActionResult` before the body runs, and feeds context the action reads through `actionContext()` with no change to its signature. You write the guard once, next to the function, and reuse that one array on every path into the action. Next.js has no first-class primitive for this (you wrap manually or lean on route middleware that cannot even see the action), which is exactly the gap this closes. diff --git a/docs/app/docs/server-actions/page.ts b/docs/app/docs/server-actions/page.ts index a524a737..a929e3a2 100644 --- a/docs/app/docs/server-actions/page.ts +++ b/docs/app/docs/server-actions/page.ts @@ -243,8 +243,9 @@ export async function POST(req: Request) {

The route() Adapter

For the common case (merge query + route params + JSON body into one input object, run an optional validator, JSON-respond the result), the route() helper from @webjsdev/server writes that handler for you in one line. The hand-written route.ts above is always available for full control (custom headers, content negotiation, streaming).

+

The examples below import the action FUNCTION, which is the bare-function form: it applies only the middleware / validate you pass in route(fn, {'{'} middleware, validate {'}'}), because a function reference cannot reach its sibling config exports. To auto-apply the action's OWN declared middleware and validate, import the module namespace instead (import * as postActions from '.../create-post.server.ts') and pass that: export const POST = route(postActions), so a guard declared once beside the action protects the RPC and REST boundaries alike (issue #876).

-
// app/api/posts/route.ts
+    
// app/api/posts/route.ts (bare-function form)
 import { route } from '@webjsdev/server';
 import { createPost } from '../../../modules/posts/actions/create-post.server.ts';
 
diff --git a/packages/cli/lib/create.js b/packages/cli/lib/create.js
index 105c7426..dff147e3 100644
--- a/packages/cli/lib/create.js
+++ b/packages/cli/lib/create.js
@@ -1179,8 +1179,9 @@ ${UI_THEME}
          Add a new design token the canonical way, a --x variable below plus a
          --color-x: var(--x) line in the @theme inline block, then use it as bg-x /
          text-x. Reach for opacity modifiers
-         (bg-primary/10, hover:bg-primary/90, text-muted-foreground/70) before
-         inventing a new token. */
+         (bg-primary/10, hover:bg-primary/90, border-border/60) before inventing a
+         new token, but keep body text at full opacity so it stays above the AA
+         contrast floor (a faded text-muted-foreground/70 measured 3.83:1). */
       :root {
         --font-sans:  -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
         --font-serif: ui-serif, 'Iowan Old Style', Palatino, Georgia, serif;
diff --git a/packages/cli/lib/saas-template.js b/packages/cli/lib/saas-template.js
index f70156bb..dbd626fc 100644
--- a/packages/cli/lib/saas-template.js
+++ b/packages/cli/lib/saas-template.js
@@ -27,7 +27,12 @@ async function readUiComponent(name) {
   // it to the scaffolded app's aliased path (cn lives at lib/utils/cn.ts).
   return raw
     .replaceAll("'../lib/utils.ts'", "'#lib/utils/cn.ts'")
-    .replaceAll('"../lib/utils.ts"', '"#lib/utils/cn.ts"');
+    .replaceAll('"../lib/utils.ts"', '"#lib/utils/cn.ts"')
+    // onBeforeCache lives in its own client-only module so cn() stays pure (#819).
+    // Without this rewrite dialog.ts keeps the registry-relative `../lib/dom.ts`
+    // (which resolves to a nonexistent components/lib/dom.ts) and fails typecheck.
+    .replaceAll("'../lib/dom.ts'", "'#lib/utils/dom.ts'")
+    .replaceAll('"../lib/dom.ts"', '"#lib/utils/dom.ts"');
 }
 
 /** Copy named registry components into `/components/ui/`. */
@@ -87,6 +92,15 @@ export async function writeSaasFiles(appDir, opts = {}) {
     "import { db } from '#db/connection.server.ts';",
     "import { compare } from './password.server.ts';",
     "",
+    "// AUTH_SECRET signs session tokens. Set a strong value in .env for any real",
+    "// deployment. The dev fallback keeps a fresh scaffold booting, but is NOT",
+    "// safe for production, so we fail fast if it is missing or blank there.",
+    "const trimmedSecret = process.env.AUTH_SECRET?.trim();",
+    "if (process.env.NODE_ENV === 'production' && !trimmedSecret) {",
+    "  throw new Error('AUTH_SECRET must be set in production');",
+    "}",
+    "const authSecret = trimmedSecret || 'dev-insecure-secret-change-me';",
+    "",
     "export const { auth, signIn, signOut, handlers } = createAuth({",
     "  providers: [",
     "    Credentials({",
@@ -97,7 +111,7 @@ export async function writeSaasFiles(appDir, opts = {}) {
     "      },",
     "    }),",
     "  ],",
-    "  secret: process.env.AUTH_SECRET,",
+    "  secret: authSecret,",
     "});",
     "",
   ].join('\n'));
@@ -324,7 +338,7 @@ export async function writeSaasFiles(appDir, opts = {}) {
     "    
", "
", "
", - "

Sign in

", + "

Sign in

", "

Welcome back: log in to continue.

", "
", "
", @@ -393,7 +407,7 @@ export async function writeSaasFiles(appDir, opts = {}) { "
", "
", "
", - "

Create an account

", + "

Create an account

", "

Get started with your new workspace.

", "
", "
", @@ -460,7 +474,7 @@ export async function writeSaasFiles(appDir, opts = {}) { "
", "
", "
", - "

Welcome, ${user?.name || user?.email}!

", + "

Welcome, ${user?.name || user?.email}!

", "

You're authenticated. Replace this scaffold with your real app.

", "
", "
", @@ -486,7 +500,7 @@ export async function writeSaasFiles(appDir, opts = {}) { "

Settings

", "
", "
", - "

Account

", + "

Account

", "

Your basic profile information.

", "
", "
", diff --git a/packages/cli/templates/gallery/app/features/file-storage/page.ts b/packages/cli/templates/gallery/app/features/file-storage/page.ts index 98eab5ce..14682eb1 100644 --- a/packages/cli/templates/gallery/app/features/file-storage/page.ts +++ b/packages/cli/templates/gallery/app/features/file-storage/page.ts @@ -42,7 +42,7 @@ export default function FileStorageExample({ setFileStore() call, no call-site change.

- @@ -54,7 +54,7 @@ export default function FileStorageExample({ ? html`
Stored ${name} - (${size} bytes) + (${size} bytes) download
` : ''} diff --git a/packages/cli/templates/gallery/app/features/forms/page.ts b/packages/cli/templates/gallery/app/features/forms/page.ts index 7df0754d..9c5368c3 100644 --- a/packages/cli/templates/gallery/app/features/forms/page.ts +++ b/packages/cli/templates/gallery/app/features/forms/page.ts @@ -26,7 +26,7 @@ const field = (label: string, name: string, input: unknown, error?: string) => h
`; -const inputCls = 'w-full bg-background border border-border rounded-xl px-3 py-2 text-[15px] text-foreground outline-none transition-colors focus:border-primary placeholder:text-muted-foreground/70'; +const inputCls = 'w-full bg-background border border-border rounded-xl px-3 py-2 text-[15px] text-foreground outline-none transition-colors focus:border-primary placeholder:text-muted-foreground'; export default function FormsFeature({ searchParams, actionData }: { searchParams: Record; actionData?: Result }) { if (searchParams.sent) { diff --git a/packages/cli/templates/gallery/modules/components/components/counter-card.ts b/packages/cli/templates/gallery/modules/components/components/counter-card.ts index 5f2084dd..39412047 100644 --- a/packages/cli/templates/gallery/modules/components/components/counter-card.ts +++ b/packages/cli/templates/gallery/modules/components/components/counter-card.ts @@ -24,7 +24,7 @@ export class CounterCard extends WebComponent({
${this.count.get()} - ${this.label} + ${this.label}
diff --git a/packages/cli/templates/gallery/modules/directives/components/directive-demo.ts b/packages/cli/templates/gallery/modules/directives/components/directive-demo.ts index 96ce0569..528c80af 100644 --- a/packages/cli/templates/gallery/modules/directives/components/directive-demo.ts +++ b/packages/cli/templates/gallery/modules/directives/components/directive-demo.ts @@ -60,7 +60,7 @@ export class DirectiveDemo extends WebComponent {
    ${repeat(this.items.get(), (it: Item) => it.id, (it: Item) => html`
  • - #${it.id}${it.label} + #${it.id}${it.label}
  • `)}
@@ -76,6 +76,7 @@ export class DirectiveDemo extends WebComponent {
this.text.set((e.target as HTMLInputElement).value)}> diff --git a/packages/cli/templates/gallery/modules/rate-limit/components/rate-probe.ts b/packages/cli/templates/gallery/modules/rate-limit/components/rate-probe.ts index e6f785f2..945079dd 100644 --- a/packages/cli/templates/gallery/modules/rate-limit/components/rate-probe.ts +++ b/packages/cli/templates/gallery/modules/rate-limit/components/rate-probe.ts @@ -38,7 +38,7 @@ export class RateProbe extends WebComponent {
  • #${p.n} ${p.status === 429 ? '429 limited' : '200 ok'} - remaining: ${p.remaining} + remaining: ${p.remaining}
  • `)} diff --git a/packages/cli/templates/gallery/modules/server-actions/components/greeter.ts b/packages/cli/templates/gallery/modules/server-actions/components/greeter.ts index bc7b6b08..6c7a52af 100644 --- a/packages/cli/templates/gallery/modules/server-actions/components/greeter.ts +++ b/packages/cli/templates/gallery/modules/server-actions/components/greeter.ts @@ -18,7 +18,7 @@ export class Greeter extends WebComponent { this.run(e)} class="flex items-center gap-2 p-2 pl-4 rounded-2xl bg-card border border-border"> + class="flex-1 min-w-0 bg-transparent border-0 outline-none text-foreground text-[15px] placeholder:text-muted-foreground py-1.5" /> diff --git a/packages/cli/templates/gallery/modules/todo/components/todo-app.ts b/packages/cli/templates/gallery/modules/todo/components/todo-app.ts index 0929457e..3bdf0dd3 100644 --- a/packages/cli/templates/gallery/modules/todo/components/todo-app.ts +++ b/packages/cli/templates/gallery/modules/todo/components/todo-app.ts @@ -82,7 +82,7 @@ export class TodoApp extends WebComponent({

    Tasks

    -

    ${done} of ${list.length} done

    +

    ${done} of ${list.length} done

    ${pct}%
    @@ -97,7 +97,7 @@ export class TodoApp extends WebComponent({ class="flex items-center gap-2 p-2 pl-4 rounded-2xl bg-card border border-border shadow-[0_1px_0_0_color-mix(in_oklch,var(--foreground)_5%,transparent)]"> + class="flex-1 min-w-0 bg-transparent border-0 outline-none text-foreground text-[15px] placeholder:text-muted-foreground py-1.5" /> @@ -120,17 +120,17 @@ export class TodoApp extends WebComponent({ - + `) : html` -
  • No tasks yet. Add your first one above.
  • +
  • No tasks yet. Add your first one above.
  • `} diff --git a/packages/server/src/action-route.js b/packages/server/src/action-route.js index 6f8f1a40..c9d1a033 100644 --- a/packages/server/src/action-route.js +++ b/packages/server/src/action-route.js @@ -28,6 +28,22 @@ * export const POST = route(createPost, { validate: createPostSchema }); * ``` * + * Passing the action's MODULE NAMESPACE instead of the function auto-applies the + * action's declared `export const middleware` and `export const validate`, so a + * guard declared once next to the action protects the REST boundary the same way + * it protects the RPC boundary (#876). This is the way to keep "declare once, + * protected on every entry point" true without re-listing the config: + * + * ```js + * // app/api/posts/route.ts + * import { route } from '@webjsdev/server'; + * import * as postActions from '../../../modules/posts/actions/create-post.server.ts'; + * export const POST = route(postActions); // its declared middleware + validate apply + * ``` + * + * The function form still takes explicit `opts`, and an explicit `opts.middleware` + * / `opts.validate` overrides the module's declared config when both are given. + * * Adapter rules when invoked over HTTP: * - URL query params, route params (`ctx.params`), and the parsed JSON body * (for a method with a body) are merged into a single object argument @@ -51,6 +67,7 @@ import { runValidate } from './actions.js'; import { runWithActionSignal } from './action-signal.js'; import { runActionChain } from './action-middleware.js'; +import { actionFunctionNames, actionMiddleware } from './action-config.js'; import { readTextBounded, payloadTooLarge, DEFAULT_MAX_BODY_BYTES } from './body-limit.js'; import { getBodyLimits } from './context.js'; @@ -69,16 +86,48 @@ function jsonBodyLimit() { * Expose a plain `'use server'` action over REST as a `route.ts`-style handler. * * @template A, R - * @param {(input: A, ctx: { req: Request, params: Record }) => R | Promise} action - * the server action to call (its source is unchanged; this only wraps the - * HTTP-to-action adapter around it). + * @param {((input: A, ctx: { req: Request, params: Record }) => R | Promise) | Record} actionOrModule + * the server action to call, OR the action's module namespace (`import * as m`) + * so its declared `middleware` / `validate` config is auto-applied (#876). Its + * source is unchanged; this only wraps the HTTP-to-action adapter around it. * @param {{ * validate?: (input: any) => any, * middleware?: Function[], * }} [opts] * @returns {(req: Request, ctx?: { params?: Record }) => Promise} */ -export function route(action, opts = {}) { +export function route(actionOrModule, opts = {}) { + // Resolve the callable action plus any config the caller did not pass + // explicitly. A function is used verbatim (the original form). A module + // namespace exposes the single action function and its declared config, so + // `route(mod)` inherits the same `middleware` / `validate` the RPC boundary + // reads (#876). An explicit `opts` value still wins over the declared one. + let action; + /** @type {{ middleware?: Function[], validate?: Function }} */ + let declared = {}; + if (typeof actionOrModule === 'function') { + action = actionOrModule; + } else if (actionOrModule && typeof actionOrModule === 'object') { + const names = actionFunctionNames(actionOrModule); + if (names.length !== 1) { + throw new Error( + `route(module) expects exactly one action function in the module, found ${names.length}` + + (names.length ? ` (${names.join(', ')})` : ''), + ); + } + action = /** @type {Function} */ (actionOrModule[names[0]]); + declared = { + middleware: actionMiddleware(actionOrModule), + validate: typeof actionOrModule.validate === 'function' + ? /** @type {Function} */ (actionOrModule.validate) + : undefined, + }; + } else { + throw new Error('route() expects an action function or a module namespace'); + } + const validate = opts.validate ?? declared.validate; + const middlewareChain = opts.middleware ?? declared.middleware ?? []; + return async function handler(req, ctx) { const params = (ctx && ctx.params) || {}; const url = new URL(req.url); @@ -102,13 +151,13 @@ export function route(action, opts = {}) { } let arg = { ...query, ...params, ...body }; - if (typeof opts.validate === 'function') { + if (typeof validate === 'function') { // Run the validator through the SHARED contract (#245) so the REST adapter // and the RPC path interpret a `{ success, fieldErrors }` envelope, a // throw, and a transform-return identically. A structured failure becomes // a 422 JSON; a throw stays a 400 (keeping a schema lib's `issues`); a // transform-return replaces the input. - const v = runValidate(opts.validate, arg); + const v = runValidate(validate, arg); if (!v.ok) { if (v.thrown !== undefined) { const msg = v.result.error || 'Invalid input'; @@ -126,10 +175,9 @@ export function route(action, opts = {}) { // The action runs inside the request signal scope (#492) and the per-action // middleware chain (#490). `ranAction` distinguishes a real completion from // a middleware short-circuit (the action never ran). - const middleware = opts.middleware || []; let ranAction = false; const result = await runWithActionSignal(req.signal, () => - runActionChain(middleware, { request: req, args: [arg], signal: req.signal }, () => { + runActionChain(middlewareChain, { request: req, args: [arg], signal: req.signal }, () => { ranAction = true; return action(/** @type any */ (arg), { req, params }); })); diff --git a/packages/server/test/actions/action-route.test.js b/packages/server/test/actions/action-route.test.js index 9a41d21b..94542e02 100644 --- a/packages/server/test/actions/action-route.test.js +++ b/packages/server/test/actions/action-route.test.js @@ -135,3 +135,62 @@ test('a validator transform-return reaches the action body (REST boundary, #245) await handler(new Request('http://x/api/x?a=1', { method: 'GET' })); assert.deepEqual(received, { a: '1', coerced: true }); }); + +// --- Module-namespace form: auto-apply the action's declared config (#876) --- + +test('route(module) applies the action-declared middleware (short-circuit)', async () => { + let ran = false; + // A module namespace: one action function + a declared `export const middleware`. + const mod = { + middleware: [async () => ({ success: false, error: 'mw-blocked', status: 403 })], + async guarded() { ran = true; return { success: true }; }, + }; + const handler = route(mod); + const res = await handler(new Request('http://x/api/x?a=1', { method: 'POST' })); + assert.equal(res.status, 403); + assert.deepEqual(await res.json(), { success: false, error: 'mw-blocked' }); + assert.equal(ran, false, 'declared middleware must gate the route boundary too'); +}); + +test('COUNTERFACTUAL: route(fn) without opts does NOT apply the declared middleware', async () => { + // Passing the bare function (not the module) cannot see sibling config, so the + // body runs. This is the exact gap #876 fixes for the module form. + let ran = false; + const mod = { + middleware: [async () => ({ success: false, error: 'mw-blocked', status: 403 })], + async guarded() { ran = true; return { success: true }; }, + }; + const handler = route(mod.guarded); + const res = await handler(new Request('http://x/api/x?a=1', { method: 'POST' })); + assert.equal(res.status, 200); + assert.equal(ran, true, 'the bare-function form has no access to declared middleware'); +}); + +test('route(module) applies the action-declared validate', async () => { + const mod = { + validate: (input) => (input.title ? { success: true } : { success: false, fieldErrors: { title: 'required' } }), + async createPost(input) { return { made: input.title }; }, + }; + const handler = route(mod); + const bad = await handler(new Request('http://x/api/x', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })); + assert.equal(bad.status, 422); + const ok = await handler(new Request('http://x/api/x', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title: 'hi' }) })); + assert.deepEqual(await ok.json(), { made: 'hi' }); +}); + +test('explicit opts override the module-declared config', async () => { + const mod = { + middleware: [async () => ({ success: false, error: 'declared', status: 403 })], + async guarded() { return { success: true, from: 'body' }; }, + }; + // Passing an empty middleware array explicitly overrides the declared one. + const handler = route(mod, { middleware: [] }); + const res = await handler(new Request('http://x/api/x?a=1', { method: 'POST' })); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { success: true, from: 'body' }); +}); + +test('route(module) with more than one action function throws', () => { + const mod = { async a() {}, async b() {} }; + assert.throws(() => route(mod), /exactly one action function/); +}); diff --git a/test/scaffolds/scaffold-integration.test.js b/test/scaffolds/scaffold-integration.test.js index 014adeb8..f8916ad4 100644 --- a/test/scaffolds/scaffold-integration.test.js +++ b/test/scaffolds/scaffold-integration.test.js @@ -415,8 +415,63 @@ test('scaffoldApp saas: writes auth + dashboard + Drizzle User model', async () if (!c.endsWith('.ts')) continue; const src = readFileSync(join(appDir, 'components', 'ui', c), 'utf8'); assert.doesNotMatch(src, /from ['"]\.\.\/lib\/utils\.ts['"]/, `${c} must not keep the stale ../lib/utils.ts cn import`); + // #877: the onBeforeCache import must be rewritten the same way. The saas + // generator previously rewrote only the cn() import, so dialog.ts kept the + // registry-relative `../lib/dom.ts` (a nonexistent components/lib/dom.ts) + // and failed `webjs typecheck` with TS2307. Counterfactual: the missing + // rewrite leaves `'../lib/dom.ts'` and this fails. + assert.doesNotMatch(src, /from ['"]\.\.\/lib\/dom\.ts['"]/, `${c} must not keep the stale ../lib/dom.ts import`); + if (/onBeforeCache/.test(src)) { + assert.match(src, /from ['"]#lib\/utils\/dom\.ts['"]/, `${c} imports onBeforeCache from #lib/utils/dom.ts`); + } + } + + // #877: lib/auth.server.ts must not assign `process.env.AUTH_SECRET` + // (string | undefined) straight to the required `string` secret (TS2322). + // It resolves through a typed const with a dev fallback + prod guard. + const authSrc = readFileSync(join(appDir, 'lib', 'auth.server.ts'), 'utf8'); + assert.doesNotMatch(authSrc, /secret:\s*process\.env\.AUTH_SECRET\b/, 'secret must not be the raw string | undefined env read'); + assert.match(authSrc, /const authSecret =/, 'auth secret resolved through a typed const'); + assert.match(authSrc, /secret:\s*authSecret\b/, 'createAuth uses the typed authSecret'); + assert.match(authSrc, /NODE_ENV === 'production'[\s\S]*AUTH_SECRET must be set/, 'production fails fast when AUTH_SECRET is unset'); + + // #878: every top-level page needs EXACTLY one

    (axe page-has-heading-one + // wants one, and a second h1 is its own violation). The auth cards are the + // sole heading so their title is the h1; the dashboard/settings pages already + // carry a page

    , so their card title stays a subordinate

    (promoting + // it to h1 was the regression this pins). Counterfactual: an

    -only page, + // or a double-h1 dashboard, fails this. + const h1Count = (src) => (src.match(/`); } + // #878: no gallery surface may drop label text below AA contrast. The + // `text-muted-foreground/70` opacity measured 3.83:1; full-opacity + // `text-muted-foreground` passes. Scan the WHOLE generated gallery (every + // component + feature page), not just one demo, so a stray low-contrast + // token anywhere reds this. Counterfactual: any `/NN` opacity fails. + const galleryDirs = [join(appDir, 'modules'), join(appDir, 'app', 'features')]; + const walk = (dir) => { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, e.name); + if (e.isDirectory()) { walk(full); continue; } + if (!e.name.endsWith('.ts')) continue; + const src = readFileSync(full, 'utf8'); + assert.doesNotMatch(src, /text-muted-foreground\/\d/, `${full} keeps full-contrast text-muted-foreground (no /NN opacity)`); + } + }; + for (const d of galleryDirs) if (existsSync(d)) walk(d); + + // #878: gallery form controls need an accessible name (axe `label`). The + // file-upload input and the directive-demo text input carried none, so a + // full axe sweep flagged them critical. Pin their aria-labels. + const fileStorage = readFileSync(join(appDir, 'app', 'features', 'file-storage', 'page.ts'), 'utf8'); + assert.match(fileStorage, /type="file"[^>]*aria-label=/, 'the file input has an aria-label'); + const directiveDemo = readFileSync(join(appDir, 'modules', 'directives', 'components', 'directive-demo.ts'), 'utf8'); + assert.match(directiveDemo, /aria-label="Editable text/, 'the ref-focus input has an aria-label'); + // Drizzle User model (saas overwrites db/schema.server.ts to add passwordHash) const schema = readFileSync(join(appDir, 'db', 'schema.server.ts'), 'utf8'); assert.match(schema, /export const users = table\('users'/, 'users table present');