create-pracht 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,7 +25,7 @@ npm run dev
25
25
  `onlyBuiltDependencies` for pnpm 10 or `allowBuilds` for pnpm 11. All adapters
26
26
  allow `esbuild`, Cloudflare also allows `workerd`, and Tailwind starters also
27
27
  allow `@tailwindcss/oxide`.
28
- - Seeds the pracht Claude Code skills into `.claude/skills/`, writes a `.mcp.json` registering the `pracht mcp` server, and writes `AGENTS.md` (yes-default prompt; all of it skipped with `--no-agent-tools`, which leaves a project with no agent files at all).
28
+ - Seeds five core pracht Claude Code skills into `.claude/skills/`, writes a `.mcp.json` registering the `pracht dev-mcp` server, and writes `AGENTS.md` (yes-default prompt; all of it skipped with `--no-agent-tools`, which leaves a project with no agent files at all). `--agent-tools=full` seeds the whole catalog instead; `pracht skills add <name>` installs the rest one at a time.
29
29
  - Initializes a git repository with an initial commit (skipped with `--no-git`, when git is unavailable, or when the target is already inside a repository).
30
30
  - `--dry-run` uses pinned fallback versions and does not require npm registry access.
31
31
 
@@ -49,7 +49,7 @@ node ./packages/start/bin/create-pracht.js my-app --adapter=node --no-tailwind -
49
49
  - `--router=manifest|pages` — choose the routing system (default: manifest).
50
50
  - `--template=minimal|tailwind` — non-interactive template selection; `minimal` is the default output, `tailwind` is minimal plus Tailwind CSS wiring.
51
51
  - `--tailwind` / `--no-tailwind` — enable or disable Tailwind CSS without going through the prompt.
52
- - `--agent-tools` / `--no-agent-tools` — seed the Claude Code skills, `.mcp.json`, and `AGENTS.md`/`CLAUDE.md` (or skip all of them) without going through the prompt.
52
+ - `--agent-tools[=core|full]` / `--no-agent-tools` — seed the Claude Code skills, `.mcp.json`, and `AGENTS.md`/`CLAUDE.md` (or skip all of them) without going through the prompt. `core` is the default.
53
53
  - `--no-git` — skip `git init` and the initial commit.
54
54
  - `--skip-install` — skip dependency installation.
55
55
  - `--yes`, `-y` — accept defaults (node adapter, manifest router, no Tailwind, agent tooling on) and skip all prompts.
@@ -66,8 +66,8 @@ node ./packages/start/bin/create-pracht.js my-app --adapter=node --no-tailwind -
66
66
  - `src/shells/public.tsx`
67
67
  - `src/api/health.ts` — serverful adapters only
68
68
  - `.gitignore`
69
- - `.claude/skills/<name>/SKILL.md` — the pracht agent skills (unless `--no-agent-tools`)
70
- - `.mcp.json` — registers the `pracht mcp` server for MCP clients (unless `--no-agent-tools`)
69
+ - `.claude/skills/<name>/SKILL.md` — the core pracht agent skills, or all of them with `--agent-tools=full` (unless `--no-agent-tools`)
70
+ - `.mcp.json` — registers the `pracht dev-mcp` server for MCP clients (unless `--no-agent-tools`)
71
71
  - `AGENTS.md` (plus a `CLAUDE.md` symlink pointing at it) — project conventions for coding
72
72
  agents (unless `--no-agent-tools`; `README.md` documents the same commands for humans)
73
73
 
@@ -92,7 +92,9 @@ Standalone pnpm scaffolds for every adapter include `pnpm-workspace.yaml` with
92
92
  the version-appropriate lifecycle policy. When the new app belongs to an
93
93
  ancestor pnpm workspace, that workspace owns the policy instead: the generated
94
94
  README and completion message list the exact entries to add and no nested
95
- workspace file is created.
95
+ workspace file is created. npm, yarn, and bun scaffolds get no
96
+ `pnpm-workspace.yaml` at all — those package managers ignore the file, so
97
+ writing it would leave a config in the repo that nothing in the repo reads.
96
98
 
97
99
  ## Generated Scripts
98
100
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pracht",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "Interactive and scriptable starter CLI for creating full-stack Preact apps with Pracht.",
5
5
  "keywords": [
6
6
  "pracht",
@@ -33,6 +33,9 @@
33
33
  "README.md"
34
34
  ],
35
35
  "type": "module",
36
+ "engines": {
37
+ "node": ">=22.18"
38
+ },
36
39
  "publishConfig": {
37
40
  "provenance": true
38
41
  },
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: add-auth
3
- version: 1.2.0
3
+ version: 2.1.0
4
4
  description: |
5
- Wire session-based auth into a pracht app: session utilities, auth middleware,
6
- login/logout/signup API routes, `<Form>` pages, and public vs. protected route
7
- groups.
5
+ Wire session-based auth into a pracht app with `@pracht/session`: encrypted
6
+ cookie sessions, gate middleware, login/logout/signup API routes, `<Form>`
7
+ pages, and public vs. protected route groups.
8
8
  Use for "add auth", "set up login", "wire authentication", "add session
9
9
  middleware", "I need users".
10
10
  allowed-tools:
@@ -20,8 +20,13 @@ allowed-tools:
20
20
  # Pracht Add Auth
21
21
 
22
22
  Stamps out the auth pattern documented in
23
- `examples/docs/src/routes/docs/recipes-auth.md`; the user replaces
24
- `verifyCredentials()` with a real DB lookup.
23
+ `examples/docs/src/routes/docs/recipes-auth.md`, on top of `@pracht/session`.
24
+ The user replaces `verifyCredentials()` with a real DB lookup.
25
+
26
+ Never hand-roll the session cookie. `@pracht/session` already handles
27
+ encryption, expiry inside the payload, secret rotation, the `Secure`/`HttpOnly`
28
+ attributes, and the 4 KB size ceiling — every one of which the hand-rolled
29
+ version got wrong.
25
30
 
26
31
  MCP: when the pracht MCP server is registered (docs/MCP.md), prefer its
27
32
  `inspect_routes`/`inspect_api`/`inspect_build`/`doctor`/`verify`/`generate_*`
@@ -33,162 +38,188 @@ config.
33
38
  Use `AskUserQuestion` for:
34
39
 
35
40
  1. **Flavor** — session cookie + email/password (default), magic link, or OAuth
36
- (out of scope: recommend a dedicated library).
41
+ (out of scope: keep `@pracht/session` for the session half and use
42
+ `arctic`/`openid-client`/the provider SDK for the protocol half).
37
43
  2. **Where credentials live** — an existing DB, or none yet? If none, run
38
44
  `/add-db` first.
39
- 3. **Cookie posture** — `SameSite=Lax` (default, recommended), `Strict`, or
45
+ 3. **Where sessions live** — in the cookie (default, no infrastructure, 4 KB
46
+ ceiling, no server-side logout) or in a store (KV/D1/Redis/Postgres).
47
+ 4. **Cookie posture** — `SameSite=Lax` (default, recommended), `Strict`, or
40
48
  `None` + token. See `/audit-csrf`.
41
49
 
42
- ## Step 2: Session utilities
50
+ ## Step 2: Install and define the storage
51
+
52
+ ```bash
53
+ pnpm add @pracht/session
54
+ ```
43
55
 
44
- `src/server/session.ts` — HMAC-signed cookie payload:
56
+ `src/server/session.ts`:
45
57
 
46
58
  ```ts
47
59
  import { serverEnv } from "@pracht/core/env/server";
60
+ import { createSessionStorage, type SessionRequestContext, type SessionStorage } from "@pracht/session";
48
61
 
49
- export interface Session {
62
+ export interface AppSession extends Record<string, unknown> {
50
63
  userId: string;
51
64
  email: string;
65
+ name: string;
66
+ notice: string;
52
67
  }
53
68
 
54
- export async function getSession(request: Request): Promise<Session | null> {
55
- const cookie = request.headers.get("cookie") ?? "";
56
- const match = cookie.match(/session=([^;]+)/);
57
- if (!match) return null;
58
- try {
59
- const [payload, signature] = match[1].split(".");
60
- if (!payload || !signature) return null;
61
- if (!(await verify(payload, signature))) return null;
62
- return JSON.parse(atob(payload));
63
- } catch {
64
- return null;
65
- }
69
+ export type SessionContext = SessionRequestContext<AppSession>;
70
+
71
+ let storage: SessionStorage<AppSession> | undefined;
72
+
73
+ // Built lazily, NOT at module scope. On Cloudflare Workers env bindings only
74
+ // exist per request, so reading `serverEnv` while the module evaluates throws
75
+ // and bricks the worker at import time.
76
+ export function sessions(): SessionStorage<AppSession> {
77
+ storage ??= createSessionStorage<AppSession>({
78
+ cookie: {
79
+ // Newest first: the first secret seals, all of them open.
80
+ secrets: [serverEnv.SESSION_SECRET as string],
81
+ // `__Host-` is browser-enforced (Secure, Path=/, host-only) and is
82
+ // validated at construction. Prefer it unless the cookie must be shared
83
+ // across subdomains.
84
+ name: "__Host-session",
85
+ maxAge: 60 * 60 * 24 * 7,
86
+ },
87
+ });
88
+ return storage;
66
89
  }
90
+ ```
67
91
 
68
- export async function createSessionCookie(session: Session): Promise<string> {
69
- const payload = btoa(JSON.stringify(session));
70
- const signature = await sign(payload);
71
- return `session=${payload}.${signature}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=604800`;
72
- }
92
+ Defaults you do not have to configure: `HttpOnly`, `SameSite=Lax`, `Path=/`,
93
+ AES-256-GCM with an HKDF-derived key, expiry sealed into the payload, and a
94
+ throw instead of an oversized cookie. `Secure` is on for every request except
95
+ plain http from localhost it fails closed, because a TLS-terminating proxy
96
+ makes a production request look like http.
73
97
 
74
- export function clearSessionCookie(): string {
75
- return "session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0";
76
- }
98
+ Expiry is absolute from the *last write*, and the middleware commits only when
99
+ the session changed. Add `rolling: true` to `createSessionStorage()` if the
100
+ app wants `maxAge` as an idle timeout instead; the cost is a `Set-Cookie` per
101
+ response and, with a store, a write per request.
77
102
 
78
- async function getKey(usage: "sign" | "verify"): Promise<CryptoKey> {
79
- // Read the secret INSIDE the function, never at module scope. On Cloudflare
80
- // Workers env bindings only exist per request — a module-level read (or
81
- // throw) bricks the worker at import time. `serverEnv` resolves correctly
82
- // per adapter (docs/ENV.md).
83
- const secret = serverEnv.SESSION_SECRET;
84
- if (!secret) throw new Error("SESSION_SECRET is required");
85
- return crypto.subtle.importKey(
86
- "raw",
87
- new TextEncoder().encode(secret),
88
- { name: "HMAC", hash: "SHA-256" },
89
- false,
90
- [usage],
91
- );
92
- }
103
+ For a store, pass `store: { get, set, delete }` — three methods over KV, D1,
104
+ Redis, or Postgres. `createMemorySessionStore()` is for tests only.
93
105
 
94
- async function sign(data: string): Promise<string> {
95
- const key = await getKey("sign");
96
- const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
97
- return btoa(String.fromCharCode(...new Uint8Array(sig)));
98
- }
106
+ Type `context.session` once, in `src/env.d.ts`:
99
107
 
100
- async function verify(data: string, signature: string): Promise<boolean> {
101
- let sig: Uint8Array;
102
- try {
103
- sig = Uint8Array.from(atob(signature), (c) => c.charCodeAt(0));
104
- } catch {
105
- return false;
108
+ ```ts
109
+ import type { SessionRequestContext } from "@pracht/session";
110
+ import type { AppSession } from "./server/session.ts";
111
+
112
+ declare module "@pracht/core" {
113
+ interface Register {
114
+ context: SessionRequestContext<AppSession>;
106
115
  }
107
- const key = await getKey("verify");
108
- return crypto.subtle.verify("HMAC", key, sig, new TextEncoder().encode(data));
109
116
  }
110
117
  ```
111
118
 
112
- - `crypto.subtle` works in Node 18+, Cloudflare Workers, and Vercel Edge.
113
- - Verification goes through `crypto.subtle.verify`, which compares in constant
114
- time. Never compare signature strings with `===` that leaks timing an
115
- attacker can use to forge signatures.
116
- - Keep `HttpOnly`, `SameSite=Lax`, and `Secure` on the cookie. Drop `Secure`
117
- only for plain-HTTP local dev, conditionalized on `NODE_ENV`.
119
+ ## Step 3: Middleware
120
+
121
+ Two factories, and the difference is the whole point:
118
122
 
119
- ## Step 3: Auth middleware
123
+ - `sessionMiddleware(storage)` loads `context.session`, never blocks
124
+ (an **Augmenter**, in `/audit-auth` terms).
125
+ - `requireSession(storage)` — loads **and gates**: pages redirect to
126
+ `loginPath`, API routes get `401` (a **Gate**).
120
127
 
121
- `src/middleware/auth.ts` — a **Gate**: it short-circuits with a redirect rather
122
- than merely augmenting context (see `/audit-auth` for that distinction).
128
+ `src/middleware/auth.ts`:
123
129
 
124
130
  ```ts
125
- import { redirect, type MiddlewareFn } from "@pracht/core";
126
- import { getSession } from "../server/session";
127
-
128
- export const middleware: MiddlewareFn = async ({ request, url }, next) => {
129
- const session = await getSession(request);
130
- if (!session) {
131
- const target = encodeURIComponent(url.pathname + url.search);
132
- return redirect(`/login?redirect=${target}`, { request });
133
- }
134
- request.headers.set("x-user-id", session.userId);
135
- request.headers.set("x-user-email", session.email);
136
- return next();
131
+ import type { MiddlewareFn } from "@pracht/core";
132
+ import { requireSession } from "@pracht/session";
133
+
134
+ import { sessions } from "../server/session.ts";
135
+
136
+ let gate: MiddlewareFn | undefined;
137
+
138
+ export const middleware: MiddlewareFn = (args, next) => {
139
+ gate ??= requireSession(sessions(), { loginPath: "/login" });
140
+ return gate(args, next);
137
141
  };
138
142
  ```
139
143
 
140
- ## Step 4: Auth API routes
144
+ Generate `src/middleware/session.ts` the same way with `sessionMiddleware` when
145
+ public routes or the API also need to read a session.
146
+
147
+ Both commit after `next()`, so a loader downstream can `context.session.set(…)`
148
+ and the cookie still lands on that loader's response. **Never** write user info
149
+ onto `args.request.headers` — the client controls those, and the incoming
150
+ `Request` is immutable on Cloudflare Workers.
151
+
152
+ ## Step 4: Password hashing
153
+
154
+ ```ts
155
+ // src/server/users.ts
156
+ import { hashPassword, verifyPassword } from "@pracht/session";
157
+
158
+ export async function verifyCredentials(email: string, password: string) {
159
+ const row = await db.users.findByEmail(email.trim().toLowerCase());
160
+ if (!row) return null;
161
+ return (await verifyPassword(password, row.passwordHash)) ? row : null;
162
+ }
163
+ ```
164
+
165
+ `hashPassword()` is PBKDF2-HMAC-SHA256 — the only password KDF WebCrypto
166
+ exposes, so it runs on every adapter. The stored string records its own
167
+ parameters, so the iteration count can be raised later without invalidating
168
+ existing hashes. Never store a plain `SHA-256(password)`. On Cloudflare
169
+ Workers, measure a login against the plan's CPU limit.
170
+
171
+ ## Step 5: Auth API routes
141
172
 
142
173
  `src/api/auth/login.ts`:
143
174
 
144
175
  ```ts
145
- import type { ApiRouteArgs } from "@pracht/core";
146
- import { createSessionCookie } from "../../server/session";
176
+ import { redirect, type ApiRouteArgs } from "@pracht/core";
177
+
178
+ import { sessions } from "../../server/session.ts";
179
+ import { verifyCredentials } from "../../server/users.ts";
147
180
 
148
181
  export async function POST({ request }: ApiRouteArgs) {
149
182
  const form = await request.formData();
150
- const email = String(form.get("email") ?? "").trim();
183
+ const email = String(form.get("email") ?? "");
151
184
  const password = String(form.get("password") ?? "");
152
- const requested = String(form.get("redirect") ?? "/dashboard");
153
185
 
154
186
  // The redirect field is user input — gate it, or this is an open redirect.
155
- const safeRedirect = requested.startsWith("/") && !requested.startsWith("//")
156
- ? requested
157
- : "/dashboard";
187
+ const requested = String(form.get("redirect") ?? "/dashboard");
188
+ const target =
189
+ requested.startsWith("/") && !requested.startsWith("//") ? requested : "/dashboard";
158
190
 
159
191
  const user = await verifyCredentials(email, password);
160
192
  if (!user) {
161
- // Redirect back with an error flag do NOT return a 401 JSON body.
162
- // Pracht's <Form> only acts on 3xx (it follows `location`); a non-redirect
163
- // response is silently ignored and the user sees nothing happen.
164
- const back = new URLSearchParams({ error: "1", redirect: safeRedirect });
165
- return new Response(null, { status: 302, headers: { location: `/login?${back}` } });
193
+ // `<Form>` only acts on 3xx (it follows `location`); a 401 JSON body is
194
+ // silently ignored and the user sees nothing happen.
195
+ return redirect(`/login?error=1&redirect=${encodeURIComponent(target)}`, { request });
166
196
  }
167
197
 
168
- const cookie = await createSessionCookie({ userId: user.id, email: user.email });
169
- return new Response(null, {
170
- status: 302,
171
- headers: { location: safeRedirect, "set-cookie": cookie },
172
- });
173
- }
174
-
175
- async function verifyCredentials(_email: string, _password: string) {
176
- // TODO: replace with a real DB lookup + password hash check (argon2 / bcrypt).
177
- return null as null | { id: string; email: string };
198
+ const storage = sessions();
199
+ const session = await storage.getSession(request);
200
+ // Session fixation: rotate the id on every privilege change, before writing
201
+ // the user. Without it, an attacker who planted a session cookie in the
202
+ // victim's browser still holds a pointer to the session that just became
203
+ // authenticated. Required with a store; harmless and future-proof without.
204
+ await session.regenerate();
205
+ session.set("userId", user.id);
206
+ session.set("email", user.email);
207
+ session.set("name", user.name);
208
+ session.flash("notice", `Welcome back, ${user.name}.`);
209
+
210
+ // `commit` appends Set-Cookie; it never replaces one already on the response.
211
+ return storage.commit(session, redirect(target, { request }));
178
212
  }
179
213
  ```
180
214
 
181
- `src/api/auth/logout.ts` — `POST` returning a 302 to `/` with
182
- `clearSessionCookie()` as `set-cookie`.
215
+ `src/api/auth/logout.ts` — `POST` returning
216
+ `storage.destroy(session, redirect("/", { request }))`. Never a `GET`.
183
217
 
184
218
  `src/api/auth/signup.ts` — same shape as login: validate (`email` present,
185
219
  `password.length >= 8`), redirect to `/signup?error=1` on failure, otherwise
186
- hash the password, insert the user, and issue `createSessionCookie()` with a
187
- 302 to `/dashboard`. Leave the hashing and insert as TODOs for the user.
188
-
189
- **Never ship the skeleton without real password hashing (argon2 or bcrypt).**
220
+ `hashPassword()`, insert the user, and issue the session.
190
221
 
191
- ## Step 5: Login and signup pages
222
+ ## Step 6: Login and signup pages
192
223
 
193
224
  `src/routes/login.tsx`:
194
225
 
@@ -196,9 +227,10 @@ hash the password, insert the user, and issue `createSessionCookie()` with a
196
227
  import { Form, type LoaderArgs, type RouteComponentProps } from "@pracht/core";
197
228
 
198
229
  export async function loader({ url }: LoaderArgs) {
230
+ const requested = url.searchParams.get("redirect") ?? "/dashboard";
199
231
  return {
200
- redirect: url.searchParams.get("redirect") ?? "/dashboard",
201
232
  error: url.searchParams.get("error") === "1",
233
+ redirect: requested.startsWith("/") && !requested.startsWith("//") ? requested : "/dashboard",
202
234
  };
203
235
  }
204
236
 
@@ -210,7 +242,7 @@ export function Component({ data }: RouteComponentProps<typeof loader>) {
210
242
  return (
211
243
  <section class="login">
212
244
  <h1>Log in</h1>
213
- {data.error && <p class="error">Invalid email or password.</p>}
245
+ {data.error && <p role="alert">Invalid email or password.</p>}
214
246
  <Form method="post" action="/api/auth/login">
215
247
  <input type="hidden" name="redirect" value={data.redirect} />
216
248
  <label>Email <input type="email" name="email" required /></label>
@@ -224,14 +256,25 @@ export function Component({ data }: RouteComponentProps<typeof loader>) {
224
256
 
225
257
  Generate `signup.tsx` analogously, posting to `/api/auth/signup`.
226
258
 
227
- ## Step 6: Wire the manifest
259
+ Protected loaders read the session, never a header:
260
+
261
+ ```ts
262
+ export async function loader({ context }: LoaderArgs<SessionContext>) {
263
+ return { user: context.session.get("name") ?? "", notice: context.session.get("notice") ?? null };
264
+ }
265
+ ```
266
+
267
+ `get()` on a flashed key is the read that consumes it.
268
+
269
+ ## Step 7: Wire the manifest
228
270
 
229
- Public routes in one group, protected routes in a group carrying the middleware:
271
+ Public routes in one group, protected routes in a group carrying the gate:
230
272
 
231
273
  ```ts
232
274
  export const app = defineApp({
233
275
  shells: { public: "./shells/public.tsx", app: "./shells/app.tsx" },
234
- middleware: { auth: "./middleware/auth.ts" },
276
+ middleware: { auth: "./middleware/auth.ts", session: "./middleware/session.ts" },
277
+ api: { middleware: ["session"] },
235
278
  routes: [
236
279
  group({ shell: "public" }, [
237
280
  route("/", "./routes/home.tsx", { render: "ssg" }),
@@ -245,17 +288,20 @@ export const app = defineApp({
245
288
  });
246
289
  ```
247
290
 
248
- If the project already has a `defineApp({...})`, merge into it — preserve the
249
- existing shells, middleware, and routes.
291
+ Anything that reads the session must be `render: "ssr"` its output is
292
+ per-visitor. If the project already has a `defineApp({...})`, merge into it and
293
+ preserve the existing shells, middleware, and routes.
250
294
 
251
- ## Step 7: Env
295
+ ## Step 8: Env
252
296
 
253
297
  Add `SESSION_SECRET=<generate with: openssl rand -base64 32>` to
254
- `.env.example`, and confirm `.env*` is gitignored.
298
+ `.env.example`, and confirm `.env*` is gitignored. Secrets shorter than 16
299
+ characters are rejected. To rotate, put the new secret first and keep the old
300
+ one for one release.
255
301
 
256
- ## Step 8: Verify
302
+ ## Step 9: Verify
257
303
 
258
- - `pracht typegen` (step 6 added routes); `pracht typegen --check` in CI.
304
+ - `pracht typegen` (step 7 added routes); `pracht typegen --check` in CI.
259
305
  - In `pracht dev`: `/dashboard` redirects to `/login?redirect=%2Fdashboard`; a
260
306
  successful login lands on `/dashboard`; a failed one lands back on
261
307
  `/login?error=1` with the message rendered; logout posts to
@@ -263,4 +309,10 @@ Add `SESSION_SECRET=<generate with: openssl rand -base64 32>` to
263
309
  - `pnpm test`, `pnpm e2e`, and `pracht verify --json` all pass.
264
310
  - Run `/audit-auth` and `/audit-csrf` to confirm the resulting posture.
265
311
 
312
+ ## Out of scope
313
+
314
+ OAuth/OIDC protocol handling, 2FA, password reset, email verification, rate
315
+ limiting on the login endpoint, and authorization (roles/permissions). Say so
316
+ rather than generating a weak version.
317
+
266
318
  $ARGUMENTS