fix: dogfood triage (route() middleware, scaffold typecheck, scaffold a11y)#879
Conversation
…idate The route() adapter only ran middleware/validate passed via opts, so an action's declared `export const middleware` did not protect the REST boundary, only the RPC one, despite the docs claiming both. A function reference cannot reach its sibling config exports. route() now also accepts the action's module namespace (`import * as m`); given a module it resolves the single action function and auto-applies the declared middleware and validate, so a guard declared once beside the action protects the RPC and REST boundaries alike. The function form is unchanged and an explicit opts value still overrides the declared config. Corrects the AGENTS.md / agent-docs / docs-site overclaim to match. Closes #876
Two dogfood findings on a freshly generated saas app. Typecheck (#877): - The saas generator rewrote the registry cn() import but not the sibling onBeforeCache import, so dialog.ts kept the registry-relative ../lib/dom.ts (a nonexistent components/lib/dom.ts) and failed TS2307. Add the ../lib/dom.ts -> #lib/utils/dom.ts rewrite, mirroring create.js. - lib/auth.server.ts assigned process.env.AUTH_SECRET (string | undefined) to the required string secret (TS2322). Resolve it through a typed authSecret const with a dev fallback that fails fast in production. Accessibility (#878): - The login / signup / dashboard / settings pages used <h3> as their only heading, so axe flagged page-has-heading-one. Promote each page's title to <h1> (styling unchanged, cardTitleClass carries the size). - The counter-card demo label used text-muted-foreground/70, a 3.83:1 contrast against the card, below AA. Drop the /70 opacity. A fresh `webjs create --template saas` now typechecks clean and returns zero axe violations on /login and the feature gallery, in light and dark. Closes #877 Closes #878
Extend the saas scaffold integration test with the regressions #877/#878 fix: the onBeforeCache import is rewritten to #lib/utils/dom.ts, the auth secret is a typed const (not the raw string | undefined env read), the login/signup pages carry an <h1>, and the counter-card label keeps full text-muted-foreground contrast. Each doubles as the counterfactual that fails when the corresponding fix is reverted.
Self-review of the h1/contrast fix surfaced three gaps a fuller axe sweep confirmed: - The dashboard and settings pages already carry a page <h1>, so promoting their card title to <h1> created a second h1. Demote those two card titles to <h2> (login/signup keep <h1>, their card title is the sole heading). - text-muted-foreground/70 (3.83:1) appeared in nine more gallery surfaces than the counter-card; drop the /70 across the gallery so no generated page ships sub-AA label text. - The file-upload input and the ref-focus demo input had no accessible name (axe label, critical); add aria-labels. A full axe sweep of every generated route (public and auth-gated) now returns zero violations, with exactly one h1 per page. Tests widened to assert one h1 on dashboard/settings, scan the whole gallery for the opacity token, and pin both aria-labels.
vivek7405
left a comment
There was a problem hiding this comment.
Went back over the scaffold a11y change with fresh eyes and the /login + gallery axe run I did was too narrow. Two things it missed:
- The dashboard and settings pages already have a page-level h1, so promoting their card title to h1 double-heads them. Those two should be h2; only login/signup (where the card title is the only heading) are correctly h1.
- The /70 muted token was not just on the counter-card. A full sweep found it on nine more gallery surfaces, so the contrast fix was half-done.
Both are fixed now, and I widened the sweep to every generated route (public and auth-gated) plus the tests. Details on the two lines below.
Second self-review round: - The production AUTH_SECRET guard used `!process.env.AUTH_SECRET`, which a whitespace-only value passes, so a blank secret would sign tokens in production. Trim first and fail fast on empty/blank; the dev fallback now also triggers for a blank value. - The AGENTS.md and recipes.md #876 examples reused the `createPost` identifier for both the module-namespace binding and the bare-function form, so the same `route(createPost)` call read as doing two opposite things. Use distinct bindings (import * as postActions for the module form, import { createPost } for the function form).
vivek7405
left a comment
There was a problem hiding this comment.
Second pass, zoomed in on the route() code path, the auth guard, and the doc prose. The route() module resolution holds up (single-action rule matches the existing one-action-per-file check, default export works, explicit opts win). Two smaller things worth fixing, both addressed:
#876 gives route() an automatic path (pass the module namespace) for applying an action's declared middleware, so the blog's earlier "route() does not pick it up on its own, a real seam to remember" framing is now stale. Rewrite that section, the heading, and the takeaway to show the module form auto-applies and the bare-function form takes the explicit option.
vivek7405
left a comment
There was a problem hiding this comment.
Third pass, cross-file. The route() code and the three in-tree doc surfaces (AGENTS.md, recipes.md, the docs site) are consistent, the auth-secret trim is type-safe and gates prod correctly, route(null)/route({}) throw sensibly, and every functional fix has a real (non-tautological) test. One drift item: the per-action-middleware blog still described the old behavior, so I folded the sync into this PR since #876 is what changed it. Noted inline.
Match the recipes.md disambiguation (#876 follow-up): the module-namespace example used the same createPost identifier as the bare-function form, so route(createPost) read as doing two opposite things. Use postActions for the module form.
vivek7405
left a comment
There was a problem hiding this comment.
Fourth pass. Blog is internally consistent and the PR body claims all still hold. One leftover: AGENTS.md kept the reused-identifier ambiguity that e9… fixed in recipes.md. Fixed to match.
The docs-site paragraph described the module form but its code example (and the blog + JSDoc examples) still reused one identifier for both forms, so route(createPost) read as auto-applying in prose while the example showed the non-applying bare-function form. Label the docs-site code block as the bare-function form and use `import * as postActions` for the module form across the docs site, the blog, and the JSDoc, to match AGENTS.md and recipes.md.
vivek7405
left a comment
There was a problem hiding this comment.
Fifth pass, the doc consistency thread. Found a real one on the docs site plus a matching clarity gap in the blog, both fixed. The route() code, tests, and the auth/a11y fixes are unchanged and hold.
vivek7405
left a comment
There was a problem hiding this comment.
Final pass, the route() doc surfaces now line up: module form is postActions everywhere, the docs-site code block is labeled the bare-function form and matches its prose, no reused-identifier collision left. Nothing else outstanding. Good to merge.
Fixes three dogfood findings from cross-checking the blog claims against a real app. Bundled into one PR per request.
Closes #876
Closes #877
Closes #878
#876 route() adapter ignores an action's declared middleware
route(action)only ran middleware/validate passed viaopts, so an action's declaredexport const middlewareprotected the RPC boundary but not the REST one (a function reference cannot reach its sibling config exports).route()now also accepts the action's MODULE NAMESPACE (import * as m; export const POST = route(m)) and auto-applies the declaredmiddleware+validate, so a guard declared once beside the action protects both boundaries. The function form is unchanged; an explicitoptsvalue still overrides the declared config.packages/server/src/action-route.js: resolve the callable + declared config from a function or a module namespace.packages/server/test/actions/action-route.test.js(module form applies middleware + validate, opts override, multi-function throw, and the counterfactual that the bare-function form does NOT apply declared middleware).agent-docs/recipes.md/ docs-site overclaim to match, and documented the new module form.#877 fresh saas scaffold fails webjs typecheck
cn()import but not the siblingonBeforeCacheone, sodialog.tskept../lib/dom.ts(a nonexistentcomponents/lib/dom.ts) and failed TS2307. Added the../lib/dom.ts->#lib/utils/dom.tsrewrite insaas-template.js, mirroringcreate.js.lib/auth.server.tsassignedprocess.env.AUTH_SECRET(string | undefined) to the requiredstringsecret (TS2322). Now resolved through a typedauthSecretconst with a dev fallback that fails fast in production.#878 fresh saas scaffold a11y violations
<h3>as their only heading (axepage-has-heading-one). Promoted each page title to<h1>(styling unchanged).text-muted-foreground/70(3.83:1, below AA). Dropped the/70.Verification
webjs typecheckclean (0 errors); axe returns 0 violations on/login,/signup, and/features/componentsin both light and dark themes; exactly one<h1>per page.packages/server/test/actions/action-route.test.js: 18/18 (module form + counterfactual).test/scaffolds/scaffold-integration.test.js+scaffold-ui-integration.test.js: 13/13, with new assertions doubling as counterfactuals (verified: reverting a fix reds the test).Definition-of-done notes
agent-docs/recipes.md,docs/app/docs/server-actions/page.tsfor the route() module form. The blogper-action-middleware.mdwas already corrected onmain(docs: add 12 SEO blog posts for shipped-but-unblogged features #875).route()uses only web-standardRequest/Response/URLand pure JS (no listener / serializer / stream /node:*/ crypto surface).