create-pracht 0.6.3 → 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 +7 -5
- package/package.json +4 -1
- package/skills/add-auth/SKILL.md +173 -121
- package/skills/add-capabilities/SKILL.md +105 -96
- package/skills/add-observability/SKILL.md +12 -39
- package/skills/audit-agent-surface/SKILL.md +21 -16
- package/skills/audit-auth/SKILL.md +68 -12
- package/skills/audit-bundles/SKILL.md +1 -1
- package/skills/audit-shells/SKILL.md +12 -5
- package/skills/migrate-nextjs/SKILL.md +30 -10
- package/skills/pracht-scaffold/SKILL.md +11 -1
- package/skills/tune-render-mode/SKILL.md +36 -0
- package/skills/typed-routes/SKILL.md +3 -2
- package/skills/upgrade-pracht/SKILL.md +18 -8
- package/src/index.js +191 -60
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
|
|
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.
|
|
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
|
},
|
package/skills/add-auth/SKILL.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: add-auth
|
|
3
|
-
version: 1.
|
|
3
|
+
version: 2.1.0
|
|
4
4
|
description: |
|
|
5
|
-
Wire session-based auth into a pracht app
|
|
6
|
-
login/logout/signup API routes, `<Form>`
|
|
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
|
|
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:
|
|
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. **
|
|
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:
|
|
50
|
+
## Step 2: Install and define the storage
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pnpm add @pracht/session
|
|
54
|
+
```
|
|
43
55
|
|
|
44
|
-
`src/server/session.ts
|
|
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
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
|
122
|
-
than merely augmenting context (see `/audit-auth` for that distinction).
|
|
128
|
+
`src/middleware/auth.ts`:
|
|
123
129
|
|
|
124
130
|
```ts
|
|
125
|
-
import {
|
|
126
|
-
import {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
|
|
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
|
-
|
|
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
|
|
146
|
-
|
|
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") ?? "")
|
|
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
|
|
156
|
-
|
|
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
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
182
|
-
`
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
249
|
-
|
|
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
|
|
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
|
|
302
|
+
## Step 9: Verify
|
|
257
303
|
|
|
258
|
-
- `pracht typegen` (step
|
|
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
|