From 9a92b23c32feb1abdd26b44e03542e4905f12e32 Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 22 Jul 2026 12:11:41 +0530 Subject: [PATCH 01/18] chore: start auth gallery card work From 187ca2379f7f843aa142beb546d7dae232889fbf Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 22 Jul 2026 12:20:57 +0530 Subject: [PATCH 02/18] feat(scaffold): add auth gallery card (login, session, protected route) --- .../gallery/app/api/auth/[...path]/route.ts | 7 ++ .../app/features/auth/dashboard/layout.ts | 21 +++++ .../app/features/auth/dashboard/middleware.ts | 14 ++++ .../app/features/auth/dashboard/page.ts | 22 +++++ .../features/auth/dashboard/settings/page.ts | 26 ++++++ .../gallery/app/features/auth/login/page.ts | 50 ++++++++++++ .../gallery/app/features/auth/page.ts | 33 ++++++++ .../gallery/app/features/auth/signup/page.ts | 68 ++++++++++++++++ .../modules/auth/actions/signup.server.ts | 19 +++++ .../gallery/modules/auth/auth.server.ts | 53 ++++++++++++ .../gallery/modules/auth/password.server.ts | 20 +++++ .../auth/queries/current-user.server.ts | 12 +++ .../templates/gallery/modules/auth/types.ts | 9 +++ .../templates/gallery/test/auth/auth.test.ts | 81 +++++++++++++++++++ 14 files changed, 435 insertions(+) create mode 100644 packages/cli/templates/gallery/app/api/auth/[...path]/route.ts create mode 100644 packages/cli/templates/gallery/app/features/auth/dashboard/layout.ts create mode 100644 packages/cli/templates/gallery/app/features/auth/dashboard/middleware.ts create mode 100644 packages/cli/templates/gallery/app/features/auth/dashboard/page.ts create mode 100644 packages/cli/templates/gallery/app/features/auth/dashboard/settings/page.ts create mode 100644 packages/cli/templates/gallery/app/features/auth/login/page.ts create mode 100644 packages/cli/templates/gallery/app/features/auth/page.ts create mode 100644 packages/cli/templates/gallery/app/features/auth/signup/page.ts create mode 100644 packages/cli/templates/gallery/modules/auth/actions/signup.server.ts create mode 100644 packages/cli/templates/gallery/modules/auth/auth.server.ts create mode 100644 packages/cli/templates/gallery/modules/auth/password.server.ts create mode 100644 packages/cli/templates/gallery/modules/auth/queries/current-user.server.ts create mode 100644 packages/cli/templates/gallery/modules/auth/types.ts create mode 100644 packages/cli/templates/gallery/test/auth/auth.test.ts diff --git a/packages/cli/templates/gallery/app/api/auth/[...path]/route.ts b/packages/cli/templates/gallery/app/api/auth/[...path]/route.ts new file mode 100644 index 00000000..a56e3b52 --- /dev/null +++ b/packages/cli/templates/gallery/app/api/auth/[...path]/route.ts @@ -0,0 +1,7 @@ +// The createAuth HTTP endpoints (signin, signout, OAuth callbacks). This route +// stays at the app root, NOT under app/features/auth/, because createAuth +// hardcodes /api/auth/signin/* and /api/auth/callback/* for its form posts and +// OAuth redirect URIs. The rest of the auth card lives under app/features/auth/. +import { handlers } from '#modules/auth/auth.server.ts'; +export const GET = handlers.GET; +export const POST = handlers.POST; diff --git a/packages/cli/templates/gallery/app/features/auth/dashboard/layout.ts b/packages/cli/templates/gallery/app/features/auth/dashboard/layout.ts new file mode 100644 index 00000000..a920f387 --- /dev/null +++ b/packages/cli/templates/gallery/app/features/auth/dashboard/layout.ts @@ -0,0 +1,21 @@ +import { html } from '@webjsdev/core'; +import { buttonClass } from '#components/ui/button.ts'; + +// Nested layout for the protected dashboard subtree. Logout is a plain +//
posting to the createAuth signout route: it clears the +// session cookie and 302s home, and works with JS off (progressive-enhancement +// default). signOut is server-only (modules/auth/auth.server.ts), so we POST to +// its route rather than import it into a browser-shipping page. After signout the +// dashboard middleware bounces any later visit to login. +export default function DashboardLayout({ children }: { children: unknown }) { + return html` +
+ ${children} + `; +} diff --git a/packages/cli/templates/gallery/app/features/auth/dashboard/middleware.ts b/packages/cli/templates/gallery/app/features/auth/dashboard/middleware.ts new file mode 100644 index 00000000..2f660628 --- /dev/null +++ b/packages/cli/templates/gallery/app/features/auth/dashboard/middleware.ts @@ -0,0 +1,14 @@ +import { auth } from '#modules/auth/auth.server.ts'; + +// The protected-route gate. A per-segment middleware.ts runs for every request +// under /features/auth/dashboard/*. It reads the signed session off the request +// with auth(req); with no valid session it 302s to login BEFORE the page renders, +// so an anonymous visitor never sees the protected content. This needs no DB +// query (only a cookie read), so the gate is real the moment the app boots. +export default async function requireAuth(req: Request, next: () => Promise) { + const session = await auth(req); + if (!session?.user) { + return new Response(null, { status: 302, headers: { location: '/features/auth/login' } }); + } + return next(); +} diff --git a/packages/cli/templates/gallery/app/features/auth/dashboard/page.ts b/packages/cli/templates/gallery/app/features/auth/dashboard/page.ts new file mode 100644 index 00000000..d77e1f90 --- /dev/null +++ b/packages/cli/templates/gallery/app/features/auth/dashboard/page.ts @@ -0,0 +1,22 @@ +import { html } from '@webjsdev/core'; +import { currentUser } from '#modules/auth/queries/current-user.server.ts'; +import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass } from '#components/ui/card.ts'; +import { badgeClass } from '#components/ui/badge.ts'; + +export const metadata = { title: 'Dashboard' }; + +export default async function Dashboard() { + const user = await currentUser(); + return html` +
+

Dashboard

+ Signed in +
+
+
+

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

+

This route is gated by middleware.ts. Promote it into your product, or drop the whole auth card with gallery:clear.

+
+
+ `; +} diff --git a/packages/cli/templates/gallery/app/features/auth/dashboard/settings/page.ts b/packages/cli/templates/gallery/app/features/auth/dashboard/settings/page.ts new file mode 100644 index 00000000..8f65b091 --- /dev/null +++ b/packages/cli/templates/gallery/app/features/auth/dashboard/settings/page.ts @@ -0,0 +1,26 @@ +import { html } from '@webjsdev/core'; +import { currentUser } from '#modules/auth/queries/current-user.server.ts'; +import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass } from '#components/ui/card.ts'; + +export const metadata = { title: 'Settings' }; + +export default async function Settings() { + const user = await currentUser(); + return html` +

Settings

+
+
+

Account

+

Your basic profile information.

+
+
+
+
Email
+
${user?.email}
+
Name
+
${user?.name || 'Not set'}
+
+
+
+ `; +} diff --git a/packages/cli/templates/gallery/app/features/auth/login/page.ts b/packages/cli/templates/gallery/app/features/auth/login/page.ts new file mode 100644 index 00000000..a56a906e --- /dev/null +++ b/packages/cli/templates/gallery/app/features/auth/login/page.ts @@ -0,0 +1,50 @@ +import { html } from '@webjsdev/core'; +import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass, cardFooterClass } from '#components/ui/card.ts'; +import { buttonClass } from '#components/ui/button.ts'; +import { inputClass } from '#components/ui/input.ts'; +import { labelClass } from '#components/ui/label.ts'; + +export const metadata = { title: 'Log in' }; + +// A failed sign-in 302s back here with ?error=... (createAuth is configured with +// pages.error: '/features/auth/login' in modules/auth/auth.server.ts). Map the +// code to a plain message so a bad password gets visible feedback instead of a +// silent bounce. +function errorMessage(code: string | undefined): string | null { + if (!code) return null; + if (code === 'CredentialsSignin') return 'Invalid email or password.'; + return 'Could not sign you in. Please try again.'; +} + +export default function LoginPage({ searchParams }: { searchParams: { error?: string } }) { + const error = errorMessage(searchParams.error); + return html` +
+
+
+

Sign in

+

Welcome back: log in to continue.

+
+
+ ${error ? html`` : ''} +
+ + +
+ + +
+
+ + +
+ +
+
+
+

Don't have an account? Sign up

+
+
+
+ `; +} diff --git a/packages/cli/templates/gallery/app/features/auth/page.ts b/packages/cli/templates/gallery/app/features/auth/page.ts new file mode 100644 index 00000000..c3b88305 --- /dev/null +++ b/packages/cli/templates/gallery/app/features/auth/page.ts @@ -0,0 +1,33 @@ +// Auth: password login/signup on top of createAuth, a signed session cookie, and +// a genuinely protected route. This card wires a REAL auth baseline (a users +// table with a passwordHash, createAuth in modules/auth/auth.server.ts, and the +// /features/auth/dashboard subtree gated by a middleware.ts), so a fresh app can +// promote it into a product. gallery:clear removes the whole surface (this card, +// modules/auth, app/api/auth, the passwordHash column) back to the minimal base. +// +// This index page is public and reads the current session with currentUser() so +// it can show who is signed in. The read is a 'use server' action, so the same +// line is the real query during SSR and a safe RPC stub on the client. +import { html } from '@webjsdev/core'; +import type { Metadata } from '@webjsdev/core'; +import { currentUser } from '#modules/auth/queries/current-user.server.ts'; + +export const metadata: Metadata = { title: 'Auth (login + protected route) | features' }; + +export default async function AuthExample() { + const user = await currentUser(); + return html` +

Auth

+

Password login on createAuth, a signed session cookie, and a protected /features/auth/dashboard that redirects anonymous visitors to login.

+ + ${user + ? html` +

Signed in as ${user.name || user.email}.

+

Open the protected dashboard

` + : html` +

You are signed out. Visiting the dashboard bounces you to login.

+

Log inCreate an account

`} + +

The gate is app/features/auth/dashboard/middleware.ts calling auth(req); the login form posts to the app/api/auth/[...path] handler; OAuth (GitHub / Google) activates once you set the matching env vars.

+ `; +} diff --git a/packages/cli/templates/gallery/app/features/auth/signup/page.ts b/packages/cli/templates/gallery/app/features/auth/signup/page.ts new file mode 100644 index 00000000..64ef6fe7 --- /dev/null +++ b/packages/cli/templates/gallery/app/features/auth/signup/page.ts @@ -0,0 +1,68 @@ +import { html } from '@webjsdev/core'; +import { signup } from '#modules/auth/actions/signup.server.ts'; +import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass, cardFooterClass } from '#components/ui/card.ts'; +import { buttonClass } from '#components/ui/button.ts'; +import { inputClass } from '#components/ui/input.ts'; +import { labelClass } from '#components/ui/label.ts'; + +export const metadata = { title: 'Sign up' }; + +// Page server action: handles the POST from the form below. With JS disabled this +// is a plain
round-trip; with JS the client router swaps the 422 re-render +// (errors) or follows the 302 (success) in place. A validation failure returns +// fieldErrors + values so the page re-renders with messages and the user's typed +// input preserved. +export async function action({ formData }: { formData: FormData }) { + const name = String(formData.get('name') || '').trim(); + const email = String(formData.get('email') || '').trim(); + const password = String(formData.get('password') || ''); + const values = { name, email }; + const fieldErrors: Record = {}; + if (!name) fieldErrors.name = 'Name is required'; + if (!email.includes('@')) fieldErrors.email = 'Enter a valid email'; + if (password.length < 8) fieldErrors.password = 'At least 8 characters'; + if (Object.keys(fieldErrors).length) return { success: false, fieldErrors, values, status: 422 }; + const result = await signup({ name, email, password }); + // On success signup returns signIn's 302 Response (auto-login -> dashboard); a + // page action may return a Response, so pass it straight through. + if (result instanceof Response) return result; + return { success: false, fieldErrors: { email: result.error }, values, status: result.status }; +} + +export default function SignupPage({ actionData }: { actionData?: { fieldErrors?: Record; values?: Record } }) { + const errors = actionData?.fieldErrors || {}; + const values = actionData?.values || {}; + return html` +
+
+
+

Create an account

+

Get started with your new workspace.

+
+
+ +
+ + + ${errors.name ? html`

${errors.name}

` : ''} +
+
+ + + ${errors.email ? html`

${errors.email}

` : ''} +
+
+ + + ${errors.password ? html`

${errors.password}

` : ''} +
+ + +
+
+

Already have an account? Log in

+
+
+
+ `; +} diff --git a/packages/cli/templates/gallery/modules/auth/actions/signup.server.ts b/packages/cli/templates/gallery/modules/auth/actions/signup.server.ts new file mode 100644 index 00000000..efea0a59 --- /dev/null +++ b/packages/cli/templates/gallery/modules/auth/actions/signup.server.ts @@ -0,0 +1,19 @@ +'use server'; + +import { db } from '#db/connection.server.ts'; +import { users } from '#db/schema.server.ts'; +import { hash } from '../password.server.ts'; +import { signIn } from '../auth.server.ts'; + +// Creates the account, then signs the new user in and lands on the dashboard. +// signIn returns a 302 Response carrying the session cookie; the signup page +// action returns that Response as-is (a page action may return a Response). +// signIn lives in the server-only auth module, imported here server-to-server, +// so it never reaches the browser (the signup page only imports this action's +// RPC stub). +export async function signup(input: { name: string; email: string; password: string }) { + const exists = await db.query.users.findFirst({ where: { email: input.email }, columns: { id: true } }); + if (exists) return { success: false as const, error: 'Email already registered', status: 409 }; + await db.insert(users).values({ name: input.name, email: input.email, passwordHash: await hash(input.password) }); + return signIn('credentials', { email: input.email, password: input.password }, { redirectTo: '/features/auth/dashboard' }); +} diff --git a/packages/cli/templates/gallery/modules/auth/auth.server.ts b/packages/cli/templates/gallery/modules/auth/auth.server.ts new file mode 100644 index 00000000..4779043f --- /dev/null +++ b/packages/cli/templates/gallery/modules/auth/auth.server.ts @@ -0,0 +1,53 @@ +// The auth configuration. A server-only utility (a .server.ts with NO +// 'use server'), so it runs server-side and its browser import is a throw-at-load +// stub. Pages and route handlers import the pieces they need server-to-server; +// the client only ever reaches auth through a 'use server' action's RPC stub. +// +// createAuth returns { auth, signIn, signOut, handlers }. `handlers` mounts at +// app/api/auth/[...path]/route.ts (createAuth hardcodes the /api/auth/signin/* +// and /api/auth/callback/* paths, which is why that route stays at the app root +// even though the rest of this card lives under app/features/auth/). +import { createAuth, Credentials, GitHub, Google } from '@webjsdev/server'; +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({ + async authorize(credentials: { email: string; password: string }) { + const user = await db.query.users.findFirst({ where: { email: credentials.email } }); + if (!user?.passwordHash || !await compare(credentials.password, user.passwordHash)) return null; + return { id: String(user.id), name: user.name, email: user.email }; + }, + }), + // OAuth providers: add GitHub / Google sign-in by setting the matching env + // vars. Each preset (GitHub(), Google()) reads AUTH__ID / _SECRET, + // so they only activate once configured and a fresh scaffold still boots with + // just Credentials. + ...(process.env.AUTH_GITHUB_ID ? [GitHub({ clientId: process.env.AUTH_GITHUB_ID, clientSecret: process.env.AUTH_GITHUB_SECRET })] : []), + ...(process.env.AUTH_GOOGLE_ID ? [Google({ clientId: process.env.AUTH_GOOGLE_ID, clientSecret: process.env.AUTH_GOOGLE_SECRET })] : []), + ], + secret: authSecret, + // A failed credentials sign-in 302s to `${pages.error}?error=CredentialsSignin`. + // Point it at the login page so it reads searchParams.error and shows a message, + // instead of the createAuth default (the home page) swallowing the error. + pages: { error: '/features/auth/login' }, +}); + +// Read the signed-in user off a request. auth(req) reads the session cookie +// (falling back to the ambient request when called with no argument), so this +// works from a page/layout, a segment middleware, or an action middleware that +// holds the request (the greet demo's require-auth reads it this way). +export async function getCurrentUser(req?: Request) { + const session = await auth(req); + return session?.user ?? null; +} diff --git a/packages/cli/templates/gallery/modules/auth/password.server.ts b/packages/cli/templates/gallery/modules/auth/password.server.ts new file mode 100644 index 00000000..7b5340f7 --- /dev/null +++ b/packages/cli/templates/gallery/modules/auth/password.server.ts @@ -0,0 +1,20 @@ +// Password hashing with scrypt from node:crypto (built into Node AND Bun, no +// dependency). A server-only utility: hashes live server-side and never reach +// the browser. Swap in argon2/bcrypt here if you prefer; the call sites only use +// hash() and compare(). +import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto'; +import { promisify } from 'node:util'; + +const scryptAsync = promisify(scrypt); + +export async function hash(password: string): Promise { + const salt = randomBytes(16).toString('hex'); + const buf = (await scryptAsync(password, salt, 64)) as Buffer; + return salt + ':' + buf.toString('hex'); +} + +export async function compare(password: string, stored: string): Promise { + const [salt, key] = stored.split(':'); + const buf = (await scryptAsync(password, salt, 64)) as Buffer; + return timingSafeEqual(buf, Buffer.from(key, 'hex')); +} diff --git a/packages/cli/templates/gallery/modules/auth/queries/current-user.server.ts b/packages/cli/templates/gallery/modules/auth/queries/current-user.server.ts new file mode 100644 index 00000000..0a30891a --- /dev/null +++ b/packages/cli/templates/gallery/modules/auth/queries/current-user.server.ts @@ -0,0 +1,12 @@ +'use server'; + +import { getCurrentUser } from '../auth.server.ts'; + +// This read deliberately stays POST-default (no 'method' export). A GET server +// action (a cacheable, SSR-seeded read) is wrong for a per-session lookup: the +// result differs per user and changes on sign-in / sign-out, so it must never be +// browser-cached or shared. Reserve GET + cache + tags for data identical for +// every visitor. +export async function currentUser() { + return getCurrentUser(); +} diff --git a/packages/cli/templates/gallery/modules/auth/types.ts b/packages/cli/templates/gallery/modules/auth/types.ts new file mode 100644 index 00000000..d3e2691a --- /dev/null +++ b/packages/cli/templates/gallery/modules/auth/types.ts @@ -0,0 +1,9 @@ +export interface User { + id: number; + name: string | null; + email: string; +} + +export type ActionResult = + | { success: true; data: T } + | { success: false; error: string; status: number }; diff --git a/packages/cli/templates/gallery/test/auth/auth.test.ts b/packages/cli/templates/gallery/test/auth/auth.test.ts new file mode 100644 index 00000000..bbc58bb7 --- /dev/null +++ b/packages/cli/templates/gallery/test/auth/auth.test.ts @@ -0,0 +1,81 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +import { createRequestHandler } from '@webjsdev/server'; +import { testRequest, loginAndGetCookies, withSessionCookie } from '@webjsdev/server/testing'; + +const appDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); + +// The auth pages + dashboard middleware query the users table via Drizzle. Until +// `db:generate` has authored the migration (then `db:migrate` applies it, or +// `dev` applies it via webjs.dev.before), a request hitting those modules 500s; +// we detect that at the RESPONSE level (a 5xx on the dashboard) and SKIP with a +// clear message rather than report a misleading failure. After the db is set up +// every assertion runs for real. +process.env.DATABASE_URL ||= 'file:./dev.db'; +process.env.AUTH_SECRET ||= 'test-secret-at-least-32-characters-long!!'; + +function makeHandler() { + // createRequestHandler builds lazily, so it succeeds even before the DB is + // migrated; the missing table only surfaces when a request reaches a module + // that queries it. That is why readiness is probed per-response. + return createRequestHandler({ appDir, dev: true }); +} + +test('protected route redirects to login when unauthenticated', async (t) => { + const app = await makeHandler(); + const res = await testRequest(app.handle, '/features/auth/dashboard'); + if (res.status >= 500) { + t.skip('app deps not ready (run db:generate + db:migrate)'); + return; + } + // The dashboard middleware calls auth(req); with no session cookie it 302s to + // login. This needs no DB row, only a cookie read, so it is always real once + // the modules import. + assert.equal(res.status, 302, 'unauthenticated dashboard is gated'); + assert.equal(res.headers.get('location'), '/features/auth/login'); +}); + +test('signup -> login -> dashboard renders for the authenticated user', async (t) => { + const app = await makeHandler(); + // Probe readiness: a 5xx on the dashboard means deps/DB are not set up. + const probe = await testRequest(app.handle, '/features/auth/dashboard'); + if (probe.status >= 500) { t.skip('app deps not ready; run db:generate + db:migrate'); return; } + + const email = `harness+${Date.now()}@example.com`; + const password = 'password123'; + + // Real signup through the page server action (the no-JS form write-path). + let canSignup = true; + try { + const signupRes = await testRequest(app.handle, '/features/auth/signup', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ name: 'Harness', email, password }).toString(), + }); + // Success auto-logs-in and 302s to the dashboard (carrying the session + // cookie); a 422 means validation failed. Either way the action ran. + assert.ok([302, 422].includes(signupRes.status), 'signup action ran'); + if (signupRes.status === 302) assert.equal(signupRes.headers.get('location'), '/features/auth/dashboard', 'signup lands on the dashboard'); + if (signupRes.status !== 302) canSignup = false; + } catch { + // No migrated DB table -> the action throws. Skip the DB-backed assertions. + canSignup = false; + } + if (!canSignup) { t.skip('no migrated DB; run db:migrate to enable the full flow'); return; } + + // Real login captures the genuine signed session cookie. + const { cookies } = await loginAndGetCookies(app.handle, { email, password }); + + // With the session cookie the protected route now renders (200). + const dash = await testRequest(app.handle, '/features/auth/dashboard', withSessionCookie({}, cookies)); + assert.equal(dash.status, 200, 'the session cookie unlocks the dashboard'); + const body = await dash.text(); + assert.match(body, /Dashboard/, 'the dashboard content rendered'); + // The greeting interpolates the real user, so the name renders and the literal + // template source never leaks (a counterfactual for the escaping bug). + assert.match(body, /Harness/, 'the dashboard greets the signed-in user by name'); + assert.ok(!body.includes('${user'), 'the greeting interpolation is not a literal string'); +}); From 03c90e438048d1f03c1715604935958b88200afc Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 22 Jul 2026 12:30:02 +0530 Subject: [PATCH 03/18] feat(scaffold): fold auth into the UI template, drop the saas template The saas template was the default UI template plus auth. Collapse to one UI template by shipping auth as a gallery card (app/features/auth + modules/auth + app/api/auth), styled in plain Tailwind like every other card, and remove --template saas and saas-template.js. The users table gains a passwordHash column in the UI template; gallery:clear strips it (and the auth card) back to the minimal base. --- packages/cli/bin/webjs.js | 32 +- packages/cli/lib/api-gallery.js | 2 +- packages/cli/lib/create.js | 138 +---- packages/cli/lib/saas-template.js | 568 ------------------ .../app/features/auth/dashboard/layout.ts | 7 +- .../app/features/auth/dashboard/page.ts | 12 +- .../features/auth/dashboard/settings/page.ts | 23 +- .../gallery/app/features/auth/login/page.ts | 46 +- .../gallery/app/features/auth/signup/page.ts | 52 +- .../cli/templates/scripts/clear-gallery.mjs | 31 +- 10 files changed, 125 insertions(+), 786 deletions(-) delete mode 100644 packages/cli/lib/saas-template.js diff --git a/packages/cli/bin/webjs.js b/packages/cli/bin/webjs.js index 81905fd8..edfe34e7 100755 --- a/packages/cli/bin/webjs.js +++ b/packages/cli/bin/webjs.js @@ -46,7 +46,7 @@ if (cmd !== 'help' && cmd !== undefined && !wantsHelp && !wantsVersion) { // Exactly three scaffolds exist. Keep this list as the single source of // truth. AI-agent docs in README.md / AGENTS.md / .cursorrules / // .agents/rules/workflow.md / .github/copilot-instructions.md mirror it. -const TEMPLATES = ['full-stack', 'api', 'saas']; +const TEMPLATES = ['full-stack', 'api']; const USAGE = `webjs commands: webjs dev [--port 8080] [--no-hot] Start dev server with live reload @@ -60,7 +60,7 @@ const USAGE = `webjs commands: --json emits the structured results (with stable codes). --strict also fails the exit on warnings webjs types Generate .webjs/routes.d.ts (typed Route union + per-route params) webjs typecheck [tsc args...] Type-check the app with the project's tsc --noEmit (non-zero on errors) - webjs create [--template full-stack|api|saas] [--db sqlite|postgres] [--runtime node|bun] [--no-install] Scaffold a new webjs app + webjs create [--template full-stack|api] [--db sqlite|postgres] [--runtime node|bun] [--no-install] Scaffold a new webjs app (only 3 templates exist. default: full-stack, Drizzle, --db sqlite, --runtime node) --runtime bun emits a Bun-flavored app (bun.lock, bun Dockerfile/CI, bun docs); also auto-detected when run via "bun create webjs". @@ -160,10 +160,10 @@ const HELP = { examples: ['webjs typecheck', 'webjs typecheck --watch'], }, create: { - usage: 'webjs create [--template full-stack|api|saas] [--db sqlite|postgres] [--runtime node|bun] [--no-install]', + usage: 'webjs create [--template full-stack|api] [--db sqlite|postgres] [--runtime node|bun] [--no-install]', summary: 'Scaffold a new app. Defaults: full-stack template, Drizzle + SQLite, Node runtime.', options: [ - { flag: '--template ', description: 'full-stack (default), api, or saas.' }, + { flag: '--template ', description: 'full-stack (default) or api (backend-only, no UI).' }, { flag: '--db ', description: 'sqlite (default) or postgres.' }, { flag: '--runtime ', description: 'node (default) or bun.' }, { flag: '--no-install', description: 'Skip the package-manager install step.' }, @@ -171,7 +171,7 @@ const HELP = { examples: [ 'webjs create my-app', 'webjs create my-api --template api', - 'webjs create my-saas --template saas --db postgres', + 'webjs create my-api --template api --db postgres', 'webjs create my-app --runtime bun', ], }, @@ -846,7 +846,7 @@ async function main() { case 'create': { const name = rest[0]; if (!name || name.startsWith('-')) { - console.error('Usage: webjs create [--template full-stack|api|saas]'); + console.error('Usage: webjs create [--template full-stack|api]'); process.exit(1); } const template = flag(rest, '--template', 'full-stack'); @@ -856,16 +856,16 @@ async function main() { // on which scaffold to pick for which kind of app. console.error(`Error: unknown template '${template}'. -Only three scaffolds exist: - full-stack (default): pages + components + API + Drizzle/SQLite. - Pick this for any app the user describes in product terms - (todo app, blog, dashboard, marketplace, social feed, …). - api backend-only: route handlers + modules, no pages/SSR. - Pick this only if the user explicitly asks for an HTTP/JSON - API with no UI. - saas auth + login/signup + protected dashboard + Drizzle User - model. Pick this only if the user explicitly asks for auth - or a SaaS-shaped product. +Only two scaffolds exist: + full-stack (default): pages + components + API + Drizzle/SQLite, plus a + browsable feature gallery. Auth is one of the gallery cards + (login + session + a protected route), so a full-stack app + already carries a real auth baseline. Pick this for any app + the user describes in product terms (todo, blog, dashboard, + marketplace, social feed, a SaaS with accounts, …). + api backend-only: route handlers + modules + Drizzle/SQLite, no + pages/SSR. Pick this only if the user explicitly asks for an + HTTP/JSON API with no UI. The scaffold is a starting point. Replace the example layout/page/ components/schema with the actual app the user requested. Use Drizzle + diff --git a/packages/cli/lib/api-gallery.js b/packages/cli/lib/api-gallery.js index 21475301..3172c7df 100644 --- a/packages/cli/lib/api-gallery.js +++ b/packages/cli/lib/api-gallery.js @@ -2,7 +2,7 @@ * Backend-features showcase for `webjs create --template api`. * A set of JSON/HTTP endpoints under `app/api/features/` that demonstrate the * backend capabilities an API app uses (the api counterpart of the UI gallery). - * Extracted here (like saas-template.js) to keep create.js readable and dodge + * Extracted here to keep create.js readable and dodge * nested-template-literal escaping: files are built from arrays of * double-quoted strings, so `${...}` and backticks are emitted literally. */ diff --git a/packages/cli/lib/create.js b/packages/cli/lib/create.js index 336e42bc..baa73161 100644 --- a/packages/cli/lib/create.js +++ b/packages/cli/lib/create.js @@ -18,7 +18,6 @@ import { existsSync } from 'node:fs'; import { createRequire } from 'node:module'; import { spawnSync } from 'node:child_process'; import { bunifyProse, bunifyDockerfile, bunifyCompose, bunifyCi } from './runtime-rewrite.js'; -import { leanComponentSource } from './lean-copy.js'; /** * Detect which package manager invoked us. Reads `npm_config_user_agent`, @@ -106,60 +105,6 @@ function resolveUiRegistryRoot() { } const UI_REGISTRY_ROOT = resolveUiRegistryRoot(); -/** - * Read a single @webjsdev/ui registry component, rewrite its relative import - * of `../lib/utils.ts` to the scaffolded app's aliased path so it resolves - * when written to `components/ui/.ts`. The scaffold puts cn() at - * `lib/utils/cn.ts` (folder-grouped with other browser-safe helpers), so the - * alias form is `#lib/utils/cn.ts` (#555/#556). - * - * @param {string} name component name without `.ts` (e.g. 'button') - * @returns {Promise} source with import rewritten - */ -async function readUiComponent(name) { - const src = join(UI_REGISTRY_ROOT, 'components', `${name}.ts`); - const raw = await readFile(src, 'utf8'); - // The registry component imports cn() via a relative `../lib/utils.ts`; rewrite - // it to the scaffolded app's aliased path (cn lives at lib/utils/cn.ts). - const rewritten = raw - .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). - .replaceAll("'../lib/dom.ts'", "'#lib/utils/dom.ts'") - .replaceAll('"../lib/dom.ts"', '"#lib/utils/dom.ts"'); - // Strip the worked @example from a Tier-1 helper (same as `webjs ui add`), so - // the scaffolded component is lean and the example is served on demand. The - // shared helper is used by the saas-template copier too, so they cannot drift. - return leanComponentSource(rewritten, name); -} - -/** - * Copy a list of @webjsdev/ui registry components into the scaffolded app - * under `components/ui/`. Throws if any name is missing from the registry, - * since the scaffold's generated pages import these by name and a missing - * file would produce ERR_MODULE_NOT_FOUND at first request. Caller must - * have already invoked assertUiRegistryAvailable(). - * - * @param {string} appDir destination app root - * @param {string[]} names list of component file basenames (without `.ts`) - */ -async function copyUiComponents(appDir, names) { - const uiDir = join(appDir, 'components', 'ui'); - await mkdir(uiDir, { recursive: true }); - for (const n of names) { - const src = join(UI_REGISTRY_ROOT, 'components', `${n}.ts`); - if (!existsSync(src)) { - throw new Error( - `@webjsdev/ui registry is missing component '${n}.ts' at ${src}. ` + - `The scaffold's example pages import this component by name. ` + - `Either the registry was published incompletely or the scaffold's ` + - `component list is out of sync with the registry.`, - ); - } - await writeFile(join(uiDir, `${n}.ts`), await readUiComponent(n)); - } -} - /** * Copy the example gallery (idiomatic, densely-commented working examples) into * the scaffolded app. Merges `templates/gallery/{app,modules}` over the app so @@ -177,7 +122,9 @@ async function copyUiComponents(appDir, names) { */ async function copyGallery(appDir) { const galleryDir = join(TEMPLATES, 'gallery'); - for (const sub of ['app', 'modules']) { + // `test` carries the auth card's real request-pipeline test (test/auth); it + // ships with the gallery and is pruned by gallery:clear alongside the card. + for (const sub of ['app', 'modules', 'test']) { await cp(join(galleryDir, sub), join(appDir, sub), { recursive: true }); } } @@ -312,21 +259,19 @@ export async function scaffoldApp(name, cwd, opts = {}) { const shouldInstall = opts.install === true; // Defence in depth. The CLI already validates this, but library // callers (tests, programmatic use) might pass anything. - const VALID_TEMPLATES = ['full-stack', 'api', 'saas']; + const VALID_TEMPLATES = ['full-stack', 'api']; if (!VALID_TEMPLATES.includes(template)) { throw new Error( `Unknown template '${template}'. Only ${VALID_TEMPLATES.join(' / ')} exist.`, ); } const isApi = template === 'api'; - const isSaas = template === 'saas'; - // The example gallery ships in every UI scaffold (full-stack AND saas). The - // copyGallery gate below is !isApi, since only the api template has no UI. saas - // overwrites db/schema.server.ts with its own schema (which includes the - // gallery's todos table) and renders the gallery below its auth landing. - // isFullStack distinguishes the plain full-stack app from saas for the parts - // that differ (its own home page and the create.js-written schema). - const isFullStack = !isApi && !isSaas; + // The example gallery ships in the one UI template (not api, which has no UI), + // so the copyGallery gate below is !isApi. Auth is one of the gallery cards + // (app/features/auth + modules/auth), so a UI app ships a real, prunable auth + // baseline. `isFullStack` names the UI template for the parts that differ from + // api (the todos table + the passwordHash column the auth card needs). + const isFullStack = !isApi; // Database dialect (#563): sqlite (default) or postgres. Drizzle is the ORM; // the schema/queries/actions are identical across dialects, only db/columns @@ -763,7 +708,10 @@ export const users = table('users', { name: text(), // JSON column: a structured value persisted as JSON, typed via json(). // Same helper works on SQLite and Postgres. Delete if you do not need it. - settings: json<{ theme?: string }>(), + settings: json<{ theme?: string }>(),${isFullStack ? ` + // The auth gallery card (app/features/auth) signs credentials against this. + // gallery:clear removes this column with the rest of the auth surface. + passwordHash: text(),` : ''} createdAt: createdAt(), }); ${isFullStack ? ` @@ -1043,7 +991,7 @@ export type ActionResult = } if (!isApi) { - // Full-stack and SaaS templates: layout + page + theme toggle + Tailwind + // The UI template: layout + page + theme toggle + Tailwind // The Tailwind stylesheet is compiled from public/input.css (written below) // to a STATIC public/tailwind.css by css:build, and lib/utils/ui.ts helpers @@ -1053,8 +1001,8 @@ export type ActionResult = const publicDir = join(appDir, 'public'); await mkdir(publicDir, { recursive: true }); // Progressive-enhancement service worker (#271): ship the opt-in offline - // primitive (the worker + its offline fallback) into the UI scaffolds - // (full-stack / saas; this block is api-excluded since api has no UI). + // primitive (the worker + its offline fallback) into the UI scaffold + // (this block is api-excluded since api has no UI). // Dormant until the app registers it (see the skill's references/service-worker.md); // it never changes the JS-disabled baseline. for (const swFile of ['sw.js', 'offline.html']) { @@ -1087,13 +1035,9 @@ export type ActionResult = // styles/globals.css (the @webjsdev/ui theme). await writeUiBootstrap(appDir); - // The saas auth pages import a few ui-* primitives. A full-stack app adds - // any component on demand with `webjs ui add `. - if (isSaas) { - await copyUiComponents(appDir, [ - 'button', 'card', 'alert', 'badge', 'separator', 'label', 'input', - ]); - } + // The gallery cards style with plain Tailwind (the app is pre-initialised for + // `webjs ui add ` via writeUiBootstrap above, but ships no components + // until you add them on demand). // The @webjsdev/ui theme (`--color-primary`, `--color-card`, the @theme maps, // @custom-variant, @keyframes) plus the app @theme mappings are compiled from @@ -1136,10 +1080,10 @@ ${uiThemeRaw} // The gallery: idiomatic, densely-commented single-feature demos under // app/features/ plus one whole example app under app/examples/, with logic - // in modules/, all linked from the home page below. Shipped in every UI - // scaffold (full-stack AND saas) so an agent gains context by browsing real - // working code; prune per-feature (delete the route + its module) for what - // the app does not use. + // in modules/, all linked from the home page below. Shipped in the UI + // scaffold so an agent gains context by browsing real working code; prune + // per-feature (delete the route + its module) for what the app does not use, + // or shed the whole gallery at once with `gallery:clear`. await copyGallery(appDir); await writeFile(join(appDir, 'app', 'layout.ts'), `import { html, cspNonce } from '@webjsdev/core'; @@ -1322,11 +1266,7 @@ export default function RootLayout({ children }: { children: unknown }) { // demo and the example app, and a footer with the docs + source links. Treat it // as a starting point: prune the demos you do not use (delete the // app/features/ route AND its modules/), then reshape this page into the - // app's real landing page. For the saas template a login/signup CTA row is - // spliced under the tagline. - const homeAuthLinks = isSaas - ? '\n ' - : ''; + // app's real landing page. await writeFile(join(appDir, 'app', 'page.ts'), `import { html } from '@webjsdev/core'; export const metadata = { @@ -1340,6 +1280,7 @@ export const metadata = { const FEATURES = [ { href: '/features/routing', title: 'Routing', blurb: 'A static route plus a dynamic [id] segment that reads params. The file-based router in miniature.' }, { href: '/features/boundaries', title: 'Boundaries', blurb: 'The control-flow throws (forbidden / unauthorized / notFound) and the nearest boundary file that catches each.' }, + { href: '/features/auth', title: 'Auth', blurb: 'Password login on createAuth, a signed session cookie, and a real protected route that redirects anonymous visitors to login.' }, { href: '/features/components', title: 'Components', blurb: 'The WebComponent factory, reactive props, instance signals, and slot projection in light DOM.' }, { href: '/features/server-actions', title: 'Server actions', blurb: 'A use-server RPC action next to a server-only .server.ts utility, and why the boundary matters.' }, { href: '/features/optimistic-ui', title: 'Optimistic UI', blurb: 'The imperative optimistic(signal, value, action) flip: instant update, automatic rollback on failure.' }, @@ -1379,7 +1320,7 @@ export default function Home() {

AI-first and web-components-first. Server-rendered, progressively enhanced, and buildless. -

${homeAuthLinks} +

@@ -1508,12 +1449,6 @@ ThemeToggle.register('theme-toggle'); `); } // end if (!isApi) - // --- SaaS template extras: auth, dashboard, drizzle User model --- - if (isSaas) { - const { writeSaasFiles } = await import('./saas-template.js'); - await writeSaasFiles(appDir, { runtime }); - } - // AGENTS.md is already in place via the shared `templateFiles` loop // earlier in this function, so no framework-root fallback needed. @@ -1534,20 +1469,11 @@ ThemeToggle.register('theme-toggle'); modules/users/{actions,queries,types.ts} ← routes over server actions db/{schema,columns,connection}.server.ts ← Drizzle (User model) ${guide} -`); - } else if (isSaas) { - console.log(` ${name}/ - app/{layout,page}.ts, login/, signup/ - app/dashboard/{page,settings,middleware}.ts ← protected - app/api/auth/[...path]/route.ts ← auth API - components/ui/*, components/theme-toggle.ts - modules/auth/*, lib/{auth,password}.server.ts - db/{schema,columns,connection}.server.ts ← Drizzle (User model) - ${guide} `); } else { console.log(` ${name}/ - app/{layout,page}.ts ← a minimal home to grow in place + app/{layout,page}.ts ← gallery home; gallery:clear grows in place + app/features/*, modules/* ← browsable demos (incl. auth: login + protected route) components/theme-toggle.ts public/input.css ← Tailwind entry (compiles to public/tailwind.css) db/{schema,columns,connection}.server.ts ← Drizzle @@ -1596,9 +1522,9 @@ ThemeToggle.register('theme-toggle'); // Next-steps banner prints LAST so the actionable command is the // final thing on screen, never buried above the AI-agent guidance. // Single copy-paste line so the user can move from "scaffold done" - // to "dev server up" in one command. The full-stack and saas - // templates ship with @webjsdev/ui already initialised; the api - // template has no UI but may add one later. + // to "dev server up" in one command. The full-stack template ships + // with @webjsdev/ui already initialised; the api template has no UI + // but may add one later. const installSegment = installed ? '' : `${pm} install && `; // The shipped schema is applied on the first `run dev` (webjs.*.before runs // `db migrate`), but only if a migration FILE exists. When we installed, we diff --git a/packages/cli/lib/saas-template.js b/packages/cli/lib/saas-template.js deleted file mode 100644 index 441d6115..00000000 --- a/packages/cli/lib/saas-template.js +++ /dev/null @@ -1,568 +0,0 @@ -/** - * SaaS template files for `webjs create --template saas`. - * Extracted to avoid nested template literal escaping issues. - */ - -import { mkdir, writeFile, readFile } from 'node:fs/promises'; -import { bunifyProse } from './runtime-rewrite.js'; -import { leanComponentSource } from './lean-copy.js'; -import { existsSync } from 'node:fs'; -import { join, resolve, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const UI_REGISTRY_ROOT = resolve( - __dirname, '..', '..', 'ui', 'packages', 'registry', -); - -/** - * Read a registry component and rewrite its `'#lib/utils.ts'` import for - * the scaffolded app's `components/ui/.ts` layout (the `#lib/utils/cn.ts` - * alias). Mirrors the helper in `create.js`, kept private here to avoid coupling. - */ -async function readUiComponent(name) { - const src = join(UI_REGISTRY_ROOT, 'components', `${name}.ts`); - if (!existsSync(src)) return null; - const raw = await readFile(src, 'utf8'); - // The registry component imports cn() via a relative `../lib/utils.ts`; rewrite - // it to the scaffolded app's aliased path (cn lives at lib/utils/cn.ts). - const rewritten = raw - .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"'); - // Strip a Tier-1 helper's worked @example (same as create.js + `webjs ui add`) - // so switch / checkbox are lean, not just the full-stack base set (#983). - return leanComponentSource(rewritten, name); -} - -/** Copy named registry components into `/components/ui/`. */ -async function copyUiComponents(appDir, names) { - const uiDir = join(appDir, 'components', 'ui'); - await mkdir(uiDir, { recursive: true }); - for (const n of names) { - const content = await readUiComponent(n); - if (content == null) continue; - await writeFile(join(uiDir, `${n}.ts`), content); - } -} - -/** - * @param {string} appDir - * @param {{ runtime?: 'node'|'bun' }} [opts] - */ -export async function writeSaasFiles(appDir, opts = {}) { - const isBun = opts.runtime === 'bun'; - // SaaS pages use auth forms, so copy the extra ui-* components on top of - // the standard set the full-stack scaffold already wrote. Pre-importing - // them in login/signup/dashboard pages below means the dev server will - // SSR these elements with full styling on first paint. - // `form` and `field` are deferred to v2 (see packages/ui/AGENTS.md) - - // the saas auth pages use raw
+ label/input class helpers instead. - await copyUiComponents(appDir, ['dialog', 'switch', 'checkbox']); - - // The db/ layer (columns/connection) is written by the full-stack scaffold - // already; this template overwrites db/schema.server.ts below to add the - // User.passwordHash column auth needs. - await mkdir(join(appDir, 'lib'), { recursive: true }); - - // lib/password.server.ts - await writeFile(join(appDir, 'lib', 'password.server.ts'), [ - "import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';", - "import { promisify } from 'node:util';", - "", - "const scryptAsync = promisify(scrypt);", - "", - "export async function hash(password: string): Promise {", - " const salt = randomBytes(16).toString('hex');", - " const buf = (await scryptAsync(password, salt, 64)) as Buffer;", - " return salt + ':' + buf.toString('hex');", - "}", - "", - "export async function compare(password: string, stored: string): Promise {", - " const [salt, key] = stored.split(':');", - " const buf = (await scryptAsync(password, salt, 64)) as Buffer;", - " return timingSafeEqual(buf, Buffer.from(key, 'hex'));", - "}", - "", - ].join('\n')); - - // lib/auth.server.ts - await writeFile(join(appDir, 'lib', 'auth.server.ts'), [ - "import { createAuth, Credentials, GitHub, Google } from '@webjsdev/server';", - "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({", - " async authorize(credentials: { email: string; password: string }) {", - " const user = await db.query.users.findFirst({ where: { email: credentials.email } });", - " if (!user || !await compare(credentials.password, user.passwordHash)) return null;", - " return { id: String(user.id), name: user.name, email: user.email };", - " },", - " }),", - " // OAuth providers: add GitHub / Google sign-in by setting the matching", - " // env vars. Each preset (GitHub(), Google()) reads AUTH__ID /", - " // _SECRET, so they only activate once configured and a fresh scaffold", - " // still boots with just Credentials.", - " ...(process.env.AUTH_GITHUB_ID ? [GitHub({ clientId: process.env.AUTH_GITHUB_ID, clientSecret: process.env.AUTH_GITHUB_SECRET })] : []),", - " ...(process.env.AUTH_GOOGLE_ID ? [Google({ clientId: process.env.AUTH_GOOGLE_ID, clientSecret: process.env.AUTH_GOOGLE_SECRET })] : []),", - " ],", - " secret: authSecret,", - " // A failed credentials sign-in 302s to `${pages.error}?error=CredentialsSignin`.", - " // Point it at /login so app/login/page.ts reads searchParams.error and shows a", - " // message, instead of the createAuth default (the home page) swallowing the error.", - " pages: { error: '/login' },", - "});", - "", - ].join('\n')); - - // db/schema.server.ts: overwrite the full-stack scaffold's example User to - // add passwordHash (the column auth needs). Drizzle, dialect-agnostic. - await writeFile(join(appDir, 'db', 'schema.server.ts'), [ - "import { defineRelations } from 'drizzle-orm';", - "import { table, pk, uuidPk, text, bool, createdAt } from './columns.server.ts';", - "", - "export const users = table('users', {", - " id: pk(),", - " email: text().notNull().unique(),", - " name: text(),", - " passwordHash: text().notNull(),", - " createdAt: createdAt(),", - "});", - "", - "// Backs the example-gallery /examples/todo route (modules/todo). Delete it", - "// with the gallery when you prune the examples you do not use.", - "export const todos = table('todos', {", - " id: uuidPk(),", - " title: text().notNull(),", - " completed: bool().notNull().default(false),", - " createdAt: createdAt(),", - "});", - "", - "export const relations = defineRelations({ users, todos }, () => ({}));", - "", - "export type User = typeof users.$inferSelect;", - "", - ].join('\n')); - - // modules/auth/actions/signup.server.ts - await mkdir(join(appDir, 'modules', 'auth', 'actions'), { recursive: true }); - await mkdir(join(appDir, 'modules', 'auth', 'queries'), { recursive: true }); - - await writeFile(join(appDir, 'modules', 'auth', 'actions', 'signup.server.ts'), [ - "'use server';", - "", - "import { db } from '#db/connection.server.ts';", - "import { users } from '#db/schema.server.ts';", - "import { hash } from '#lib/password.server.ts';", - "import { signIn } from '#lib/auth.server.ts';", - "", - "// Creates the account, then signs the new user in and lands on the", - "// dashboard. signIn returns a 302 Response carrying the session cookie; the", - "// signup page action returns that Response as-is (a page action may return a", - "// Response). signIn lives in the server-only auth module, imported here", - "// server-to-server, so it never reaches the browser (the signup page only", - "// imports this action's RPC stub).", - "export async function signup(input: { name: string; email: string; password: string }) {", - " const exists = await db.query.users.findFirst({ where: { email: input.email }, columns: { id: true } });", - " if (exists) return { success: false as const, error: 'Email already registered', status: 409 };", - " await db.insert(users).values({ name: input.name, email: input.email, passwordHash: await hash(input.password) });", - " return signIn('credentials', { email: input.email, password: input.password }, { redirectTo: '/dashboard' });", - "}", - "", - ].join('\n')); - - // modules/auth/queries/current-user.server.ts - await writeFile(join(appDir, 'modules', 'auth', 'queries', 'current-user.server.ts'), [ - "'use server';", - "", - "import { auth } from '#lib/auth.server.ts';", - "", - "// This read deliberately stays POST-default (no 'method' export). A GET", - "// server action (#488) is cacheable and SSR-seeded, which is wrong for a", - "// per-session read: the result differs per user and changes on sign-in /", - "// sign-out, so it must never be browser-cached or shared. Reserve GET +", - "// cache + tags for data identical for every visitor (see", - "// modules/users/queries/list-users.server.ts in the full-stack template).", - "export async function currentUser() {", - " const session = await auth();", - " return session?.user ?? null;", - "}", - "", - ].join('\n')); - - // modules/auth/types.ts - await writeFile(join(appDir, 'modules', 'auth', 'types.ts'), [ - "export interface User {", - " id: number;", - " name: string | null;", - " email: string;", - "}", - "", - "export type ActionResult =", - " | { success: true; data: T }", - " | { success: false; error: string; status: number };", - "", - ].join('\n')); - - // test/auth/auth.test.ts: a REAL auth-flow test driven through the framework - // request pipeline with the @webjsdev/server test harness (createRequestHandler - // + the handle() helpers from @webjsdev/server/testing). It lives under the - // documented test// convention (test/auth/), not the old test/unit/ - // path. - // - // Two layers, by DB availability: - // - The protected-route gate (unauthenticated /dashboard -> 302 /login) runs - // ALWAYS once the app modules import: auth() only reads a cookie, no DB - // query. This is the headline security assertion and it is REAL. - // The signup, login, and protected-route flow writes + reads a user, so it - // needs the migrated users table (`npm run db:generate`, then `npm run dev` - // applies it via webjs.dev.before). Until then those flows error, so the - // suite probes readiness and skips with a clear message instead of crashing, - // then runs for real once the db is set up. - await mkdir(join(appDir, 'test', 'auth'), { recursive: true }); - // The generated comments reference `npm run db:*` setup; bun-ify them so a - // bun-flavored saas app reads `bun run db:*` (#541; db is Node tooling, so a - // plain `bun run`, not the --bun server form). The transform is a no-op on Node. - const authTest = [ - "import { test } from 'node:test';", - "import assert from 'node:assert/strict';", - "import { fileURLToPath } from 'node:url';", - "import { dirname, resolve } from 'node:path';", - "", - "import { createRequestHandler } from '@webjsdev/server';", - "import { testRequest, loginAndGetCookies, withSessionCookie } from '@webjsdev/server/testing';", - "", - "const appDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');", - "", - "// The auth pages + dashboard middleware query the users table via Drizzle.", - "// Until `npm run db:generate` has authored the migration (then `npm run dev`", - "// applies it via webjs.dev.before, or `npm run db:migrate` directly), a", - "// request hitting those modules 500s; we detect that at the RESPONSE level", - "// (a 5xx on the dashboard) and SKIP with a clear message rather than report", - "// a misleading failure. After the db is set up every assertion runs for real.", - "process.env.DATABASE_URL ||= 'file:./dev.db';", - "process.env.AUTH_SECRET ||= 'test-secret-at-least-32-characters-long!!';", - "", - "function makeHandler() {", - " // createRequestHandler builds lazily, so it succeeds even before the DB", - " // is migrated; the missing table only surfaces when a request reaches a", - " // module that queries it. That is why readiness is probed per-response.", - " return createRequestHandler({ appDir, dev: true });", - "}", - "", - "test('protected route redirects to /login when unauthenticated', async (t) => {", - " const app = await makeHandler();", - " const res = await testRequest(app.handle, '/dashboard');", - " if (res.status >= 500) {", - " t.skip('app deps not ready (run `npm run db:generate` + `npm run db:migrate`)');", - " return;", - " }", - " // The dashboard middleware calls auth(); with no session cookie it 302s to", - " // /login. This needs no DB row, only a cookie read, so it is always real", - " // once the modules import.", - " assert.equal(res.status, 302, 'unauthenticated dashboard is gated');", - " assert.equal(res.headers.get('location'), '/login');", - "});", - "", - "test('signup -> login -> dashboard renders for the authenticated user', async (t) => {", - " const app = await makeHandler();", - " // Probe readiness: a 5xx on the dashboard means deps/DB are not set up.", - " const probe = await testRequest(app.handle, '/dashboard');", - " if (probe.status >= 500) { t.skip('app deps not ready; run `npm run db:generate` + `npm run db:migrate`'); return; }", - "", - " const email = `harness+${Date.now()}@example.com`;", - " const password = 'password123';", - "", - " // Real signup through the page server action (the no-JS form write-path).", - " let canSignup = true;", - " try {", - " const signupRes = await testRequest(app.handle, '/signup', {", - " method: 'POST',", - " headers: { 'content-type': 'application/x-www-form-urlencoded' },", - " body: new URLSearchParams({ name: 'Harness', email, password }).toString(),", - " });", - " // Success auto-logs-in and 302s to /dashboard (carrying the session", - " // cookie); a 422 means validation failed. Either way the action ran.", - " assert.ok([302, 422].includes(signupRes.status), 'signup action ran');", - " if (signupRes.status === 302) assert.equal(signupRes.headers.get('location'), '/dashboard', 'signup lands on the dashboard');", - " if (signupRes.status !== 302) canSignup = false;", - " } catch {", - " // No migrated DB table -> the action throws. Skip the DB-backed assertions.", - " canSignup = false;", - " }", - " if (!canSignup) { t.skip('no migrated DB; run `npm run db:migrate` to enable the full flow'); return; }", - "", - " // Real login captures the genuine signed session cookie.", - " const { cookies } = await loginAndGetCookies(app.handle, { email, password });", - "", - " // With the session cookie the protected route now renders (200).", - " const dash = await testRequest(app.handle, '/dashboard', withSessionCookie({}, cookies));", - " assert.equal(dash.status, 200, 'the session cookie unlocks the dashboard');", - " const body = await dash.text();", - " assert.match(body, /Dashboard/, 'the dashboard content rendered');", - " // The greeting interpolates the real user, so the name renders and the", - " // literal template source never leaks (a counterfactual for the escaping bug).", - " assert.match(body, /Harness/, 'the dashboard greets the signed-in user by name');", - " assert.ok(!body.includes('${user'), 'the greeting interpolation is not a literal string');", - "});", - "", - ].join('\n'); - await writeFile( - join(appDir, 'test', 'auth', 'auth.test.ts'), - isBun ? bunifyProse(authTest) : authTest, - ); - - // app/api/auth/[...path]/route.ts - await mkdir(join(appDir, 'app', 'api', 'auth', '[...path]'), { recursive: true }); - await writeFile(join(appDir, 'app', 'api', 'auth', '[...path]', 'route.ts'), [ - "import { handlers } from '#lib/auth.server.ts';", - "export const GET = handlers.GET;", - "export const POST = handlers.POST;", - "", - ].join('\n')); - - // app/login/page.ts - await mkdir(join(appDir, 'app', 'login'), { recursive: true }); - await writeFile(join(appDir, 'app', 'login', 'page.ts'), [ - "import { html } from '@webjsdev/core';", - "import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass, cardFooterClass } from '#components/ui/card.ts';", - "import { buttonClass } from '#components/ui/button.ts';", - "import { inputClass } from '#components/ui/input.ts';", - "import { labelClass } from '#components/ui/label.ts';", - "", - "export const metadata = { title: 'Login' };", - "", - "// A failed sign-in 302s back here with ?error=... (createAuth is configured", - "// with pages.error: '/login' in lib/auth.server.ts). Map the code to a plain", - "// message so a bad password gets visible feedback instead of a silent bounce.", - "function errorMessage(code: string | undefined): string | null {", - " if (!code) return null;", - " if (code === 'CredentialsSignin') return 'Invalid email or password.';", - " return 'Could not sign you in. Please try again.';", - "}", - "", - "export default function LoginPage({ searchParams }: { searchParams: { error?: string } }) {", - " const error = errorMessage(searchParams.error);", - " return html`", - "
", - "
", - "
", - "

Sign in

", - "

Welcome back: log in to continue.

", - "
", - "
", - " ${error ? html`

${error}

` : ''}", - " ", - " ", - " ", - "
", - " ", - " ", - "
", - "
", - " ", - " ", - "
", - " ", - " ", - "
", - "
", - "

Don't have an account? Sign up

", - "
", - "
", - "
", - " `;", - "}", - "", - ].join('\n')); - - // app/signup/page.ts - await mkdir(join(appDir, 'app', 'signup'), { recursive: true }); - await writeFile(join(appDir, 'app', 'signup', 'page.ts'), [ - "import { html } from '@webjsdev/core';", - "import { signup } from '#modules/auth/actions/signup.server.ts';", - "import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass, cardFooterClass } from '#components/ui/card.ts';", - "import { buttonClass } from '#components/ui/button.ts';", - "import { inputClass } from '#components/ui/input.ts';", - "import { labelClass } from '#components/ui/label.ts';", - "", - "export const metadata = { title: 'Sign up' };", - "", - "// Page server action: handles the POST from the form below. With JS", - "// disabled this is a plain
round-trip; with JS the client router", - "// swaps the 422 re-render (errors) or follows the 303 (success) in place.", - "// A validation failure returns fieldErrors + values so the page re-renders", - "// with messages and the user's typed input preserved (#244).", - "export async function action({ formData }: { formData: FormData }) {", - " const name = String(formData.get('name') || '').trim();", - " const email = String(formData.get('email') || '').trim();", - " const password = String(formData.get('password') || '');", - " const values = { name, email };", - " const fieldErrors: Record = {};", - " if (!name) fieldErrors.name = 'Name is required';", - " if (!email.includes('@')) fieldErrors.email = 'Enter a valid email';", - " if (password.length < 8) fieldErrors.password = 'At least 8 characters';", - " if (Object.keys(fieldErrors).length) return { success: false, fieldErrors, values, status: 422 };", - " const result = await signup({ name, email, password });", - " // On success signup returns signIn's 302 Response (auto-login -> /dashboard);", - " // a page action may return a Response, so pass it straight through.", - " if (result instanceof Response) return result;", - " return { success: false, fieldErrors: { email: result.error }, values, status: result.status };", - "}", - "", - "export default function SignupPage({ actionData }: { actionData?: { fieldErrors?: Record; values?: Record } }) {", - " const errors = actionData?.fieldErrors || {};", - " const values = actionData?.values || {};", - " return html`", - "
", - "
", - "
", - "

Create an account

", - "

Get started with your new workspace.

", - "
", - "
", - " ", - "
", - " ", - " ", - " ${errors.name ? html`

${errors.name}

` : ''}", - "
", - "
", - " ", - " ", - " ${errors.email ? html`

${errors.email}

` : ''}", - "
", - "
", - " ", - " ", - " ${errors.password ? html`

${errors.password}

` : ''}", - "
", - " ", - " ", - "
", - "
", - "

Already have an account? Log in

", - "
", - "
", - "
", - " `;", - "}", - "", - ].join('\n')); - - // app/dashboard/middleware.ts - await mkdir(join(appDir, 'app', 'dashboard', 'settings'), { recursive: true }); - await writeFile(join(appDir, 'app', 'dashboard', 'middleware.ts'), [ - "import { auth } from '#lib/auth.server.ts';", - "", - "export default async function requireAuth(req: Request, next: () => Promise) {", - " const session = await auth();", - " if (!session?.user) {", - " return new Response(null, { status: 302, headers: { location: '/login' } });", - " }", - " return next();", - "}", - "", - ].join('\n')); - - // app/dashboard/layout.ts: a thin sub-nav shared by every /dashboard page - // (page.ts and settings/page.ts). It carries the logout control so a signed-in - // user can end the session from anywhere under /dashboard. - await writeFile(join(appDir, 'app', 'dashboard', 'layout.ts'), [ - "import { html } from '@webjsdev/core';", - "import { buttonClass } from '#components/ui/button.ts';", - "", - "// Nested layout for the protected /dashboard subtree. Logout is a plain", - "//
posting to the createAuth signout route: it clears the", - "// session cookie and 302s home, and works with JS off (progressive-enhancement", - "// default). signOut is server-only (lib/auth.server.ts), so we POST to its route", - "// rather than import it into a browser-shipping page. After signout the dashboard", - "// middleware bounces any later /dashboard visit to /login.", - "export default function DashboardLayout({ children }: { children: unknown }) {", - " return html`", - "
", - " ${children}", - " `;", - "}", - "", - ].join('\n')); - - // app/dashboard/page.ts - await writeFile(join(appDir, 'app', 'dashboard', 'page.ts'), [ - "import { html } from '@webjsdev/core';", - "import { currentUser } from '#modules/auth/queries/current-user.server.ts';", - "import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass } from '#components/ui/card.ts';", - "import { badgeClass } from '#components/ui/badge.ts';", - "", - "export const metadata = { title: 'Dashboard' };", - "", - "export default async function Dashboard() {", - " const user = await currentUser();", - " return html`", - "
", - "

Dashboard

", - " Signed in", - "
", - "
", - "
", - "

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

", - "

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

", - "
", - "
", - " `;", - "}", - "", - ].join('\n')); - - // app/dashboard/settings/page.ts - await writeFile(join(appDir, 'app', 'dashboard', 'settings', 'page.ts'), [ - "import { html } from '@webjsdev/core';", - "import { currentUser } from '#modules/auth/queries/current-user.server.ts';", - "import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass } from '#components/ui/card.ts';", - "", - "export const metadata = { title: 'Settings' };", - "", - "export default async function Settings() {", - " const user = await currentUser();", - " return html`", - "

Settings

", - "
", - "
", - "

Account

", - "

Your basic profile information.

", - "
", - "
", - "
", - "
Email
", - "
${user?.email}
", - "
Name
", - "
${user?.name || 'Not set'}
", - "
", - "
", - "
", - " `;", - "}", - "", - ].join('\n')); -} diff --git a/packages/cli/templates/gallery/app/features/auth/dashboard/layout.ts b/packages/cli/templates/gallery/app/features/auth/dashboard/layout.ts index a920f387..7b4263af 100644 --- a/packages/cli/templates/gallery/app/features/auth/dashboard/layout.ts +++ b/packages/cli/templates/gallery/app/features/auth/dashboard/layout.ts @@ -1,5 +1,4 @@ import { html } from '@webjsdev/core'; -import { buttonClass } from '#components/ui/button.ts'; // Nested layout for the protected dashboard subtree. Logout is a plain //
posting to the createAuth signout route: it clears the @@ -10,10 +9,10 @@ import { buttonClass } from '#components/ui/button.ts'; export default function DashboardLayout({ children }: { children: unknown }) { return html`
${children} diff --git a/packages/cli/templates/gallery/app/features/auth/dashboard/page.ts b/packages/cli/templates/gallery/app/features/auth/dashboard/page.ts index d77e1f90..dd68314e 100644 --- a/packages/cli/templates/gallery/app/features/auth/dashboard/page.ts +++ b/packages/cli/templates/gallery/app/features/auth/dashboard/page.ts @@ -1,7 +1,5 @@ import { html } from '@webjsdev/core'; import { currentUser } from '#modules/auth/queries/current-user.server.ts'; -import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass } from '#components/ui/card.ts'; -import { badgeClass } from '#components/ui/badge.ts'; export const metadata = { title: 'Dashboard' }; @@ -10,13 +8,11 @@ export default async function Dashboard() { return html`

Dashboard

- Signed in + Signed in
-
-
-

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

-

This route is gated by middleware.ts. Promote it into your product, or drop the whole auth card with gallery:clear.

-
+
+

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

+

This route is gated by middleware.ts. Promote it into your product, or drop the whole auth card with gallery:clear.

`; } diff --git a/packages/cli/templates/gallery/app/features/auth/dashboard/settings/page.ts b/packages/cli/templates/gallery/app/features/auth/dashboard/settings/page.ts index 8f65b091..60b37486 100644 --- a/packages/cli/templates/gallery/app/features/auth/dashboard/settings/page.ts +++ b/packages/cli/templates/gallery/app/features/auth/dashboard/settings/page.ts @@ -1,6 +1,5 @@ import { html } from '@webjsdev/core'; import { currentUser } from '#modules/auth/queries/current-user.server.ts'; -import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass } from '#components/ui/card.ts'; export const metadata = { title: 'Settings' }; @@ -8,19 +7,15 @@ export default async function Settings() { const user = await currentUser(); return html`

Settings

-
-
-

Account

-

Your basic profile information.

-
-
-
-
Email
-
${user?.email}
-
Name
-
${user?.name || 'Not set'}
-
-
+
+

Account

+

Your basic profile information.

+
+
Email
+
${user?.email}
+
Name
+
${user?.name || 'Not set'}
+
`; } diff --git a/packages/cli/templates/gallery/app/features/auth/login/page.ts b/packages/cli/templates/gallery/app/features/auth/login/page.ts index a56a906e..b74ab9ae 100644 --- a/packages/cli/templates/gallery/app/features/auth/login/page.ts +++ b/packages/cli/templates/gallery/app/features/auth/login/page.ts @@ -1,11 +1,9 @@ import { html } from '@webjsdev/core'; -import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass, cardFooterClass } from '#components/ui/card.ts'; -import { buttonClass } from '#components/ui/button.ts'; -import { inputClass } from '#components/ui/input.ts'; -import { labelClass } from '#components/ui/label.ts'; export const metadata = { title: 'Log in' }; +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'; + // A failed sign-in 302s back here with ?error=... (createAuth is configured with // pages.error: '/features/auth/login' in modules/auth/auth.server.ts). Map the // code to a plain message so a bad password gets visible feedback instead of a @@ -19,32 +17,24 @@ function errorMessage(code: string | undefined): string | null { export default function LoginPage({ searchParams }: { searchParams: { error?: string } }) { const error = errorMessage(searchParams.error); return html` -
-
-
-

Sign in

-

Welcome back: log in to continue.

-
-
- ${error ? html`` : ''} -
- - -
- - -
-
- - -
- -
+
+

Sign in

+

Welcome back: log in to continue.

+ ${error ? html`` : ''} +
+ + +
+ +
-
-

Don't have an account? Sign up

+
+ +
-
+ +
+

Don't have an account? Sign up

`; } diff --git a/packages/cli/templates/gallery/app/features/auth/signup/page.ts b/packages/cli/templates/gallery/app/features/auth/signup/page.ts index 64ef6fe7..4ad1597d 100644 --- a/packages/cli/templates/gallery/app/features/auth/signup/page.ts +++ b/packages/cli/templates/gallery/app/features/auth/signup/page.ts @@ -1,12 +1,10 @@ import { html } from '@webjsdev/core'; import { signup } from '#modules/auth/actions/signup.server.ts'; -import { cardClass, cardHeaderClass, cardTitleClass, cardDescriptionClass, cardContentClass, cardFooterClass } from '#components/ui/card.ts'; -import { buttonClass } from '#components/ui/button.ts'; -import { inputClass } from '#components/ui/input.ts'; -import { labelClass } from '#components/ui/label.ts'; export const metadata = { title: 'Sign up' }; +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'; + // Page server action: handles the POST from the form below. With JS disabled this // is a plain
round-trip; with JS the client router swaps the 422 re-render // (errors) or follows the 302 (success) in place. A validation failure returns @@ -33,36 +31,28 @@ export default function SignupPage({ actionData }: { actionData?: { fieldErrors? const errors = actionData?.fieldErrors || {}; const values = actionData?.values || {}; return html` -
-
-
-

Create an account

-

Get started with your new workspace.

+
+

Create an account

+

Get started with your new workspace.

+ +
+ + + ${errors.name ? html`

${errors.name}

` : ''}
-
- -
- - - ${errors.name ? html`

${errors.name}

` : ''} -
-
- - - ${errors.email ? html`

${errors.email}

` : ''} -
-
- - - ${errors.password ? html`

${errors.password}

` : ''} -
- - +
+ + + ${errors.email ? html`

${errors.email}

` : ''}
-
-

Already have an account? Log in

+
+ + + ${errors.password ? html`

${errors.password}

` : ''}
-
+ + +

Already have an account? Log in

`; } diff --git a/packages/cli/templates/scripts/clear-gallery.mjs b/packages/cli/templates/scripts/clear-gallery.mjs index 302ac5b6..b9edf90c 100644 --- a/packages/cli/templates/scripts/clear-gallery.mjs +++ b/packages/cli/templates/scripts/clear-gallery.mjs @@ -10,11 +10,12 @@ // once to shed the gallery, then grow the app in place. // // It removes the gallery routes + modules + demo metadata routes, resets -// app/page.ts to a minimal home, and drops the demo `todos` table from the -// schema. It KEEPS the agent skill (.agents/skills/webjs/), the layout, the -// database wiring, the theme toggle, and (for the saas template) the auth -// modules. It is a one-time reset: if the gallery is already gone (no -// app/features/) it does nothing, so a rerun never clobbers an app you built. +// app/page.ts to a minimal home, and drops the demo `todos` table plus the auth +// card's `passwordHash` column from the schema. It KEEPS the agent skill +// (.agents/skills/webjs/), the layout, the database wiring, the theme toggle, and +// the example `users` table. It is a one-time reset: if the gallery is already +// gone (no app/features/) it does nothing, so a rerun never clobbers an app you +// built. import { rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; @@ -30,16 +31,21 @@ if (!existsSync(join(root, 'app/features'))) { process.exit(0); } -// 1) Gallery route trees + example metadata routes. +// 1) Gallery route trees + example metadata routes. `app/api/auth` is the auth +// card's createAuth handler (it lives at the app root, not under app/features/, +// because createAuth hardcodes /api/auth/*), and `test/auth` is the auth card's +// request-pipeline test, so both are removed here alongside the card. const galleryPaths = [ - 'app/features', 'app/examples', 'app/sitemaps', + 'app/features', 'app/examples', 'app/sitemaps', 'app/api/auth', 'test/auth', 'app/icon.ts', 'app/apple-icon.ts', 'app/manifest.ts', 'app/opengraph-image.ts', 'app/twitter-image.ts', 'app/robots.ts', 'app/sitemap.ts', 'app/global-error.ts', 'app/global-not-found.ts', ]; -// 2) The gallery's feature modules (by name, so saas auth modules survive). +// 2) The gallery's feature modules (by name). `auth` is the auth card's server +// modules (createAuth config, password hashing, signup action, current-user +// query), pruned with the rest of the card. const galleryModules = [ - 'async-render', 'broadcast', 'caching', 'client-router', 'components', + 'async-render', 'auth', 'broadcast', 'caching', 'client-router', 'components', 'directives', 'file-storage', 'frames', 'optimistic-ui', 'rate-limit', 'route-handler', 'server-actions', 'sessions', 'streaming', 'suspense', 'todo', 'websockets', @@ -51,12 +57,17 @@ for (const p of [...galleryPaths, ...galleryModules]) if (rm(p)) removed++; // 3) Reset app/page.ts to a minimal home (no gallery grid, no dead links). writeFileSync(join(root, 'app/page.ts'), MINIMAL_PAGE()); -// 4) Drop the demo `todos` table from the schema (keep everything else). +// 4) Drop the demo `todos` table and the auth card's `passwordHash` column from +// the schema (keep the example `users` table and everything else), reverting the +// schema to the minimal base. const schemaPath = join(root, 'db/schema.server.ts'); if (existsSync(schemaPath)) { let s = readFileSync(schemaPath, 'utf8'); s = s.replace(/\n(?:\/\/[^\n]*\n)*export const todos = table\('todos',[\s\S]*?\n\}\);\n/, '\n'); s = s.replace(/defineRelations\(\{ users, todos \}/, 'defineRelations({ users }'); + // Strip the auth `passwordHash` column (and its leading comment lines) from the + // users table; the rest of the table is the minimal example base. + s = s.replace(/\n(?:[ \t]*\/\/[^\n]*\n)*[ \t]*passwordHash: text\(\),\n/, '\n'); writeFileSync(schemaPath, s); } From 98dc49fcd776df208551f5cfc11aa0a74a8e43b6 Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 22 Jul 2026 12:33:20 +0530 Subject: [PATCH 04/18] feat(scaffold): wire the server-actions greet demo to real session auth Replace the signedOut-from-input simulation in require-auth.server.ts with getCurrentUser(ctx.request) reading the signed session cookie (the RPC POST is same-origin so it rides along). The 401 is now real: an anonymous caller is genuinely denied. Drop the signedOut flag from greet.server.ts and the simulate checkbox from greeter.ts, and update greet.test.ts to prove denial + an authenticated success path via a real login. --- .../server-actions/actions/greet.server.ts | 4 +- .../server-actions/actions/greet.test.ts | 75 ++++++++++--------- .../server-actions/components/greeter.ts | 13 ++-- .../middleware/require-auth.server.ts | 19 ++--- 4 files changed, 56 insertions(+), 55 deletions(-) diff --git a/packages/cli/templates/gallery/modules/server-actions/actions/greet.server.ts b/packages/cli/templates/gallery/modules/server-actions/actions/greet.server.ts index dacb3066..e2c9d4f4 100644 --- a/packages/cli/templates/gallery/modules/server-actions/actions/greet.server.ts +++ b/packages/cli/templates/gallery/modules/server-actions/actions/greet.server.ts @@ -13,7 +13,7 @@ import { requireAuth, type AuthUser } from '../middleware/require-auth.server.ts // (the action never runs) or stashes the caller on the request context. export const middleware = [requireAuth]; -export async function greet(input: { name: string; signedOut?: boolean }): Promise> { +export async function greet(input: { name: string }): Promise> { // actionContext() is populated ONLY on a boundary that runs the middleware // chain (the RPC stub here, or a route() adapter). requireAuth runs there and // guarantees a user, so `caller` is set on every real call. A DIRECT @@ -33,7 +33,7 @@ export async function greet(input: { name: string; signedOut?: boolean }): Promi // envelope. A guard BEFORE any await can never fire, since nothing has been // awaited yet, which is why the re-check lives after the await. try { - const message = await lookupGreeting(name, caller.name, actionSignal()); + const message = await lookupGreeting(name, caller.name || caller.email || 'a signed-in user', actionSignal()); return { success: true, data: { message } }; } catch (e) { if (actionSignal().aborted) return { success: false, error: 'Request cancelled.', status: 499 }; diff --git a/packages/cli/templates/gallery/modules/server-actions/actions/greet.test.ts b/packages/cli/templates/gallery/modules/server-actions/actions/greet.test.ts index 23bdac4f..0fab5379 100644 --- a/packages/cli/templates/gallery/modules/server-actions/actions/greet.test.ts +++ b/packages/cli/templates/gallery/modules/server-actions/actions/greet.test.ts @@ -1,15 +1,24 @@ // Example node test for the documented test helpers (from @webjsdev/server). // Run it with node --test (or webjs test after moving it under test/). The // handle() harness drives the FULL request pipeline: createRequestHandler({ -// appDir }) builds it, and rawActionRequest() fires a 'use server' action -// through it (CSRF + the rich serializer included), returning the raw Response. +// appDir }) builds it, and rawActionRequest() / invokeActionForTest() fire a +// 'use server' action through it (CSRF + the rich serializer included). // buildRouteTable(appDir) parses the file router; matchPage / matchApi resolve a // URL against it, params included. See the testing docs. +// +// greet is gated by the requireAuth middleware, which reads the REAL signed +// session off the request (the auth gallery card). So an unauthenticated call is +// genuinely denied (401), and the success path needs a real session cookie +// (obtained via a signup + loginAndGetCookies, skipped until the db is migrated). import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { createRequestHandler, buildRouteTable, matchPage, matchApi, rawActionRequest, invokeActionForTest } from '@webjsdev/server'; +import { createRequestHandler, buildRouteTable, matchPage, matchApi, invokeActionForTest } from '@webjsdev/server'; +import { testRequest, loginAndGetCookies } from '@webjsdev/server/testing'; const appDir = process.cwd(); +process.env.AUTH_SECRET ||= 'test-secret-at-least-32-characters-long!!'; + +const GREET = 'modules/server-actions/actions/greet.server.ts'; test('buildRouteTable + matchPage resolve a dynamic route with its params', async () => { const table = await buildRouteTable(appDir); @@ -24,47 +33,41 @@ test('matchApi resolves the route-handler endpoint', async () => { assert.ok(m, 'the route.ts endpoint matches'); }); -test('rawActionRequest fires the greet action through the pipeline', async () => { +test('an unauthenticated greet is denied by requireAuth before greet runs', async () => { const app = await createRequestHandler({ appDir, dev: true }); if (app.warmup) await app.warmup(); - const res = await rawActionRequest( - app, - 'modules/server-actions/actions/greet.server.ts', - 'greet', - [{ name: 'Ada' }], - ); - assert.equal(res.status, 200); + // No session cookie -> requireAuth short-circuits with a 401 failure envelope + // (it reads only the cookie, so this is real without a migrated db). + const r = (await invokeActionForTest( + app, GREET, 'greet', [{ name: 'Bob' }], { throwOnError: false }, + )) as { success: boolean; error?: string; status?: number }; + assert.equal(r.success, false); + assert.equal(r.status, 401); }); -test('the middleware sets the caller on the context and greet reads it via actionContext()', async () => { +test('an authenticated greet reads the caller off the session via actionContext()', async (t) => { const app = await createRequestHandler({ appDir, dev: true }); if (app.warmup) await app.warmup(); - // invokeActionForTest returns the deserialized result as unknown; cast to the - // action's ActionResult shape to read it. + + // Real signup through the auth card's page action, then a real login to capture + // the signed session cookie. Both hit the users table, so skip until the db is + // migrated (run db:generate + db:migrate) rather than fail misleadingly. + const email = `greet+${Date.now()}@example.com`; + const password = 'password123'; + const signupRes = await testRequest(app.handle, '/features/auth/signup', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ name: 'Ada', email, password }).toString(), + }); + if (signupRes.status !== 302) { t.skip('app deps/db not ready; run db:generate + db:migrate'); return; } + const { cookies } = await loginAndGetCookies(app.handle, { email, password }); + + // With the session cookie the middleware sets the caller, and greet reads it. const r = (await invokeActionForTest( - app, - 'modules/server-actions/actions/greet.server.ts', - 'greet', - [{ name: 'Bob' }], - )) as { success: boolean; data?: { message: string }; error?: string; status?: number }; + app, GREET, 'greet', [{ name: 'Bob' }], { extraCookies: cookies }, + )) as { success: boolean; data?: { message: string } }; assert.equal(r.success, true); - // The message carries BOTH the input (Bob) and the middleware-set caller (Ada). + // The message carries BOTH the input (Bob) and the session caller (Ada). assert.match(r.data?.message ?? '', /BOB/); assert.match(r.data?.message ?? '', /Ada/); }); - -test('the auth middleware short-circuits a signed-out request before greet runs', async () => { - const app = await createRequestHandler({ appDir, dev: true }); - if (app.warmup) await app.warmup(); - // A middleware short-circuit rides as a normal failure envelope (200 with the - // status inside), so read the result rather than expecting a thrown non-2xx. - const r = (await invokeActionForTest( - app, - 'modules/server-actions/actions/greet.server.ts', - 'greet', - [{ name: 'Bob', signedOut: true }], - { throwOnError: false }, - )) as { success: boolean; data?: { message: string }; error?: string; status?: number }; - assert.equal(r.success, false); - assert.equal(r.status, 401); -}); 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 eaee556c..3f6a8947 100644 --- a/packages/cli/templates/gallery/modules/server-actions/components/greeter.ts +++ b/packages/cli/templates/gallery/modules/server-actions/components/greeter.ts @@ -5,14 +5,14 @@ import { greet } from '../actions/greet.server.ts'; export class Greeter extends WebComponent { private msg = signal(''); - // Drives the requireAuth middleware on the action: when true the request is - // treated as signed-out, so the middleware 401s BEFORE greet() runs. - private signedOut = signal(false); async run(e: SubmitEvent) { e.preventDefault(); const name = String(new FormData(e.target as HTMLFormElement).get('name') ?? ''); - const r = await greet({ name, signedOut: this.signedOut.get() }); + // The action's requireAuth middleware reads the real session off the request. + // Signed out, this comes back as a 401 failure envelope ("Sign in to + // continue."); sign in at /features/auth/login and the greeting succeeds. + const r = await greet({ name }); // Narrow on r.success so TS knows `data` (success) vs `error` (failure). A // middleware short-circuit arrives here as a normal failure envelope. this.msg.set(r.success ? (r.data?.message ?? '') : (r.error ?? 'error')); @@ -27,10 +27,7 @@ export class Greeter extends WebComponent { - +

The action is gated by requireAuth. Sign in to greet; signed out returns a real 401.

${this.msg.get() ? html`

${this.msg.get()}

` : ''}
`; diff --git a/packages/cli/templates/gallery/modules/server-actions/middleware/require-auth.server.ts b/packages/cli/templates/gallery/modules/server-actions/middleware/require-auth.server.ts index 2b7d0edd..c5cf3af1 100644 --- a/packages/cli/templates/gallery/modules/server-actions/middleware/require-auth.server.ts +++ b/packages/cli/templates/gallery/modules/server-actions/middleware/require-auth.server.ts @@ -7,10 +7,12 @@ // .server.ts with NO 'use server'): the action imports it server-side; it never // ships to the browser. import type { ActionResult } from '@webjsdev/server'; +import { getCurrentUser } from '#modules/auth/auth.server.ts'; export interface AuthUser { id: string; - name: string; + name?: string | null; + email?: string; } // The context object the framework passes each middleware. `context` is the @@ -24,17 +26,16 @@ interface ActionMiddlewareCtx { } export async function requireAuth(ctx: ActionMiddlewareCtx, next: () => Promise): Promise { - // A REAL guard reads the signed session or JWT off ctx.request, because auth - // belongs to the request, not the payload. This gallery has no login backend on - // the action path, so to keep BOTH branches exercisable the demo treats the - // caller as signed in UNLESS the request asks to simulate a signed-out visitor - // (the checkbox in the component sends { signedOut: true } in the action input). - const [input] = ctx.args as [{ signedOut?: boolean } | undefined]; - const user: AuthUser | null = input?.signedOut ? null : { id: 'u_1', name: 'Ada' }; + // A REAL guard reads the signed session off the request, because auth belongs + // to the request, not the payload. The RPC POST is same-origin, so the auth + // cookie rides along and getCurrentUser(ctx.request) reads it (this uses the + // auth gallery card's createAuth config; sign in at /features/auth/login). + const user = (await getCurrentUser(ctx.request)) as AuthUser | null; // Short-circuit: return an ActionResult WITHOUT calling next(), so the action // never runs. On the RPC boundary the short-circuit rides as the result with its - // status inside the envelope, and a denied call is served no-store (never cached). + // status inside the envelope, and a denied call is served no-store (never + // cached). An anonymous caller is genuinely denied here. if (!user) return { success: false, error: 'Sign in to continue.', status: 401 } satisfies ActionResult; ctx.context.user = user; // what actionContext().user reads inside the action From 647cb991dae9f5379ab9f55f1d335f0665a41ce8 Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 22 Jul 2026 12:39:27 +0530 Subject: [PATCH 05/18] test(scaffold): retarget saas suites to the full-stack auth card Drop saas from the template lists (gallery-coverage, runtime, template validation), retarget the auth + dashboard + current-user assertions to the full-stack auth card's new paths (app/features/auth, modules/auth), and delete the obsolete saas ui-hygiene test plus the now-orphaned lean-copy.js helper. --- packages/cli/lib/lean-copy.js | 43 ------ test/scaffolds/gallery-coverage.test.js | 2 +- test/scaffolds/scaffold-gallery.test.js | 44 +++--- test/scaffolds/scaffold-integration.test.js | 141 +++++++----------- test/scaffolds/scaffold-runtime.test.js | 9 +- .../scaffold-template-validation.test.js | 1 - .../scaffolds/scaffold-ui-integration.test.js | 133 +---------------- test/service-worker/sw.test.mjs | 4 +- 8 files changed, 89 insertions(+), 288 deletions(-) delete mode 100644 packages/cli/lib/lean-copy.js diff --git a/packages/cli/lib/lean-copy.js b/packages/cli/lib/lean-copy.js deleted file mode 100644 index fe260d8d..00000000 --- a/packages/cli/lib/lean-copy.js +++ /dev/null @@ -1,43 +0,0 @@ -/** - * The scaffold's lean-copy of a ui component (#983). - * - * `webjs create` copies a few `@webjsdev/ui` registry components into a - * generated app. To match what `webjs ui add` writes, a Tier-1 helper's worked - * `@example` is stripped (the example is served on demand by `webjs ui view` / - * the MCP `ui` tool), while a Tier-2 element file is kept whole. Both scaffold - * copiers (`create.js` and `saas-template.js`) go through THIS one helper so - * they cannot drift. - * - * The strip primitives live in `@webjsdev/ui/registry/extract`; if that subpath - * cannot be resolved, this degrades to a no-op (keep the example) so the strip - * is never a reason `webjs create` fails. - * - * @module lean-copy - */ - -let _mod = null; - -async function loadPrimitives() { - if (_mod) return _mod; - try { - const m = await import('@webjsdev/ui/registry/extract'); - _mod = { stripExample: m.stripExample, isCustomElementSource: m.isCustomElementSource }; - } catch { - _mod = { stripExample: (s) => s, isCustomElementSource: () => true }; - } - return _mod; -} - -/** - * Return the component source as `webjs ui add` would write it: a Tier-1 helper - * has its worked `@example` stripped and a pointer left; a Tier-2 element is - * returned unchanged. - * - * @param {string} source the component source (imports already rewritten) - * @param {string} name the component name (for the pointer) - * @returns {Promise} - */ -export async function leanComponentSource(source, name) { - const { stripExample, isCustomElementSource } = await loadPrimitives(); - return isCustomElementSource(source) ? source : stripExample(source, name); -} diff --git a/test/scaffolds/gallery-coverage.test.js b/test/scaffolds/gallery-coverage.test.js index 3016b4c9..6ce6d462 100644 --- a/test/scaffolds/gallery-coverage.test.js +++ b/test/scaffolds/gallery-coverage.test.js @@ -182,7 +182,7 @@ function scaffoldAnalysis() { } }; try { - for (const t of ['full-stack', 'api', 'saas']) { + for (const t of ['full-stack', 'api']) { // scaffoldApp(name, parentDir) writes parentDir/name. await scaffoldApp(t, base, { template: t, install: false }); walk(join(base, t)); diff --git a/test/scaffolds/scaffold-gallery.test.js b/test/scaffolds/scaffold-gallery.test.js index 11aae9e0..ce991f30 100644 --- a/test/scaffolds/scaffold-gallery.test.js +++ b/test/scaffolds/scaffold-gallery.test.js @@ -7,8 +7,8 @@ * placeholder-marker gate was retired: `npm run gallery:clear` sheds the gallery * in one step instead. * - * Also guards the scoping decision: api has no UI, so the gallery ships in every - * UI template (full-stack AND saas). + * Also guards the scoping decision: api has no UI, so the gallery ships in the + * one UI template (full-stack). Auth is one of the gallery cards. * * The todo query assertion is a regression guard for the rc.3 relational-query * orderBy bug (`[desc(col)]` compiles to `no such column: d0.0`); the query must @@ -25,7 +25,7 @@ import { scaffoldApp } from '../../packages/cli/lib/create.js'; // Single-feature demos under app/features/. const FEATURES = [ - 'routing', 'boundaries', 'components', 'server-actions', 'optimistic-ui', + 'routing', 'boundaries', 'auth', 'components', 'server-actions', 'optimistic-ui', 'async-render', 'streaming', 'suspense', 'directives', 'route-handler', 'forms', 'metadata', 'caching', 'env', 'client-router', 'view-transitions', 'frames', 'service-worker', 'websockets', 'broadcast', 'rate-limit', 'file-storage', 'sessions', @@ -35,7 +35,7 @@ const EXAMPLE_APPS = ['todo']; // Demos whose logic lives in a modules/ folder, spot-checked here. The // app-only demos (routing / metadata / env / boundaries / view-transitions, // which have no modules/ dir) are excluded. -const MODULE_ROUTES = ['components', 'server-actions', 'optimistic-ui', 'async-render', 'streaming', 'suspense', 'directives', 'frames', 'todo', 'websockets', 'broadcast', 'rate-limit', 'file-storage', 'sessions']; +const MODULE_ROUTES = ['auth', 'components', 'server-actions', 'optimistic-ui', 'async-render', 'streaming', 'suspense', 'directives', 'frames', 'todo', 'websockets', 'broadcast', 'rate-limit', 'file-storage', 'sessions']; async function tempCwd() { return mkdtemp(join(tmpdir(), 'webjs-scaffold-gallery-')); @@ -201,29 +201,27 @@ test('the api template ships the backend-features showcase, not the UI gallery', } }); -test('the saas template ships the gallery AND keeps its auth example', async () => { +test('the full-stack auth card wires a real, protected auth baseline', async () => { const cwd = await tempCwd(); try { - await scaffoldApp('demo', cwd, { template: 'saas' }); + await scaffoldApp('demo', cwd, { template: 'full-stack' }); const appDir = join(cwd, 'demo'); - // The full webjs feature gallery ships below the auth landing. - for (const name of FEATURES) { - assert.ok(await exists(join(appDir, 'app', 'features', name, 'page.ts')), `saas app/features/${name}/page.ts`); - } - assert.ok(await exists(join(appDir, 'app', 'examples', 'todo', 'page.ts')), 'saas todo example'); - assert.ok(await exists(join(appDir, 'modules', 'todo')), 'saas todo module'); - // The saas schema carries BOTH the auth users table and the gallery todos table. + // The auth card ships login, signup, and a protected dashboard subtree. + assert.ok(await exists(join(appDir, 'app', 'features', 'auth', 'login', 'page.ts')), 'auth login page'); + assert.ok(await exists(join(appDir, 'app', 'features', 'auth', 'signup', 'page.ts')), 'auth signup page'); + assert.ok(await exists(join(appDir, 'app', 'features', 'auth', 'dashboard', 'page.ts')), 'protected dashboard page'); + assert.ok(await exists(join(appDir, 'app', 'features', 'auth', 'dashboard', 'middleware.ts')), 'the dashboard gate'); + // The createAuth handler stays at the app root (createAuth hardcodes /api/auth/*). + assert.ok(await exists(join(appDir, 'app', 'api', 'auth', '[...path]', 'route.ts')), 'auth api handler at root'); + // The auth server modules: createAuth config, hashing, signup, current-user. + assert.ok(await exists(join(appDir, 'modules', 'auth', 'auth.server.ts')), 'createAuth config module'); + assert.ok(await exists(join(appDir, 'modules', 'auth', 'password.server.ts')), 'password hashing module'); + // The users table carries the auth passwordHash column. const schema = await readFile(join(appDir, 'db', 'schema.server.ts'), 'utf8'); - assert.match(schema, /table\('users'/, 'saas keeps the auth users table'); - assert.match(schema, /passwordHash/, 'saas users table keeps the auth column'); - assert.match(schema, /table\('todos'/, 'saas adds the gallery todos table'); - // The auth example is intact. - assert.ok(await exists(join(appDir, 'app', 'login', 'page.ts')), 'saas keeps login'); - assert.ok(await exists(join(appDir, 'app', 'dashboard', 'page.ts')), 'saas keeps the dashboard'); - // The saas home shows both the auth CTAs and the gallery. - const home = await readFile(join(appDir, 'app', 'page.ts'), 'utf8'); - assert.match(home, /\/login/, 'saas home keeps the auth CTA'); - assert.match(home, /\/features\/routing/, 'saas home links the gallery'); + assert.match(schema, /table\('users'/, 'the users table'); + assert.match(schema, /passwordHash/, 'users table carries the auth column'); + // The real auth-flow test ships with the card. + assert.ok(await exists(join(appDir, 'test', 'auth', 'auth.test.ts')), 'the auth flow test ships'); } finally { await rm(cwd, { recursive: true, force: true }); } diff --git a/test/scaffolds/scaffold-integration.test.js b/test/scaffolds/scaffold-integration.test.js index eb6f0ca8..0ed64988 100644 --- a/test/scaffolds/scaffold-integration.test.js +++ b/test/scaffolds/scaffold-integration.test.js @@ -1,11 +1,11 @@ /** - * Integration tests for `scaffoldApp`: invokes the full-stack, api, and - * saas scaffolds programmatically in a temp dir and asserts the expected - * files / directory structure are produced. Runs entirely offline. + * Integration tests for `scaffoldApp`: invokes the full-stack and api scaffolds + * programmatically in a temp dir and asserts the expected files / directory + * structure are produced. Runs entirely offline. * - * This is a coverage anchor for `packages/cli/lib/create.js` and - * `packages/cli/lib/saas-template.js`: both files are otherwise only - * exercised by manual `webjs create` runs. + * This is a coverage anchor for `packages/cli/lib/create.js` (including the auth + * gallery card it wires into the UI template), otherwise only exercised by manual + * `webjs create` runs. */ import { test } from 'node:test'; @@ -84,8 +84,8 @@ test('scaffoldApp full-stack: writes the canonical full-stack app layout', async } // #271: the opt-in progressive-enhancement service worker + its offline - // fallback ship into the UI scaffolds (full-stack / saas; api has no UI), - // dormant until the app registers it. This test covers full-stack. + // fallback ship into the UI scaffold (full-stack; api has no UI), + // dormant until the app registers it. assert.ok(existsSync(join(appDir, 'public', 'sw.js')), 'public/sw.js should exist'); assert.ok(existsSync(join(appDir, 'public', 'offline.html')), 'public/offline.html should exist'); @@ -428,120 +428,91 @@ test('scaffoldApp api: writes API-only template (no layout, no components)', asy } }); -test('scaffoldApp saas: writes auth + dashboard + Drizzle User model', async () => { +test('scaffoldApp full-stack: the auth card wires createAuth + dashboard + User model', async () => { const cwd = await tempCwd(); const restore = muteConsole(); try { - await scaffoldApp('my-saas', cwd, { template: 'saas' }); - const appDir = join(cwd, 'my-saas'); + await scaffoldApp('my-app', cwd, { template: 'full-stack' }); + const appDir = join(cwd, 'my-app'); - // Core scaffold still in place + // Core scaffold in place assert.ok(existsSync(join(appDir, 'app', 'layout.ts')), 'layout.ts written'); assert.ok(existsSync(join(appDir, 'app', 'page.ts')), 'page.ts written'); - // saas shares the same minimal-shell root layout (bare full-height main, no - // header), and designs its own chrome (the dashboard sub-nav lives in - // app/dashboard/layout.ts). - const saasLayoutSrc = readFileSync(join(appDir, 'app', 'layout.ts'), 'utf8'); - assert.match(saasLayoutSrc, /
/, - 'saas root layout is the minimal full-height shell'); - assert.ok(!existsSync(join(appDir, 'LAYOUT-REFERENCE.md')), - 'saas ships no LAYOUT-REFERENCE.md (removed)'); - - // #271: saas is a UI scaffold, so it ships the opt-in service worker. - assert.ok(existsSync(join(appDir, 'public', 'sw.js')), 'saas ships public/sw.js'); - assert.ok(existsSync(join(appDir, 'public', 'offline.html')), 'saas ships public/offline.html'); - - // SaaS-specific lib files - assert.ok(existsSync(join(appDir, 'lib', 'password.server.ts')), 'lib/password.server.ts present'); - assert.ok(existsSync(join(appDir, 'lib', 'auth.server.ts')), 'lib/auth.server.ts present'); - assert.ok(!existsSync(join(appDir, 'lib', 'prisma.server.ts')), 'no lib/prisma.server.ts'); + // The UI template ships the opt-in service worker (#271). + assert.ok(existsSync(join(appDir, 'public', 'sw.js')), 'ships public/sw.js'); + assert.ok(existsSync(join(appDir, 'public', 'offline.html')), 'ships public/offline.html'); - // The copied ui-* components import cn() via the #lib/utils/cn.ts alias, not - // a stale relative `../lib/utils.ts` that would ERR_MODULE_NOT_FOUND from - // components/ui/ (saas-template's readUiComponent rewrite, #556). The cn - // helper itself lives at lib/utils/cn.ts. Counterfactual: the no-op rewrite - // bug left `'../lib/utils.ts'` and this fails. - assert.ok(existsSync(join(appDir, 'lib', 'utils', 'cn.ts')), 'cn helper at lib/utils/cn.ts'); - for (const c of readdirSync(join(appDir, 'components', 'ui'))) { - 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`); - } - } + // Auth server modules (the auth gallery card). createAuth config + hashing + // live under modules/auth/ so copyGallery ships them and gallery:clear prunes + // them; the handler route stays at the app root (createAuth hardcodes /api/auth). + assert.ok(existsSync(join(appDir, 'modules', 'auth', 'password.server.ts')), 'modules/auth/password.server.ts present'); + assert.ok(existsSync(join(appDir, 'modules', 'auth', 'auth.server.ts')), 'modules/auth/auth.server.ts present'); + assert.ok(existsSync(join(appDir, 'app', 'api', 'auth', '[...path]', 'route.ts')), 'auth api handler at the app root'); - // #877: lib/auth.server.ts must not assign `process.env.AUTH_SECRET` + // #877/#556: 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'); + const authSrc = readFileSync(join(appDir, 'modules', 'auth', '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'); + assert.match(authSrc, /createAuth, Credentials, GitHub, Google/, 'demonstrates the createAuth provider surface'); - // #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. + // #878: each auth page carries EXACTLY one

(axe page-has-heading-one + // wants one, and a second h1 is its own violation). Counterfactual: an + //

-only page, or a double-h1 dashboard, fails this. const h1Count = (src) => (src.match(/`); + const pageSrc = readFileSync(join(appDir, 'app', 'features', 'auth', ...p, 'page.ts'), 'utf8'); + assert.equal(h1Count(pageSrc), 1, `auth/${p.join('/')} page has exactly one

`); } - // Drizzle User model (saas overwrites db/schema.server.ts to add passwordHash) + // The users table carries the auth passwordHash column (create.js adds it in + // the UI template; gallery:clear strips it back out). const schema = readFileSync(join(appDir, 'db', 'schema.server.ts'), 'utf8'); assert.match(schema, /export const users = table\('users'/, 'users table present'); - assert.match(schema, /passwordHash/, 'User has passwordHash field'); + assert.match(schema, /passwordHash/, 'users table carries the auth passwordHash column'); // Signup page is the canonical no-JS form write-path (#244): it exports a // page `action`, posts via `
`, and returns fieldErrors // + values on failure so the re-render keeps the user's input. - const signup = readFileSync(join(appDir, 'app', 'signup', 'page.ts'), 'utf8'); + const signup = readFileSync(join(appDir, 'app', 'features', 'auth', 'signup', 'page.ts'), 'utf8'); assert.match(signup, /export async function action/, 'signup page exports an action'); assert.match(signup, / to the createAuth signout route, - // so logout works with JS off (progressive-enhancement default) and appears on - // every /dashboard page. - const dashLayout = readFileSync(join(appDir, 'app', 'dashboard', 'layout.ts'), 'utf8'); + // so logout works with JS off (progressive-enhancement default). + const dashLayout = readFileSync(join(appDir, 'app', 'features', 'auth', 'dashboard', 'layout.ts'), 'utf8'); assert.match(dashLayout, / { - // The saas auth read is per-user, so it deliberately is NOT a cacheable GET - // server action: it ships as the documented counter-example. Counterfactual: - // add `export const method = 'GET'` to saas-template's current-user and this - // fails (which is exactly the data-leak the comment warns against). +test('scaffoldApp full-stack: per-session current-user stays POST-default (#488)', async () => { + // The auth-card current-user read is per-user, so it deliberately is NOT a + // cacheable GET server action: it ships as the documented counter-example. + // Counterfactual: add `export const method = 'GET'` to the auth card's + // current-user and this fails (the exact data-leak the comment warns against). const cwd = await tempCwd(); const restore = muteConsole(); try { - await scaffoldApp('verb-saas', cwd, { template: 'saas' }); - const appDir = join(cwd, 'verb-saas'); + await scaffoldApp('verb-app', cwd, { template: 'full-stack' }); + const appDir = join(cwd, 'verb-app'); const currentUser = readFileSync( join(appDir, 'modules', 'auth', 'queries', 'current-user.server.ts'), 'utf8'); assert.doesNotMatch(currentUser, /export const method =/, 'current-user is not a verb-configured GET'); - assert.match(currentUser, /per-session read/, 'current-user explains why it stays POST-default'); + assert.match(currentUser, /per-session/, 'current-user explains why it stays POST-default'); } finally { restore(); await rm(cwd, { recursive: true, force: true }); diff --git a/test/scaffolds/scaffold-runtime.test.js b/test/scaffolds/scaffold-runtime.test.js index 658e113b..aa6e57a2 100644 --- a/test/scaffolds/scaffold-runtime.test.js +++ b/test/scaffolds/scaffold-runtime.test.js @@ -143,8 +143,8 @@ test('bun scaffold: agent-config markdown shows bun commands, no npm commands', } }); -test('bun scaffold works across all three templates', async () => { - for (const template of ['full-stack', 'api', 'saas']) { +test('bun scaffold works across both templates', async () => { + for (const template of ['full-stack', 'api']) { const cwd = await tempCwd(); const restore = mute(); try { @@ -155,11 +155,6 @@ test('bun scaffold works across all three templates', async () => { const df = read(appDir, 'Dockerfile'); assert.match(df, /FROM oven\/bun:1/, `${template}: pure oven/bun base`); assert.match(df, /CMD \["bun", "--bun", "run", "start"\]/, `${template}: serves on bun`); - // saas generates an auth test whose setup comments are bun-ified. - if (template === 'saas') { - const authTest = read(appDir, 'test/auth/auth.test.ts'); - assert.doesNotMatch(authTest, /\bnpm run /, 'saas auth test comments bun-ified'); - } } finally { restore(); await rm(cwd, { recursive: true, force: true }); diff --git a/test/scaffolds/scaffold-template-validation.test.js b/test/scaffolds/scaffold-template-validation.test.js index b2a0b19e..c633064e 100644 --- a/test/scaffolds/scaffold-template-validation.test.js +++ b/test/scaffolds/scaffold-template-validation.test.js @@ -39,7 +39,6 @@ test('scaffoldApp error message mentions the valid templates', async () => { } catch (err) { assert.match(err.message, /full-stack/); assert.match(err.message, /api/); - assert.match(err.message, /saas/); } } finally { await rm(cwd, { recursive: true, force: true }); diff --git a/test/scaffolds/scaffold-ui-integration.test.js b/test/scaffolds/scaffold-ui-integration.test.js index 9270a151..571af53b 100644 --- a/test/scaffolds/scaffold-ui-integration.test.js +++ b/test/scaffolds/scaffold-ui-integration.test.js @@ -1,16 +1,9 @@ /** - * Verifies that the full-stack and saas scaffolds pre-initialise the Webjs UI - * kit correctly: components.json + lib/utils/cn.ts + styles/globals.css are - * written, the standard component sources land in components/ui/, generated - * pages call the Tier-1 class helpers on raw native elements (not stale - * `` tags for Tier-1 components), and the API template deliberately - * ships none of that. - * - * The "no stale Tier-1 tags" assertion catches a real regression class - - * before the Tier-1/Tier-2 split, scaffolded pages used ``, - * ``, etc. as custom elements. After the split, those Tier-1 - * components are class helpers (`buttonClass()`, `cardClass()`); a tag - * like `` would render as un-upgraded HTML. + * Verifies that the full-stack scaffold pre-initialises the Webjs UI kit + * correctly: components.json + lib/utils/cn.ts + styles/globals.css are written + * so the app is ready for `webjs ui add`, but no component kit is pre-copied + * (components are added on demand). The API template deliberately ships none of + * that. */ import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -21,28 +14,6 @@ import { tmpdir } from 'node:os'; import { scaffoldApp } from '../../packages/cli/lib/create.js'; -// Tier 1: class helpers. Pages MUST call e.g. `buttonClass()` and apply -// to a native

Backend (API) -

Route handlers + Drizzle

+

Route handlers + ORM

A backend-only app, no UI or SSR. File-based route handlers, modules, middleware, rate limiting, WebSockets, a Drizzle database, and a backend-features gallery.

app/api/users/route.ts
 app/api/chat/route.ts

From 760644bcdcede4c9b791c6bf4447bb39c8c5e227 Mon Sep 17 00:00:00 2001
From: Vivek 
Date: Wed, 22 Jul 2026 14:26:59 +0530
Subject: [PATCH 14/18] docs: drop Drizzle from the website backend card body
 (neutral framing)

---
 website/app/page.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/website/app/page.ts b/website/app/page.ts
index a2dbd5ab..ffbe08c5 100644
--- a/website/app/page.ts
+++ b/website/app/page.ts
@@ -368,7 +368,7 @@ actions/posts.server.ts
Backend (API)

Route handlers + ORM

-

A backend-only app, no UI or SSR. File-based route handlers, modules, middleware, rate limiting, WebSockets, a Drizzle database, and a backend-features gallery.

+

A backend-only app, no UI or SSR. File-based route handlers, modules, middleware, rate limiting, WebSockets, a database, and a backend-features gallery.

app/api/users/route.ts
 app/api/chat/route.ts
 middleware.ts
From a385f3d65d89f5fb284e955f874801161e73d541 Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 22 Jul 2026 15:03:52 +0530 Subject: [PATCH 15/18] docs(scaffold): explain frames vs layouts in the frames gallery demo --- .../templates/gallery/app/features/frames/page.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/cli/templates/gallery/app/features/frames/page.ts b/packages/cli/templates/gallery/app/features/frames/page.ts index 5752ba06..2dba2a80 100644 --- a/packages/cli/templates/gallery/app/features/frames/page.ts +++ b/packages/cli/templates/gallery/app/features/frames/page.ts @@ -2,7 +2,13 @@ // link targeting its id, shipping zero component JS. It is WebJs's take on Turbo // Frames. Unlike the client router (which swaps the whole page's children when // you navigate to a DIFFERENT url), a frame refreshes just ONE sub-region in -// place. The filter links below live INSIDE the frame, so a click walks +// place. It is also NOT a layout: a layout (layout.ts) is server-rendered chrome +// that WRAPS a route subtree via ${children} and re-renders only as part of a +// navigation, so it answers "what structure wraps these routes". A frame answers +// "which region updates itself in place", with no navigation at all. Use a layout +// for shared chrome across routes; use a frame when one region (a filtered list, +// a paginated table, a tab panel) must refresh on its own without navigating. +// The filter links below live INSIDE the frame, so a click walks // closest('webjs-frame'), refetches THIS same page with the new ?status, and the // server returns ONLY the subtree (open the network tab // to see it). The router swaps that subtree in; everything outside the frame, @@ -40,7 +46,9 @@ export default function FramesExample({ searchParams }: { searchParams: Record
From cf154f722a4eb41d364e606428d18e19ccfd1e36 Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 22 Jul 2026 15:19:36 +0530 Subject: [PATCH 16/18] feat(scaffold): add a dedicated gallery card renderStream() / (element-level surgical DOM updates, #248) was only demoed as a secondary button inside the WebSockets card. Add a dedicated card at app/features/stream + modules/stream showing append / prepend / replace / remove / update by target id, registered in FEATURES, pruned by gallery:clear, and covered by the scaffold tests. --- packages/cli/lib/create.js | 1 + .../gallery/app/features/stream/page.ts | 45 +++++++++++ .../modules/stream/components/stream-demo.ts | 76 +++++++++++++++++++ .../cli/templates/scripts/clear-gallery.mjs | 2 +- test/scaffolds/scaffold-gallery.test.js | 4 +- 5 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 packages/cli/templates/gallery/app/features/stream/page.ts create mode 100644 packages/cli/templates/gallery/modules/stream/components/stream-demo.ts diff --git a/packages/cli/lib/create.js b/packages/cli/lib/create.js index baa73161..cd8d48f3 100644 --- a/packages/cli/lib/create.js +++ b/packages/cli/lib/create.js @@ -1286,6 +1286,7 @@ const FEATURES = [ { href: '/features/optimistic-ui', title: 'Optimistic UI', blurb: 'The imperative optimistic(signal, value, action) flip: instant update, automatic rollback on failure.' }, { href: '/features/async-render', title: 'Async render', blurb: 'A component that awaits server data in async render(), so the resolved value is in the first paint.' }, { href: '/features/streaming', title: 'Streaming actions', blurb: 'A use-server action that returns an async generator, streamed to the call site token by token with for await.' }, + { href: '/features/stream', title: 'Stream updates', blurb: 'The element: renderStream() applies surgical append / replace / remove DOM updates by target id, no region redraw.' }, { href: '/features/suspense', title: 'Suspense boundary', blurb: 'The element: a first-paint fallback for a SLOW component, with the resolved content streamed in.' }, { href: '/features/view-transitions', title: 'View transitions', blurb: 'The opt-in view-transition meta cross-fades a soft navigation, with a data-webjs-permanent element persisted across the swap.' }, { href: '/features/directives', title: 'Directives', blurb: 'The lit-html directive set: repeat for keyed lists, watch(signal) for a fine-grained node swap.' }, diff --git a/packages/cli/templates/gallery/app/features/stream/page.ts b/packages/cli/templates/gallery/app/features/stream/page.ts new file mode 100644 index 00000000..82a0ae58 --- /dev/null +++ b/packages/cli/templates/gallery/app/features/stream/page.ts @@ -0,0 +1,45 @@ +// Stream updates: the element + renderStream() (#248). It is the +// element-level DOM-update grammar (Turbo Streams parity): a self-applying +// element that clones its