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
+//
@@ -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`",
- "
",
- " `;",
- "}",
- "",
- ].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.
`;
}
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