@pracht/cli 1.2.1 → 1.2.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,79 @@
1
1
  # @pracht/cli
2
2
 
3
+ ## 1.2.2
4
+
5
+ ### Patch Changes
6
+
7
+ - [#124](https://github.com/JoviDeCroock/pracht/pull/124) [`8f662c0`](https://github.com/JoviDeCroock/pracht/commit/8f662c0b78b1911a7534ffd7aa4e919cf22a3a42) Thanks [@JoviDeCroock](https://github.com/JoviDeCroock)! - Internal refactor: split several large modules into smaller, focused files to improve maintainability. Public APIs are unchanged.
8
+
9
+ - [#132](https://github.com/JoviDeCroock/pracht/pull/132) [`30d867f`](https://github.com/JoviDeCroock/pracht/commit/30d867f4a4cd41107a1ed60c607afe0d51848c3b) Thanks [@JoviDeCroock](https://github.com/JoviDeCroock)! - Follow-up security hardening after the main audit fixes.
10
+
11
+ - `@pracht/adapter-node` now supports `canonicalOrigin` so apps can pin
12
+ `request.url` to a known public origin instead of depending on untrusted
13
+ `Host` values. The adapter also treats both `x-pracht-route-state-request`
14
+ and `?_data=1` as route-state transports before any static/ISG HTML serving,
15
+ and ISG regeneration now uses a clean HTML request instead of replaying the
16
+ triggering user's cookies or authorization headers.
17
+ - `@pracht/adapter-cloudflare` now bypasses static asset serving for both
18
+ route-state transports (`x-pracht-route-state-request` and `?_data=1`).
19
+ - `@pracht/cli` now emits a Vercel Build Output rule that sends `?_data=1`
20
+ requests to the render function before static rewrites can serve prerendered
21
+ HTML.
22
+
23
+ - [#131](https://github.com/JoviDeCroock/pracht/pull/131) [`015e987`](https://github.com/JoviDeCroock/pracht/commit/015e987a2de471980fab557e3dbf3d52937ad0ac) Thanks [@JoviDeCroock](https://github.com/JoviDeCroock)! - Security hardening across request handling, redirects, and build output.
24
+
25
+ **Framework (`@pracht/core`)**
26
+
27
+ - **Middleware/loader redirects are now validated.** `javascript:`, `data:`,
28
+ `vbscript:`, `blob:`, and `file:` targets are refused server-side (they
29
+ were already refused on the client) and CR/LF in the `Location` value
30
+ throws instead of producing a split response. Non-safe-method redirects
31
+ now default to **303 See Other** rather than 302 so browsers don't
32
+ resend the POST body to the redirect target. `MiddlewareResult`'s
33
+ `redirect` form now accepts an optional `status` override.
34
+ - **CSRF protection for mutating API routes.** Non-GET API requests are
35
+ rejected with 403 unless the browser signals a same-origin/same-site
36
+ fetch (`Sec-Fetch-Site`) or the `Origin` header matches the request
37
+ URL's origin. Opt out per-app via `defineApp({ api: { requireSameOrigin: false } })`.
38
+ - **`_data=1` route-state bypass is now gated.** The query-param form of
39
+ the route-state endpoint now requires `Sec-Fetch-Site: same-origin`/
40
+ `same-site` (or a matching `Origin`). The explicit
41
+ `x-pracht-route-state-request` header is still accepted unconditionally
42
+ (CORS-protected).
43
+ - **Catch-all path traversal at build time is closed.**
44
+ `buildPathFromSegments` now percent-encodes catch-all components
45
+ individually and explicitly neutralises `.` / `..` segments, so a
46
+ `getStaticPaths` returning `{ "*": "../../etc/passwd" }` can no longer
47
+ escape `dist/client/` at SSG/ISG write time.
48
+ - **`headers()` values are validated for CR/LF.** `applyHeaders` now
49
+ throws a consistent framework error on response-splitting attempts,
50
+ regardless of adapter-specific Headers implementation behaviour.
51
+ - **`debugErrors` is ignored in production.** When `NODE_ENV=production`,
52
+ `debugErrors: true` is refused (with a one-shot console warning) so a
53
+ misconfigured deploy cannot leak stack traces and module paths.
54
+
55
+ **Adapter (`@pracht/adapter-node`)**
56
+
57
+ - **Symlinks are no longer followed by the static server.** `resolveStaticFile`
58
+ now uses `lstat` and rejects files whose inode is a symlink, preventing
59
+ a malicious build artifact from exposing files outside `dist/client/`.
60
+ - **ISG cache is path-contained.** The on-disk write path is now
61
+ `resolve()`-checked against the static root, rejecting any URL path
62
+ that would escape via `..`, encoded separators, or NUL bytes.
63
+ - **ISG skips the on-disk cache when the response is user-specific.**
64
+ Responses that set `Cache-Control: no-store`/`private`, `Set-Cookie`,
65
+ or a `Vary` covering `cookie`/`authorization`/`*` are served through
66
+ but not written to disk, closing a per-user cache-poisoning window.
67
+
68
+ **Packaging**
69
+
70
+ - `@pracht/cli` now has an explicit `files` allowlist so future
71
+ workdir additions can't accidentally ship in the npm tarball.
72
+ - `create-pracht`'s bin entry is now executable in the repository.
73
+
74
+ - Updated dependencies [[`caae3cb`](https://github.com/JoviDeCroock/pracht/commit/caae3cb53e0b6136ef78c3ac189a0d0ab82e4df7), [`8f662c0`](https://github.com/JoviDeCroock/pracht/commit/8f662c0b78b1911a7534ffd7aa4e919cf22a3a42), [`901ef5b`](https://github.com/JoviDeCroock/pracht/commit/901ef5b7958e4066d5382f836d098bded8bfe320), [`015e987`](https://github.com/JoviDeCroock/pracht/commit/015e987a2de471980fab557e3dbf3d52937ad0ac)]:
75
+ - @pracht/core@0.3.0
76
+
3
77
  ## 1.2.1
4
78
 
5
79
  ### Patch Changes
@@ -36,6 +36,14 @@ function createVercelOutputConfig({ functionName, headersManifest, staticRoutes,
36
36
  value: "1"
37
37
  }],
38
38
  src: "/(.*)"
39
+ }, {
40
+ dest: target,
41
+ has: [{
42
+ type: "query",
43
+ key: "_data",
44
+ value: "1"
45
+ }],
46
+ src: "/(.*)"
39
47
  }];
40
48
  for (const route of sortStaticRoutes(staticRoutes)) routes.push({
41
49
  dest: routeToStaticHtmlPath(route),
@@ -1,4 +1,4 @@
1
- import { t as runDoctor } from "./verification-CId4crSF.mjs";
1
+ import { t as runDoctor } from "./verification-Dwf1ZuNn.mjs";
2
2
  import { defineCommand } from "citty";
3
3
  //#region src/commands/doctor.ts
4
4
  var doctor_default = defineCommand({
@@ -1,7 +1,146 @@
1
- import { a as toManifestModulePath, i as insertArrayItem, n as extractRegistryEntries, o as upsertObjectEntry, t as ensureCoreNamedImport } from "./manifest-DGq1n5LT.mjs";
2
- import { _ as requireEnum, a as readProjectConfig, c as resolveProjectPath, d as writeGeneratedFile, f as ensureTrailingNewline, g as quote, h as parseCommaList, l as resolveRouteModulePath, m as parseApiMethods, n as displayPath, o as resolveApiModulePath, s as resolvePagesRouteModulePath, t as assertFileExists, u as resolveScopedFile, v as requirePositiveInteger } from "./project-DUyH_7eJ.mjs";
1
+ import { _ as requireEnum, a as readProjectConfig, c as resolveProjectPath, d as writeGeneratedFile, f as ensureTrailingNewline, g as quote, h as parseCommaList, l as resolveRouteModulePath, m as parseApiMethods, n as displayPath, o as resolveApiModulePath, s as resolvePagesRouteModulePath, t as assertFileExists, u as resolveScopedFile, v as requirePositiveInteger } from "./project-BdMiN3s7.mjs";
2
+ import { a as toManifestModulePath, i as insertArrayItem, n as extractRegistryEntries, o as upsertObjectEntry, t as ensureCoreNamedImport } from "./manifest-Bs5hp3gA.mjs";
3
3
  import { defineCommand } from "citty";
4
4
  import { readFileSync, writeFileSync } from "node:fs";
5
+ //#region src/commands/generate-paths.ts
6
+ function normalizeRoutePathString(value) {
7
+ if (!value || value === "/") return "/";
8
+ const normalized = `/${value}`.replace(/\/+/g, "/");
9
+ return normalized !== "/" && normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
10
+ }
11
+ function normalizeApiPath(value) {
12
+ return normalizeRoutePathString(value).replace(/^\/api(?=\/|$)/, "") || "/";
13
+ }
14
+ function hasDynamicSegments(routePath) {
15
+ return routePath.split("/").some((segment) => segment.startsWith(":") || segment === "*");
16
+ }
17
+ function dynamicParamNames(routePath) {
18
+ return routePath.split("/").filter(Boolean).map((segment) => {
19
+ if (segment.startsWith(":")) return segment.slice(1);
20
+ if (segment === "*") return "slug";
21
+ return null;
22
+ }).filter((s) => s !== null);
23
+ }
24
+ function routeIdFromPath(routePath) {
25
+ if (routePath === "/") return "index";
26
+ return routePath.split("/").filter(Boolean).map((segment) => segment.replace(/^:/, "").replace(/\*/g, "splat")).join("-");
27
+ }
28
+ function titleFromPath(routePath) {
29
+ if (routePath === "/") return "Home";
30
+ return titleCase((routePath.split("/").filter(Boolean).at(-1) ?? "Page").replace(/^:/, "").replace(/\*/g, "slug"));
31
+ }
32
+ function titleCase(value) {
33
+ return value.split(/[-_/]/).filter(Boolean).map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)).join(" ");
34
+ }
35
+ //#endregion
36
+ //#region src/commands/generate-source.ts
37
+ function buildManifestRouteModuleSource(opts) {
38
+ const sections = buildRouteModuleSections(opts);
39
+ const componentIdx = sections.findIndex((s) => s.startsWith("export function Component"));
40
+ const insertAt = componentIdx === -1 ? sections.length : componentIdx;
41
+ sections.splice(insertAt, 0, "export function head() {", ` return { title: ${quote(opts.title)} };`, "}", "");
42
+ return `${sections.join("\n")}\n`;
43
+ }
44
+ function buildPagesRouteModuleSource(opts) {
45
+ const sections = buildRouteModuleSections(opts);
46
+ const firstExportIdx = sections.findIndex((s) => s.startsWith("export"));
47
+ const insertAt = firstExportIdx === -1 ? sections.length : firstExportIdx;
48
+ sections.splice(insertAt, 0, `export const RENDER_MODE = ${quote(opts.render)};`, "");
49
+ return `${sections.join("\n")}\n`;
50
+ }
51
+ function buildShellModuleSource(name) {
52
+ const title = titleCase(name);
53
+ return [
54
+ "import type { ShellProps } from \"@pracht/core\";",
55
+ "",
56
+ "export function Shell({ children }: ShellProps) {",
57
+ " return (",
58
+ ` <div class=${quote(`${name}-shell`)}>`,
59
+ " <main>{children}</main>",
60
+ " </div>",
61
+ " );",
62
+ "}",
63
+ "",
64
+ "export function head() {",
65
+ ` return { title: ${quote(title)} };`,
66
+ "}",
67
+ ""
68
+ ].join("\n");
69
+ }
70
+ function buildMiddlewareModuleSource() {
71
+ return [
72
+ "import type { MiddlewareFn } from \"@pracht/core\";",
73
+ "",
74
+ "export const middleware: MiddlewareFn = async (_args) => {",
75
+ " return;",
76
+ "};",
77
+ ""
78
+ ].join("\n");
79
+ }
80
+ function buildApiRouteSource({ endpointPath, methods }) {
81
+ return [
82
+ "import type { BaseRouteArgs } from \"@pracht/core\";",
83
+ "",
84
+ ...methods.flatMap((method, index) => {
85
+ const lines = buildApiMethodSource(method, methods, endpointPath);
86
+ if (index === methods.length - 1) return lines;
87
+ return [...lines, ""];
88
+ }),
89
+ ""
90
+ ].join("\n");
91
+ }
92
+ function buildRouteModuleSections(opts) {
93
+ const { includeErrorBoundary, includeLoader, includeStaticPaths, routePath, title } = opts;
94
+ const params = dynamicParamNames(routePath);
95
+ const imports = [];
96
+ const sections = [];
97
+ if (includeLoader) imports.push("LoaderArgs", "RouteComponentProps");
98
+ if (includeErrorBoundary) imports.push("ErrorBoundaryProps");
99
+ if (imports.length > 0) {
100
+ sections.push(`import type { ${imports.join(", ")} } from "@pracht/core";`);
101
+ sections.push("");
102
+ }
103
+ if (includeLoader) sections.push("export async function loader(_args: LoaderArgs) {", ` return { message: ${quote(`Welcome to ${title}.`)} };`, "}", "");
104
+ if (includeStaticPaths) sections.push("export function getStaticPaths() {", ` return [${buildStaticPathsStub(params)}];`, "}", "");
105
+ if (includeLoader) sections.push("export function Component({ data }: RouteComponentProps<typeof loader>) {", " return (", " <section>", ` <h1>${escapeJsxText(title)}</h1>`, " <p>{data.message}</p>", " </section>", " );", "}");
106
+ else sections.push("export function Component() {", " return (", " <section>", ` <h1>${escapeJsxText(title)}</h1>`, " </section>", " );", "}");
107
+ if (includeErrorBoundary) sections.push("", "export function ErrorBoundary({ error }: ErrorBoundaryProps) {", " return <p>{error.message}</p>;", "}");
108
+ return sections;
109
+ }
110
+ function buildApiMethodSource(method, methods, endpointPath) {
111
+ if (method === "DELETE" || method === "HEAD") return [
112
+ `export function ${method}(_args: BaseRouteArgs) {`,
113
+ " return new Response(null, { status: 204 });",
114
+ "}"
115
+ ];
116
+ if (method === "OPTIONS") return [
117
+ `export function ${method}(_args: BaseRouteArgs) {`,
118
+ " return new Response(null, {",
119
+ ` headers: { allow: ${quote(methods.join(", "))} },`,
120
+ " status: 204,",
121
+ " });",
122
+ "}"
123
+ ];
124
+ if (method === "GET") return [
125
+ `export function ${method}(_args: BaseRouteArgs) {`,
126
+ ` return Response.json({ endpoint: ${quote(`/api${endpointPath}`)}, ok: true });`,
127
+ "}"
128
+ ];
129
+ const status = method === "POST" ? 201 : 200;
130
+ return [
131
+ `export async function ${method}({ request }: BaseRouteArgs) {`,
132
+ " const body = await request.json();",
133
+ ` return Response.json({ body, ok: true }, { status: ${status} });`,
134
+ "}"
135
+ ];
136
+ }
137
+ function buildStaticPathsStub(params) {
138
+ if (params.length === 0) return "{}";
139
+ return `{ ${params.map((name) => `${name}: ${quote(`example-${name}`)}`).join(", ")} }`;
140
+ }
141
+ function escapeJsxText(value) {
142
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
143
+ }
5
144
  var generate_default = defineCommand({
6
145
  meta: {
7
146
  name: "generate",
@@ -260,141 +399,5 @@ function generateApi(args, project) {
260
399
  updated: []
261
400
  };
262
401
  }
263
- function buildRouteModuleSections(opts) {
264
- const { includeErrorBoundary, includeLoader, includeStaticPaths, routePath, title } = opts;
265
- const params = dynamicParamNames(routePath);
266
- const imports = [];
267
- const sections = [];
268
- if (includeLoader) imports.push("LoaderArgs", "RouteComponentProps");
269
- if (includeErrorBoundary) imports.push("ErrorBoundaryProps");
270
- if (imports.length > 0) {
271
- sections.push(`import type { ${imports.join(", ")} } from "@pracht/core";`);
272
- sections.push("");
273
- }
274
- if (includeLoader) sections.push("export async function loader(_args: LoaderArgs) {", ` return { message: ${quote(`Welcome to ${title}.`)} };`, "}", "");
275
- if (includeStaticPaths) sections.push("export function getStaticPaths() {", ` return [${buildStaticPathsStub(params)}];`, "}", "");
276
- if (includeLoader) sections.push("export function Component({ data }: RouteComponentProps<typeof loader>) {", " return (", " <section>", ` <h1>${escapeJsxText(title)}</h1>`, " <p>{data.message}</p>", " </section>", " );", "}");
277
- else sections.push("export function Component() {", " return (", " <section>", ` <h1>${escapeJsxText(title)}</h1>`, " </section>", " );", "}");
278
- if (includeErrorBoundary) sections.push("", "export function ErrorBoundary({ error }: ErrorBoundaryProps) {", " return <p>{error.message}</p>;", "}");
279
- return sections;
280
- }
281
- function buildManifestRouteModuleSource(opts) {
282
- const sections = buildRouteModuleSections(opts);
283
- const componentIdx = sections.findIndex((s) => s.startsWith("export function Component"));
284
- const insertAt = componentIdx === -1 ? sections.length : componentIdx;
285
- sections.splice(insertAt, 0, "export function head() {", ` return { title: ${quote(opts.title)} };`, "}", "");
286
- return `${sections.join("\n")}\n`;
287
- }
288
- function buildPagesRouteModuleSource(opts) {
289
- const sections = buildRouteModuleSections(opts);
290
- const firstExportIdx = sections.findIndex((s) => s.startsWith("export"));
291
- const insertAt = firstExportIdx === -1 ? sections.length : firstExportIdx;
292
- sections.splice(insertAt, 0, `export const RENDER_MODE = ${quote(opts.render)};`, "");
293
- return `${sections.join("\n")}\n`;
294
- }
295
- function buildShellModuleSource(name) {
296
- const title = titleCase(name);
297
- return [
298
- "import type { ShellProps } from \"@pracht/core\";",
299
- "",
300
- "export function Shell({ children }: ShellProps) {",
301
- " return (",
302
- ` <div class=${quote(`${name}-shell`)}>`,
303
- " <main>{children}</main>",
304
- " </div>",
305
- " );",
306
- "}",
307
- "",
308
- "export function head() {",
309
- ` return { title: ${quote(title)} };`,
310
- "}",
311
- ""
312
- ].join("\n");
313
- }
314
- function buildMiddlewareModuleSource() {
315
- return [
316
- "import type { MiddlewareFn } from \"@pracht/core\";",
317
- "",
318
- "export const middleware: MiddlewareFn = async (_args) => {",
319
- " return;",
320
- "};",
321
- ""
322
- ].join("\n");
323
- }
324
- function buildApiRouteSource({ endpointPath, methods }) {
325
- return [
326
- "import type { BaseRouteArgs } from \"@pracht/core\";",
327
- "",
328
- ...methods.flatMap((method, index) => {
329
- const lines = buildApiMethodSource(method, methods, endpointPath);
330
- if (index === methods.length - 1) return lines;
331
- return [...lines, ""];
332
- }),
333
- ""
334
- ].join("\n");
335
- }
336
- function buildApiMethodSource(method, methods, endpointPath) {
337
- if (method === "DELETE" || method === "HEAD") return [
338
- `export function ${method}(_args: BaseRouteArgs) {`,
339
- " return new Response(null, { status: 204 });",
340
- "}"
341
- ];
342
- if (method === "OPTIONS") return [
343
- `export function ${method}(_args: BaseRouteArgs) {`,
344
- " return new Response(null, {",
345
- ` headers: { allow: ${quote(methods.join(", "))} },`,
346
- " status: 204,",
347
- " });",
348
- "}"
349
- ];
350
- if (method === "GET") return [
351
- `export function ${method}(_args: BaseRouteArgs) {`,
352
- ` return Response.json({ endpoint: ${quote(`/api${endpointPath}`)}, ok: true });`,
353
- "}"
354
- ];
355
- const status = method === "POST" ? 201 : 200;
356
- return [
357
- `export async function ${method}({ request }: BaseRouteArgs) {`,
358
- " const body = await request.json();",
359
- ` return Response.json({ body, ok: true }, { status: ${status} });`,
360
- "}"
361
- ];
362
- }
363
- function normalizeRoutePathString(value) {
364
- if (!value || value === "/") return "/";
365
- const normalized = `/${value}`.replace(/\/+/g, "/");
366
- return normalized !== "/" && normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
367
- }
368
- function normalizeApiPath(value) {
369
- return normalizeRoutePathString(value).replace(/^\/api(?=\/|$)/, "") || "/";
370
- }
371
- function hasDynamicSegments(routePath) {
372
- return routePath.split("/").some((segment) => segment.startsWith(":") || segment === "*");
373
- }
374
- function dynamicParamNames(routePath) {
375
- return routePath.split("/").filter(Boolean).map((segment) => {
376
- if (segment.startsWith(":")) return segment.slice(1);
377
- if (segment === "*") return "slug";
378
- return null;
379
- }).filter((s) => s !== null);
380
- }
381
- function routeIdFromPath(routePath) {
382
- if (routePath === "/") return "index";
383
- return routePath.split("/").filter(Boolean).map((segment) => segment.replace(/^:/, "").replace(/\*/g, "splat")).join("-");
384
- }
385
- function titleFromPath(routePath) {
386
- if (routePath === "/") return "Home";
387
- return titleCase((routePath.split("/").filter(Boolean).at(-1) ?? "Page").replace(/^:/, "").replace(/\*/g, "slug"));
388
- }
389
- function titleCase(value) {
390
- return value.split(/[-_/]/).filter(Boolean).map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)).join(" ");
391
- }
392
- function buildStaticPathsStub(params) {
393
- if (params.length === 0) return "{}";
394
- return `{ ${params.map((name) => `${name}: ${quote(`example-${name}`)}`).join(", ")} }`;
395
- }
396
- function escapeJsxText(value) {
397
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
398
- }
399
402
  //#endregion
400
403
  export { generate_default as default };
package/dist/index.mjs CHANGED
@@ -29,12 +29,12 @@ runMain(defineCommand({
29
29
  description: "The pracht CLI"
30
30
  },
31
31
  subCommands: {
32
- build: () => import("./build-DlABFPbG.mjs").then((m) => m.default),
32
+ build: () => import("./build-DkP2ffu9.mjs").then((m) => m.default),
33
33
  dev: () => import("./dev-BCFe3g38.mjs").then((m) => m.default),
34
- doctor: () => import("./doctor-CQq64tfW.mjs").then((m) => m.default),
35
- generate: () => import("./generate-sHtRHRAH.mjs").then((m) => m.default),
36
- inspect: () => import("./inspect-DP-h3vLw.mjs").then((m) => m.default),
37
- verify: () => import("./verify-D7Bg1Su6.mjs").then((m) => m.default)
34
+ doctor: () => import("./doctor-CwQJjJ83.mjs").then((m) => m.default),
35
+ generate: () => import("./generate-C9AMSGsv.mjs").then((m) => m.default),
36
+ inspect: () => import("./inspect-MseHPU9s.mjs").then((m) => m.default),
37
+ verify: () => import("./verify-o0G5Xuff.mjs").then((m) => m.default)
38
38
  }
39
39
  }));
40
40
  //#endregion
@@ -1,6 +1,6 @@
1
1
  import { t as HTTP_METHODS } from "./index.mjs";
2
2
  import { t as readClientBuildAssets } from "./build-metadata-BOChHO8g.mjs";
3
- import { a as readProjectConfig, c as resolveProjectPath, p as handleCliError } from "./project-DUyH_7eJ.mjs";
3
+ import { a as readProjectConfig, c as resolveProjectPath, p as handleCliError } from "./project-BdMiN3s7.mjs";
4
4
  import { defineCommand } from "citty";
5
5
  import { existsSync, readFileSync } from "node:fs";
6
6
  import { resolve } from "node:path";