@bractjs/bractjs 0.1.28 → 0.1.29
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/package.json +3 -2
- package/src/__tests__/fixtures/app/routes/features-demo.tsx +28 -0
- package/src/__tests__/headers.test.ts +111 -0
- package/src/__tests__/integration.test.ts +34 -0
- package/src/__tests__/layout-registry.test.ts +7 -3
- package/src/__tests__/matcher.test.ts +29 -0
- package/src/__tests__/module-registry.test.ts +2 -3
- package/src/__tests__/route-lint.test.ts +5 -0
- package/src/__tests__/route-middleware.test.ts +84 -0
- package/src/__tests__/scanner.test.ts +46 -1
- package/src/__tests__/security-fixes.test.ts +201 -0
- package/src/__tests__/use-matches.test.ts +54 -0
- package/src/build/route-lint.ts +3 -3
- package/src/client/ClientRouter.tsx +118 -18
- package/src/client/hooks/useMatches.ts +32 -0
- package/src/client/router.tsx +7 -1
- package/src/client/rpc.ts +11 -1
- package/src/codegen/module-registry.ts +13 -21
- package/src/codegen/route-codegen.ts +8 -3
- package/src/config/load.ts +1 -0
- package/src/index.ts +11 -3
- package/src/server/action-handler.ts +1 -20
- package/src/server/adapter.ts +16 -0
- package/src/server/api-route.ts +47 -0
- package/src/server/csp.ts +9 -3
- package/src/server/csrf.ts +10 -3
- package/src/server/headers.ts +49 -0
- package/src/server/layout.ts +12 -19
- package/src/server/matcher.ts +29 -2
- package/src/server/matches.ts +50 -0
- package/src/server/middleware.ts +66 -0
- package/src/server/proto-guard.ts +56 -0
- package/src/server/render.ts +34 -16
- package/src/server/request-handler.ts +67 -27
- package/src/server/scanner.ts +45 -3
- package/src/server/search.ts +5 -1
- package/src/server/serve.ts +28 -3
- package/src/server/session.ts +12 -1
- package/src/server/validate.ts +4 -1
- package/src/shared/context.ts +3 -1
- package/src/shared/route-types.ts +108 -0
- package/types/config.d.ts +3 -0
- package/types/index.d.ts +17 -0
- package/types/route.d.ts +76 -1
- package/LICENSE +0 -21
- package/README.md +0 -1331
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bractjs/bractjs",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.29",
|
|
4
4
|
"description": "Production-grade SSR framework for Bun + React 19. File-based routing, streaming SSR, server actions, typed routes.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/bractjs/bractjs#readme",
|
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
"scripts": {
|
|
46
46
|
"dev": "bun run src/dev/server.ts",
|
|
47
47
|
"build": "bun run src/build/bundler.ts",
|
|
48
|
-
"test": "bun test"
|
|
48
|
+
"test": "bun test",
|
|
49
|
+
"typecheck": "bunx tsc --noEmit"
|
|
49
50
|
},
|
|
50
51
|
"peerDependencies": {
|
|
51
52
|
"react": "^19",
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Fixture exercising the Phase 1/2/4 route exports end-to-end:
|
|
2
|
+
// - `headers` → Cache-Control on the document + /_data response
|
|
3
|
+
// - `handle` → surfaced via useMatches() (asserted from the payload)
|
|
4
|
+
// - `middleware` → sets context (read by the loader) and stamps a header
|
|
5
|
+
import type { RouteMiddlewareFunction, HeadersArgs } from "../../../../shared/route-types.ts";
|
|
6
|
+
|
|
7
|
+
const setUser: RouteMiddlewareFunction = async (ctx, next) => {
|
|
8
|
+
ctx.context.demoUser = "alice";
|
|
9
|
+
const res = await next();
|
|
10
|
+
res.headers.set("X-Demo-Mw", "1");
|
|
11
|
+
return res;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const middleware = [setUser];
|
|
15
|
+
|
|
16
|
+
export const handle = { breadcrumb: "Features" };
|
|
17
|
+
|
|
18
|
+
export function loader({ context }: { context: Record<string, unknown> }) {
|
|
19
|
+
return { user: context.demoUser ?? null };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function headers(_args: HeadersArgs) {
|
|
23
|
+
return { "Cache-Control": "public, max-age=120" };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default function FeaturesDemo() {
|
|
27
|
+
return <p>features</p>;
|
|
28
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { resolveHeaders, applyRouteHeaders } from "../server/headers.ts";
|
|
3
|
+
import type { LayoutChain } from "../server/layout.ts";
|
|
4
|
+
import type { LoaderResults } from "../server/loader.ts";
|
|
5
|
+
import type { HeadersFunction } from "../shared/route-types.ts";
|
|
6
|
+
|
|
7
|
+
const params = {};
|
|
8
|
+
const req = new Request("http://localhost/");
|
|
9
|
+
|
|
10
|
+
function chain(parts: {
|
|
11
|
+
root?: HeadersFunction;
|
|
12
|
+
layouts?: (HeadersFunction | undefined)[];
|
|
13
|
+
route?: HeadersFunction;
|
|
14
|
+
}): LayoutChain {
|
|
15
|
+
return {
|
|
16
|
+
root: parts.root ? { headers: parts.root } : {},
|
|
17
|
+
layouts: (parts.layouts ?? []).map((h) => (h ? { headers: h } : {})),
|
|
18
|
+
route: parts.route ? { headers: parts.route } : {},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function results(over: Partial<LoaderResults> = {}): LoaderResults {
|
|
23
|
+
return { root: null, layouts: [], route: null, ...over };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("resolveHeaders", () => {
|
|
27
|
+
test("returns null when no module exports headers", () => {
|
|
28
|
+
expect(resolveHeaders(chain({}), results(), params, req)).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("collects a single route's headers", () => {
|
|
32
|
+
const merged = resolveHeaders(
|
|
33
|
+
chain({ route: () => ({ "Cache-Control": "max-age=60" }) }),
|
|
34
|
+
results(),
|
|
35
|
+
params,
|
|
36
|
+
req,
|
|
37
|
+
);
|
|
38
|
+
expect(merged?.get("Cache-Control")).toBe("max-age=60");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("innermost route wins per key (route over root)", () => {
|
|
42
|
+
const merged = resolveHeaders(
|
|
43
|
+
chain({
|
|
44
|
+
root: () => ({ "Cache-Control": "max-age=0", Vary: "Cookie" }),
|
|
45
|
+
route: () => ({ "Cache-Control": "max-age=300" }),
|
|
46
|
+
}),
|
|
47
|
+
results(),
|
|
48
|
+
params,
|
|
49
|
+
req,
|
|
50
|
+
);
|
|
51
|
+
expect(merged?.get("Cache-Control")).toBe("max-age=300");
|
|
52
|
+
// Root-only key survives.
|
|
53
|
+
expect(merged?.get("Vary")).toBe("Cookie");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("parentHeaders carries accumulated values to inner links", () => {
|
|
57
|
+
let seen: string | null = "unset";
|
|
58
|
+
const merged = resolveHeaders(
|
|
59
|
+
chain({
|
|
60
|
+
root: () => ({ "X-From-Root": "1" }),
|
|
61
|
+
route: ({ parentHeaders }) => {
|
|
62
|
+
seen = parentHeaders.get("X-From-Root");
|
|
63
|
+
return {};
|
|
64
|
+
},
|
|
65
|
+
}),
|
|
66
|
+
results(),
|
|
67
|
+
params,
|
|
68
|
+
req,
|
|
69
|
+
);
|
|
70
|
+
expect(seen).toBe("1");
|
|
71
|
+
expect(merged?.get("X-From-Root")).toBe("1");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("passes the matching loaderData slice to each link", () => {
|
|
75
|
+
const merged = resolveHeaders(
|
|
76
|
+
chain({ route: ({ loaderData }) => ({ ETag: String((loaderData as { etag: string }).etag) }) }),
|
|
77
|
+
results({ route: { etag: "abc" } }),
|
|
78
|
+
params,
|
|
79
|
+
req,
|
|
80
|
+
);
|
|
81
|
+
expect(merged?.get("ETag")).toBe("abc");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("layout headers merge between root and route", () => {
|
|
85
|
+
const merged = resolveHeaders(
|
|
86
|
+
chain({
|
|
87
|
+
root: () => ({ "Cache-Control": "max-age=0" }),
|
|
88
|
+
layouts: [() => ({ "Cache-Control": "max-age=10", Vary: "Accept" })],
|
|
89
|
+
route: () => ({ "Cache-Control": "max-age=60" }),
|
|
90
|
+
}),
|
|
91
|
+
results({ layouts: [null] }),
|
|
92
|
+
params,
|
|
93
|
+
req,
|
|
94
|
+
);
|
|
95
|
+
expect(merged?.get("Cache-Control")).toBe("max-age=60");
|
|
96
|
+
expect(merged?.get("Vary")).toBe("Accept");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("applyRouteHeaders", () => {
|
|
101
|
+
test("overrides same-key defaults and is a no-op for null", () => {
|
|
102
|
+
const base = new Headers({ "Cache-Control": "no-store", "X-Base": "1" });
|
|
103
|
+
applyRouteHeaders(base, new Headers({ "Cache-Control": "max-age=60" }));
|
|
104
|
+
expect(base.get("Cache-Control")).toBe("max-age=60");
|
|
105
|
+
expect(base.get("X-Base")).toBe("1");
|
|
106
|
+
|
|
107
|
+
const base2 = new Headers({ "X-Base": "1" });
|
|
108
|
+
applyRouteHeaders(base2, null);
|
|
109
|
+
expect(base2.get("X-Base")).toBe("1");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -182,3 +182,37 @@ test("/_data of a beforeLoad-gated route is blocked and never leaks loader data"
|
|
|
182
182
|
const body = await res.text();
|
|
183
183
|
expect(body).not.toContain("TOP-SECRET-LOADER-DATA");
|
|
184
184
|
});
|
|
185
|
+
|
|
186
|
+
// ── Route headers / useMatches / nested middleware (Phases 1, 2, 4) ──────────
|
|
187
|
+
|
|
188
|
+
test("route `headers` export sets Cache-Control on the document response", async () => {
|
|
189
|
+
const res = await fetch(`${BASE}/features-demo`);
|
|
190
|
+
expect(res.status).toBe(200);
|
|
191
|
+
expect(res.headers.get("Cache-Control")).toBe("public, max-age=120");
|
|
192
|
+
// Baseline hardening headers still present (not clobbered).
|
|
193
|
+
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("route `headers` export also applies to the /_data response", async () => {
|
|
197
|
+
const res = await fetch(`${BASE}/_data?path=/features-demo`);
|
|
198
|
+
expect(res.status).toBe(200);
|
|
199
|
+
expect(res.headers.get("Cache-Control")).toBe("public, max-age=120");
|
|
200
|
+
expect(res.headers.get("content-type")).toContain("application/json");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("nested middleware runs (sets context read by the loader, stamps a header)", async () => {
|
|
204
|
+
const res = await fetch(`${BASE}/_data?path=/features-demo`);
|
|
205
|
+
expect(res.headers.get("X-Demo-Mw")).toBe("1");
|
|
206
|
+
const data = (await res.json()) as { route?: { user?: string } };
|
|
207
|
+
// The loader saw the context value the middleware set.
|
|
208
|
+
expect(data.route?.user).toBe("alice");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("/_data payload carries the matched chain (useMatches) with handle", async () => {
|
|
212
|
+
const res = await fetch(`${BASE}/_data?path=/features-demo`);
|
|
213
|
+
const data = (await res.json()) as { matches?: Array<{ id: string; handle?: { breadcrumb?: string } }> };
|
|
214
|
+
expect(Array.isArray(data.matches)).toBe(true);
|
|
215
|
+
// Leaf route carries its handle export.
|
|
216
|
+
const leaf = data.matches!.at(-1)!;
|
|
217
|
+
expect(leaf.handle?.breadcrumb).toBe("Features");
|
|
218
|
+
});
|
|
@@ -58,13 +58,17 @@ describe("resolveLayoutChainFromRegistry", () => {
|
|
|
58
58
|
expect(r.layoutFiles).toEqual([]);
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
-
test("
|
|
62
|
-
//
|
|
61
|
+
test("wraps a folder index in that folder's layout", () => {
|
|
62
|
+
// routes/blog/_index.tsx (URL /blog) lives inside routes/blog/, so the
|
|
63
|
+
// sibling routes/blog/layout.tsx wraps it — matching Remix/RR/Next, where
|
|
64
|
+
// an index is nested under its directory's layout. Layout dirs are derived
|
|
65
|
+
// from the FILE path, so the `_index` → `blog` urlPattern collapse no
|
|
66
|
+
// longer hides the ancestor directory.
|
|
63
67
|
const r = resolveLayoutChainFromRegistry(
|
|
64
68
|
{ filePath: "routes/blog/_index.tsx", urlPattern: "blog", segments: ["blog"] },
|
|
65
69
|
registry,
|
|
66
70
|
);
|
|
67
|
-
expect(r.layoutFiles).toEqual(["root.tsx"]);
|
|
71
|
+
expect(r.layoutFiles).toEqual(["root.tsx", "routes/blog/layout.tsx"]);
|
|
68
72
|
});
|
|
69
73
|
});
|
|
70
74
|
|
|
@@ -67,3 +67,32 @@ describe("matchRoute", () => {
|
|
|
67
67
|
expect(r2?.params.id).toBe("123");
|
|
68
68
|
});
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
describe("optional segments [[id]]", () => {
|
|
72
|
+
test("matches with the segment present (binds the param)", () => {
|
|
73
|
+
const trie = buildTrie([makeRoute("users/[[id]]")]);
|
|
74
|
+
const r = matchRoute("/users/42", trie);
|
|
75
|
+
expect(r).not.toBeNull();
|
|
76
|
+
expect(r?.params).toEqual({ id: "42" });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("matches with the segment absent (param unset)", () => {
|
|
80
|
+
const trie = buildTrie([makeRoute("users/[[id]]")]);
|
|
81
|
+
const r = matchRoute("/users", trie);
|
|
82
|
+
expect(r).not.toBeNull();
|
|
83
|
+
expect(r?.params).toEqual({});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("static sibling still wins over the optional param", () => {
|
|
87
|
+
const trie = buildTrie([makeRoute("users/[[id]]"), makeRoute("users/me")]);
|
|
88
|
+
const r = matchRoute("/users/me", trie);
|
|
89
|
+
expect(r?.routeFile.urlPattern).toBe("users/me");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("does not over-consume — extra segment falls through to catch-all", () => {
|
|
93
|
+
const trie = buildTrie([makeRoute("users/[[id]]"), makeRoute("users/[...rest]")]);
|
|
94
|
+
const r = matchRoute("/users/1/2", trie);
|
|
95
|
+
expect(r?.routeFile.urlPattern).toBe("users/[...rest]");
|
|
96
|
+
expect(r?.params.rest).toBe("1/2");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -18,9 +18,8 @@ beforeAll(async () => {
|
|
|
18
18
|
await writeFile(join(TMP, "root.tsx"), `export default function Root() { return null; }\n`);
|
|
19
19
|
await writeFile(join(TMP, "routes", "_index.tsx"), `export default function Home() { return null; }\n`);
|
|
20
20
|
await writeFile(join(TMP, "routes", "blog", "layout.tsx"), `export default function L({ children }: any) { return children; }\n`);
|
|
21
|
-
// Nested route — `routes/blog/layout.tsx`
|
|
22
|
-
//
|
|
23
|
-
// same path level (matches `layout.ts`'s `layoutDirs` resolution).
|
|
21
|
+
// Nested route under /blog/ — `routes/blog/layout.tsx` wraps everything in
|
|
22
|
+
// its directory (including a `blog/_index`), per `layoutDirsFromFilePath`.
|
|
24
23
|
await writeFile(join(TMP, "routes", "blog", "[slug].tsx"), `export default function P() { return null; }\n`);
|
|
25
24
|
|
|
26
25
|
await writeFile(
|
|
@@ -58,6 +58,11 @@ describe("lintRouteModuleSource — miscased exports", () => {
|
|
|
58
58
|
`export default () => null;\n` +
|
|
59
59
|
`export function loader() { return {}; }\n` +
|
|
60
60
|
`export function action() { return {}; }\n` +
|
|
61
|
+
`export const clientLoader = () => ({});\n` +
|
|
62
|
+
`export const clientAction = () => ({});\n` +
|
|
63
|
+
`export function headers() { return {}; }\n` +
|
|
64
|
+
`export const middleware = [];\n` +
|
|
65
|
+
`export const handle = {};\n` +
|
|
61
66
|
`export const searchSchema = {};\n` +
|
|
62
67
|
`export function Fallback() { return null; }\n` +
|
|
63
68
|
`export const ssr = false;\n`;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
runRouteMiddleware,
|
|
4
|
+
collectRouteMiddleware,
|
|
5
|
+
type RouteMiddleware,
|
|
6
|
+
} from "../server/middleware.ts";
|
|
7
|
+
import type { MiddlewareContext } from "../server/middleware.ts";
|
|
8
|
+
|
|
9
|
+
function makeCtx(): MiddlewareContext {
|
|
10
|
+
return { request: new Request("http://localhost/"), params: {}, context: {} };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const ok = async () => new Response("ok", { status: 200 });
|
|
14
|
+
|
|
15
|
+
describe("collectRouteMiddleware", () => {
|
|
16
|
+
test("orders root → layouts → route and flattens arrays", () => {
|
|
17
|
+
const order: string[] = [];
|
|
18
|
+
const mk = (label: string): RouteMiddleware => async (_c, n) => { order.push(label); return n(); };
|
|
19
|
+
const chain = {
|
|
20
|
+
root: { middleware: mk("root") },
|
|
21
|
+
layouts: [{ middleware: [mk("l0a"), mk("l0b")] }, { middleware: mk("l1") }],
|
|
22
|
+
route: { middleware: mk("route") },
|
|
23
|
+
};
|
|
24
|
+
const fns = collectRouteMiddleware(chain);
|
|
25
|
+
expect(fns).toHaveLength(5);
|
|
26
|
+
return runRouteMiddleware(fns, makeCtx(), ok).then(() => {
|
|
27
|
+
expect(order).toEqual(["root", "l0a", "l0b", "l1", "route"]);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("ignores modules without middleware and non-function entries", () => {
|
|
32
|
+
const chain = {
|
|
33
|
+
root: {},
|
|
34
|
+
layouts: [{ middleware: undefined }, { middleware: ["nope" as unknown] }],
|
|
35
|
+
route: { middleware: (async (_c: MiddlewareContext, n: () => Promise<Response>) => n()) },
|
|
36
|
+
};
|
|
37
|
+
expect(collectRouteMiddleware(chain)).toHaveLength(1);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("runRouteMiddleware", () => {
|
|
42
|
+
test("empty list calls handler directly", async () => {
|
|
43
|
+
const res = await runRouteMiddleware([], makeCtx(), ok);
|
|
44
|
+
expect(res.status).toBe(200);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a middleware can short-circuit by not calling next()", async () => {
|
|
48
|
+
let handlerRan = false;
|
|
49
|
+
const gate: RouteMiddleware = async () => new Response("denied", { status: 403 });
|
|
50
|
+
const res = await runRouteMiddleware([gate], makeCtx(), async () => {
|
|
51
|
+
handlerRan = true;
|
|
52
|
+
return ok();
|
|
53
|
+
});
|
|
54
|
+
expect(res.status).toBe(403);
|
|
55
|
+
expect(handlerRan).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("shares a mutable context across the chain and into the handler", async () => {
|
|
59
|
+
const ctx = makeCtx();
|
|
60
|
+
const setUser: RouteMiddleware = async (c, n) => { c.context.user = "alice"; return n(); };
|
|
61
|
+
let seenInHandler: unknown;
|
|
62
|
+
await runRouteMiddleware([setUser], ctx, async () => {
|
|
63
|
+
seenInHandler = ctx.context.user;
|
|
64
|
+
return ok();
|
|
65
|
+
});
|
|
66
|
+
expect(seenInHandler).toBe("alice");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("rejects if a middleware calls next() twice", async () => {
|
|
70
|
+
const bad: RouteMiddleware = async (_c, n) => { await n(); return n(); };
|
|
71
|
+
await expect(runRouteMiddleware([bad], makeCtx(), ok)).rejects.toThrow(/more than once/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("outer middleware can post-process the inner Response", async () => {
|
|
75
|
+
const stamp: RouteMiddleware = async (_c, n) => {
|
|
76
|
+
const res = await n();
|
|
77
|
+
const out = new Response(res.body, res);
|
|
78
|
+
out.headers.set("X-Stamped", "1");
|
|
79
|
+
return out;
|
|
80
|
+
};
|
|
81
|
+
const res = await runRouteMiddleware([stamp], makeCtx(), ok);
|
|
82
|
+
expect(res.headers.get("X-Stamped")).toBe("1");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { test, expect, describe } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
filePathToPattern,
|
|
4
|
+
pathToSegments,
|
|
5
|
+
layoutDirsFromFilePath,
|
|
6
|
+
isRouteGroupSegment,
|
|
7
|
+
} from "../server/scanner.ts";
|
|
3
8
|
|
|
4
9
|
describe("filePathToPattern", () => {
|
|
5
10
|
test("_index maps to empty pattern (root index)", () => {
|
|
@@ -25,6 +30,42 @@ describe("filePathToPattern", () => {
|
|
|
25
30
|
test("strips .ts extension too", () => {
|
|
26
31
|
expect(filePathToPattern("routes/api/data.ts")).toBe("api/data");
|
|
27
32
|
});
|
|
33
|
+
|
|
34
|
+
test("route group folder adds no URL segment", () => {
|
|
35
|
+
expect(filePathToPattern("routes/(marketing)/about.tsx")).toBe("about");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("nested route group strips only the group segment", () => {
|
|
39
|
+
expect(filePathToPattern("routes/(marketing)/blog/[id].tsx")).toBe("blog/[id]");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("group wrapping the index → root pattern", () => {
|
|
43
|
+
expect(filePathToPattern("routes/(marketing)/_index.tsx")).toBe("");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("[[id]] optional kept in pattern string", () => {
|
|
47
|
+
expect(filePathToPattern("routes/users/[[id]].tsx")).toBe("users/[[id]]");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("route groups", () => {
|
|
52
|
+
test("isRouteGroupSegment detects (group) but not () or plain", () => {
|
|
53
|
+
expect(isRouteGroupSegment("(marketing)")).toBe(true);
|
|
54
|
+
expect(isRouteGroupSegment("()")).toBe(false);
|
|
55
|
+
expect(isRouteGroupSegment("about")).toBe(false);
|
|
56
|
+
expect(isRouteGroupSegment("[id]")).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("layoutDirsFromFilePath includes group folders", () => {
|
|
60
|
+
expect(layoutDirsFromFilePath("routes/(marketing)/blog/[id].tsx")).toEqual([
|
|
61
|
+
"(marketing)",
|
|
62
|
+
"(marketing)/blog",
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("layoutDirsFromFilePath for a top-level route → empty", () => {
|
|
67
|
+
expect(layoutDirsFromFilePath("routes/about.tsx")).toEqual([]);
|
|
68
|
+
});
|
|
28
69
|
});
|
|
29
70
|
|
|
30
71
|
describe("pathToSegments", () => {
|
|
@@ -44,6 +85,10 @@ describe("pathToSegments", () => {
|
|
|
44
85
|
expect(pathToSegments("docs/[...slug]")).toEqual(["docs", { catchAll: "slug" }]);
|
|
45
86
|
});
|
|
46
87
|
|
|
88
|
+
test("[[id]] → optional segment", () => {
|
|
89
|
+
expect(pathToSegments("users/[[id]]")).toEqual(["users", { optional: "id" }]);
|
|
90
|
+
});
|
|
91
|
+
|
|
47
92
|
test("nested static path", () => {
|
|
48
93
|
expect(pathToSegments("a/b/c")).toEqual(["a", "b", "c"]);
|
|
49
94
|
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { test, expect, describe, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { createServer } from "../server/serve.ts";
|
|
3
|
+
import { pipeline } from "../server/middleware.ts";
|
|
4
|
+
import { route, handleApiRequest } from "../server/api-route.ts";
|
|
5
|
+
import { csp } from "../server/csp.ts";
|
|
6
|
+
import { hasForbiddenKey, nullProtoFromEntries } from "../server/proto-guard.ts";
|
|
7
|
+
import { searchParamsToObject } from "../server/search.ts";
|
|
8
|
+
import { validate } from "../server/validate.ts";
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
|
|
11
|
+
const PORT = 3989;
|
|
12
|
+
const BASE = `http://localhost:${PORT}`;
|
|
13
|
+
const FIXTURE_APP = resolve(import.meta.dir, "fixtures/app");
|
|
14
|
+
|
|
15
|
+
// A marker middleware on the GLOBAL pipeline + a CSP middleware, plus a couple
|
|
16
|
+
// of API routes. Registered before the server starts; cleared afterwards so
|
|
17
|
+
// these don't leak into other suites sharing the process.
|
|
18
|
+
const MARKER = "X-Test-Global-MW";
|
|
19
|
+
|
|
20
|
+
let handle: ReturnType<typeof createServer>;
|
|
21
|
+
|
|
22
|
+
beforeAll(() => {
|
|
23
|
+
pipeline.clear();
|
|
24
|
+
pipeline.use(async (_ctx, next) => {
|
|
25
|
+
const res = await next();
|
|
26
|
+
res.headers.set(MARKER, "1");
|
|
27
|
+
return res;
|
|
28
|
+
});
|
|
29
|
+
pipeline.use(csp());
|
|
30
|
+
|
|
31
|
+
// Protected-by-default mutating route, an opted-out one, and a GET.
|
|
32
|
+
route("POST", "/api/secure", (input: unknown) => ({ ok: true, input }));
|
|
33
|
+
route("POST", "/api/public", (input: unknown) => ({ ok: true, input }), { csrf: false });
|
|
34
|
+
route("GET", "/api/ping", () => ({ pong: true }));
|
|
35
|
+
|
|
36
|
+
handle = createServer({
|
|
37
|
+
port: PORT,
|
|
38
|
+
appDir: FIXTURE_APP,
|
|
39
|
+
manifest: { clientEntry: "/build/client/client.js", routes: {} },
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterAll(() => {
|
|
44
|
+
handle.stop();
|
|
45
|
+
pipeline.clear();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ── H-1 — global middleware now wraps the special endpoints ────────────────
|
|
49
|
+
|
|
50
|
+
describe("H-1: global middleware covers special endpoints", () => {
|
|
51
|
+
test("marker + CSP applied to an /api response", async () => {
|
|
52
|
+
const res = await fetch(`${BASE}/api/ping`);
|
|
53
|
+
expect(res.headers.get(MARKER)).toBe("1");
|
|
54
|
+
expect(res.headers.get("Content-Security-Policy")).toContain("default-src 'self'");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("marker applied to /_action (even on 404 unknown id)", async () => {
|
|
58
|
+
const res = await fetch(`${BASE}/_action?id=deadbeefdeadbeef`, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: { "X-BractJS-Action": "1", "Content-Type": "application/json" },
|
|
61
|
+
body: "[]",
|
|
62
|
+
});
|
|
63
|
+
// unknown id → 404, but it still passed through the global pipeline.
|
|
64
|
+
expect(res.headers.get(MARKER)).toBe("1");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("marker applied to /_image (even on 400 bad request)", async () => {
|
|
68
|
+
const res = await fetch(`${BASE}/_image?src=/etc/passwd`);
|
|
69
|
+
expect(res.headers.get(MARKER)).toBe("1");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("marker applied to a normal SSR document too (no double-run)", async () => {
|
|
73
|
+
const res = await fetch(`${BASE}/`);
|
|
74
|
+
expect(res.headers.get(MARKER)).toBe("1");
|
|
75
|
+
// CSP header present exactly once.
|
|
76
|
+
expect(res.headers.get("Content-Security-Policy")).toContain("script-src");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ── H-2 — CSRF on typed /api routes ────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
describe("H-2: /api CSRF protection", () => {
|
|
83
|
+
test("cross-site POST to a protected route → 403", async () => {
|
|
84
|
+
const res = await fetch(`${BASE}/api/secure`, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: { "Content-Type": "application/json", "Sec-Fetch-Site": "cross-site" },
|
|
87
|
+
body: JSON.stringify({ a: 1 }),
|
|
88
|
+
});
|
|
89
|
+
expect(res.status).toBe(403);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("no-attribution POST (no Origin / no header / no Sec-Fetch-Site) → 403", async () => {
|
|
93
|
+
// Call the handler directly so no Origin is auto-added by fetch.
|
|
94
|
+
const req = new Request("http://localhost/api/secure", {
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers: { "Content-Type": "application/json" },
|
|
97
|
+
body: JSON.stringify({ a: 1 }),
|
|
98
|
+
});
|
|
99
|
+
const res = await handleApiRequest(req);
|
|
100
|
+
expect(res?.status).toBe(403);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("same-origin POST (X-BractJS-Action) to a protected route → 200", async () => {
|
|
104
|
+
const res = await fetch(`${BASE}/api/secure`, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: { "Content-Type": "application/json", "X-BractJS-Action": "1" },
|
|
107
|
+
body: JSON.stringify({ a: 1 }),
|
|
108
|
+
});
|
|
109
|
+
expect(res.status).toBe(200);
|
|
110
|
+
expect(await res.json()).toEqual({ ok: true, input: { a: 1 } });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("same-origin POST via Sec-Fetch-Site → 200 (no custom header needed)", async () => {
|
|
114
|
+
const res = await fetch(`${BASE}/api/secure`, {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers: { "Content-Type": "application/json", "Sec-Fetch-Site": "same-origin" },
|
|
117
|
+
body: JSON.stringify({ a: 2 }),
|
|
118
|
+
});
|
|
119
|
+
expect(res.status).toBe(200);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("opted-out route (csrf:false) allows cross-site POST", async () => {
|
|
123
|
+
const res = await fetch(`${BASE}/api/public`, {
|
|
124
|
+
method: "POST",
|
|
125
|
+
headers: { "Content-Type": "application/json", "Sec-Fetch-Site": "cross-site" },
|
|
126
|
+
body: JSON.stringify({ a: 3 }),
|
|
127
|
+
});
|
|
128
|
+
expect(res.status).toBe(200);
|
|
129
|
+
expect(await res.json()).toEqual({ ok: true, input: { a: 3 } });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("GET /api is never CSRF-gated", async () => {
|
|
133
|
+
const res = await fetch(`${BASE}/api/ping`, { headers: { "Sec-Fetch-Site": "cross-site" } });
|
|
134
|
+
expect(res.status).toBe(200);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("forbidden-key JSON body to an /api route → 400", async () => {
|
|
138
|
+
const res = await fetch(`${BASE}/api/secure`, {
|
|
139
|
+
method: "POST",
|
|
140
|
+
headers: { "Content-Type": "application/json", "X-BractJS-Action": "1" },
|
|
141
|
+
body: '{"__proto__":{"polluted":true}}',
|
|
142
|
+
});
|
|
143
|
+
expect(res.status).toBe(400);
|
|
144
|
+
// And Object.prototype was not polluted.
|
|
145
|
+
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ── M-1 — CSP form-action ──────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
describe("M-1: CSP defaults", () => {
|
|
152
|
+
test("default policy includes form-action 'self'", async () => {
|
|
153
|
+
const res = await fetch(`${BASE}/api/ping`);
|
|
154
|
+
expect(res.headers.get("Content-Security-Policy")).toContain("form-action 'self'");
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ── M-2 — prototype-pollution guards ───────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
describe("M-2: proto-guard", () => {
|
|
161
|
+
test("hasForbiddenKey detects buried __proto__", () => {
|
|
162
|
+
let v: unknown = JSON.parse('{"__proto__":{"x":1}}');
|
|
163
|
+
for (let i = 0; i < 5; i++) v = { a: v };
|
|
164
|
+
expect(hasForbiddenKey(v)).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("hasForbiddenKey passes a clean object", () => {
|
|
168
|
+
expect(hasForbiddenKey({ a: { b: { c: 1 } } })).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("hasForbiddenKey fails closed past the scan depth", () => {
|
|
172
|
+
let v: unknown = { value: "x" };
|
|
173
|
+
for (let i = 0; i < 250; i++) v = { a: v };
|
|
174
|
+
expect(hasForbiddenKey(v)).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("searchParamsToObject yields a null-prototype object", () => {
|
|
178
|
+
const out = searchParamsToObject(new URLSearchParams("__proto__=evil&a=1"));
|
|
179
|
+
expect(Object.getPrototypeOf(out)).toBeNull();
|
|
180
|
+
// __proto__ lands as a real own key, not a prototype mutation.
|
|
181
|
+
expect(out["__proto__"]).toBe("evil");
|
|
182
|
+
expect(({} as Record<string, unknown>).evil).toBeUndefined();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("validate() over FormData with a __proto__ field does not pollute", async () => {
|
|
186
|
+
const fd = new FormData();
|
|
187
|
+
fd.set("__proto__", "evil");
|
|
188
|
+
fd.set("name", "ok");
|
|
189
|
+
// Identity schema — just returns what it gets.
|
|
190
|
+
const schema = { parse: (x: unknown) => x };
|
|
191
|
+
const out = (await validate(schema, fd)) as Record<string, unknown>;
|
|
192
|
+
expect(({} as Record<string, unknown>).evil).toBeUndefined();
|
|
193
|
+
expect(out.name).toBe("ok");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("nullProtoFromEntries builds a null-prototype object", () => {
|
|
197
|
+
const out = nullProtoFromEntries([["__proto__", 1], ["a", 2]]);
|
|
198
|
+
expect(Object.getPrototypeOf(out)).toBeNull();
|
|
199
|
+
expect(out["a"]).toBe(2);
|
|
200
|
+
});
|
|
201
|
+
});
|