@bractjs/bractjs 0.1.25 → 0.1.27
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 +773 -465
- package/bin/cli.ts +23 -3
- package/package.json +1 -1
- package/src/__tests__/build-path.test.ts +29 -0
- package/src/__tests__/codegen.test.ts +36 -0
- package/src/__tests__/compile-safety.test.ts +163 -0
- package/src/__tests__/compile-smoke.test.ts +276 -0
- package/src/__tests__/csp.test.ts +80 -0
- package/src/__tests__/fixtures/app/routes/_index.tsx +6 -2
- package/src/__tests__/fixtures/app/routes/protected.tsx +15 -0
- package/src/__tests__/fixtures/app/routes/redirect-action.tsx +13 -0
- package/src/__tests__/integration.test.ts +62 -0
- package/src/__tests__/layout-registry.test.ts +23 -0
- package/src/__tests__/loader.test.ts +23 -0
- package/src/__tests__/middleware.test.ts +22 -0
- package/src/__tests__/programmatic-api.test.ts +41 -2
- package/src/__tests__/response.test.ts +54 -1
- package/src/__tests__/security.test.ts +35 -0
- package/src/__tests__/server-module-stub.test.ts +145 -0
- package/src/__tests__/stream-handler.test.ts +36 -0
- package/src/__tests__/typed-routing.test.ts +189 -0
- package/src/build/bundler.ts +46 -20
- package/src/build/directives.ts +2 -2
- package/src/build/env-plugin.ts +63 -0
- package/src/build/react-dedupe.ts +41 -0
- package/src/client/ClientRouter.tsx +22 -8
- package/src/client/build-path.ts +24 -0
- package/src/client/components/Form.tsx +10 -1
- package/src/client/components/Link.tsx +31 -8
- package/src/client/hooks/useFetcher.ts +17 -1
- package/src/client/hooks/useNavigate.ts +46 -0
- package/src/client/hooks/useParams.ts +15 -4
- package/src/client/hooks/useSearchParams.ts +16 -6
- package/src/client/nav-utils.ts +54 -3
- package/src/client/registry.ts +107 -0
- package/src/client/types.ts +3 -0
- package/src/codegen/route-codegen.ts +62 -23
- package/src/config/load.ts +50 -2
- package/src/dev/devtools.ts +72 -39
- package/src/dev/hmr-module-handler.ts +6 -4
- package/src/dev/rebuilder.ts +16 -1
- package/src/dev/server.ts +3 -0
- package/src/index.ts +30 -3
- package/src/server/csp.ts +92 -0
- package/src/server/csrf.ts +44 -6
- package/src/server/layout.ts +12 -2
- package/src/server/loader.ts +5 -7
- package/src/server/render.ts +29 -10
- package/src/server/request-handler.ts +15 -4
- package/src/server/response.ts +58 -5
- package/src/server/serve.ts +10 -0
- package/src/server/static.ts +11 -1
- package/src/server/stream-handler.ts +8 -7
- package/src/server/use-client-runtime.ts +62 -0
- package/src/shared/meta-tags.tsx +46 -0
- package/types/index.d.ts +67 -5
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Fixture for the "action returns (not throws) a redirect" regression test.
|
|
2
|
+
// The documented pattern (README §5/§6) is `return redirect("/")`. The handler
|
|
3
|
+
// must surface that as a real 3xx so `<Form>`/the browser follows it — not wrap
|
|
4
|
+
// it into a 200 JSON body.
|
|
5
|
+
import { redirect } from "../../../../server/response.ts";
|
|
6
|
+
|
|
7
|
+
export async function action() {
|
|
8
|
+
return redirect("/");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default function RedirectActionPage() {
|
|
12
|
+
return <p>redirect action page</p>;
|
|
13
|
+
}
|
|
@@ -42,6 +42,31 @@ test("POST / runs action and returns 200 HTML", async () => {
|
|
|
42
42
|
expect(res.headers.get("content-type")).toContain("text/html");
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
+
// Regression: an action that *returns* (not throws) a redirect must produce a
|
|
46
|
+
// real 3xx with the Location header — previously it was wrapped into a 200 JSON
|
|
47
|
+
// body, so `<Form>`/the browser never followed it.
|
|
48
|
+
test("POST action that RETURNS redirect() yields a 302 with Location (X-BractJS-Action)", async () => {
|
|
49
|
+
const res = await fetch(`${BASE}/redirect-action`, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
body: new FormData(),
|
|
52
|
+
headers: { Origin: BASE, "X-BractJS-Action": "1" },
|
|
53
|
+
redirect: "manual",
|
|
54
|
+
});
|
|
55
|
+
expect(res.status).toBe(302);
|
|
56
|
+
expect(res.headers.get("Location")).toBe("/");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("full-page POST action that RETURNS redirect() also yields a 302", async () => {
|
|
60
|
+
const res = await fetch(`${BASE}/redirect-action`, {
|
|
61
|
+
method: "POST",
|
|
62
|
+
body: new FormData(),
|
|
63
|
+
headers: { Origin: BASE },
|
|
64
|
+
redirect: "manual",
|
|
65
|
+
});
|
|
66
|
+
expect(res.status).toBe(302);
|
|
67
|
+
expect(res.headers.get("Location")).toBe("/");
|
|
68
|
+
});
|
|
69
|
+
|
|
45
70
|
test("GET /nonexistent returns 404", async () => {
|
|
46
71
|
const res = await fetch(`${BASE}/nonexistent`);
|
|
47
72
|
expect(res.status).toBe(404);
|
|
@@ -64,3 +89,40 @@ test("HTML includes <title> from meta()", async () => {
|
|
|
64
89
|
const html = await res.text();
|
|
65
90
|
expect(html).toContain("BractJS Test Home");
|
|
66
91
|
});
|
|
92
|
+
|
|
93
|
+
test("SSR HTML renders a real <title> tag in the document (not just the data island)", async () => {
|
|
94
|
+
const res = await fetch(`${BASE}/`);
|
|
95
|
+
const html = await res.text();
|
|
96
|
+
// Strip the <script> data island so we assert on the rendered document head,
|
|
97
|
+
// not the __BRACTJS_DATA__ JSON (which also contains the title text).
|
|
98
|
+
const withoutScripts = html.replace(/<script[\s\S]*?<\/script>/g, "");
|
|
99
|
+
expect(withoutScripts).toMatch(/<title>BractJS Test Home<\/title>/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("SSR HTML renders <meta name=description> and og:title from meta()", async () => {
|
|
103
|
+
const res = await fetch(`${BASE}/`);
|
|
104
|
+
const html = await res.text();
|
|
105
|
+
const withoutScripts = html.replace(/<script[\s\S]*?<\/script>/g, "");
|
|
106
|
+
expect(withoutScripts).toMatch(/<meta[^>]+name="description"[^>]+content="Bract test description"/);
|
|
107
|
+
expect(withoutScripts).toMatch(/<meta[^>]+property="og:title"[^>]+content="Bract OG Title"/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ── /_data auth parity (S2) ─────────────────────────────────────────────────
|
|
111
|
+
// beforeLoad() is the documented contract point for auth. It MUST run for the
|
|
112
|
+
// /_data soft-nav JSON endpoint exactly as it does for a full-page GET, so a
|
|
113
|
+
// gated route cannot leak its loader data as JSON via /_data.
|
|
114
|
+
|
|
115
|
+
test("full-page GET of a beforeLoad-gated route is blocked (403)", async () => {
|
|
116
|
+
const res = await fetch(`${BASE}/protected`);
|
|
117
|
+
expect(res.status).toBe(403);
|
|
118
|
+
const body = await res.text();
|
|
119
|
+
expect(body).not.toContain("TOP-SECRET-LOADER-DATA");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("/_data of a beforeLoad-gated route is blocked and never leaks loader data", async () => {
|
|
123
|
+
const res = await fetch(`${BASE}/_data?path=/protected`);
|
|
124
|
+
// Same gate as the full-page GET — beforeLoad short-circuits before loaders.
|
|
125
|
+
expect(res.status).toBe(403);
|
|
126
|
+
const body = await res.text();
|
|
127
|
+
expect(body).not.toContain("TOP-SECRET-LOADER-DATA");
|
|
128
|
+
});
|
|
@@ -15,11 +15,21 @@ const blogLayoutModule = { default: () => "blog-layout" } as const;
|
|
|
15
15
|
const blogPostModule = { default: () => "blog-post" } as const;
|
|
16
16
|
const indexModule = { default: () => "index" } as const;
|
|
17
17
|
|
|
18
|
+
// Module exporting the security-sensitive gate (beforeLoad) + context factory.
|
|
19
|
+
const guardBeforeLoad = () => new Response("Forbidden", { status: 403 });
|
|
20
|
+
const guardContextFactory = { _factory: () => ({ user: null }) };
|
|
21
|
+
const guardedModule = {
|
|
22
|
+
default: () => "guarded",
|
|
23
|
+
beforeLoad: guardBeforeLoad,
|
|
24
|
+
context: guardContextFactory,
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
18
27
|
const registry: ModuleRegistry = {
|
|
19
28
|
"root.tsx": rootModule,
|
|
20
29
|
"routes/blog/layout.tsx": blogLayoutModule,
|
|
21
30
|
"routes/blog/[slug].tsx": blogPostModule,
|
|
22
31
|
"routes/_index.tsx": indexModule,
|
|
32
|
+
"routes/guarded.tsx": guardedModule,
|
|
23
33
|
};
|
|
24
34
|
|
|
25
35
|
beforeAll(async () => {
|
|
@@ -92,4 +102,17 @@ describe("resolveRouteChain — registry mode", () => {
|
|
|
92
102
|
);
|
|
93
103
|
expect(chain.route.default).toBe(blogPostModule.default);
|
|
94
104
|
});
|
|
105
|
+
|
|
106
|
+
// SECURITY(high) regression guard: beforeLoad (the auth gate) and the context
|
|
107
|
+
// factory must survive module projection. Dropping them silently disables
|
|
108
|
+
// every beforeLoad() — see importRouteModule/pickRouteModule in layout.ts.
|
|
109
|
+
test("projects beforeLoad and context factory through registry mode", async () => {
|
|
110
|
+
const chain = await resolveRouteChain(
|
|
111
|
+
{ filePath: "routes/guarded.tsx", urlPattern: "guarded", segments: ["guarded"] },
|
|
112
|
+
"/nonexistent",
|
|
113
|
+
registry,
|
|
114
|
+
);
|
|
115
|
+
expect(chain.route.beforeLoad).toBe(guardBeforeLoad);
|
|
116
|
+
expect((chain.route as { context?: unknown }).context).toBe(guardContextFactory);
|
|
117
|
+
});
|
|
95
118
|
});
|
|
@@ -77,6 +77,29 @@ describe("runLoaders", () => {
|
|
|
77
77
|
expect(results.root).toMatchObject({ __error: { message: expect.any(String) } });
|
|
78
78
|
expect(results.route).toEqual({ ok: true });
|
|
79
79
|
});
|
|
80
|
+
|
|
81
|
+
test("runs the route loader concurrently with layout loaders (not serialized after)", async () => {
|
|
82
|
+
// Each loader records when it started relative to the others. If the route
|
|
83
|
+
// loader were serialized after the layout wave (the old behavior), its
|
|
84
|
+
// start would be later than the layout loader's *finish*. With true
|
|
85
|
+
// parallelism, all three observe each other as already-started.
|
|
86
|
+
let started = 0;
|
|
87
|
+
let maxConcurrent = 0;
|
|
88
|
+
const enter = async () => {
|
|
89
|
+
started++;
|
|
90
|
+
maxConcurrent = Math.max(maxConcurrent, started);
|
|
91
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
92
|
+
started--;
|
|
93
|
+
};
|
|
94
|
+
const chain: LayoutChain = {
|
|
95
|
+
root: { ...emptyModule, loader: async () => { await enter(); return { root: true }; } },
|
|
96
|
+
layouts: [{ ...emptyModule, loader: async () => { await enter(); return { layout: true }; } }],
|
|
97
|
+
route: { ...emptyModule, loader: async () => { await enter(); return { route: true }; } },
|
|
98
|
+
};
|
|
99
|
+
await runLoaders(chain, stubArgs);
|
|
100
|
+
// All three loaders must be in flight at the same time.
|
|
101
|
+
expect(maxConcurrent).toBe(3);
|
|
102
|
+
});
|
|
80
103
|
});
|
|
81
104
|
|
|
82
105
|
describe("buildLoaderArgs", () => {
|
|
@@ -213,4 +213,26 @@ describe("requestLogger()", () => {
|
|
|
213
213
|
expect(logs[0]).toContain("/hello");
|
|
214
214
|
expect(logs[0]).toContain("200");
|
|
215
215
|
});
|
|
216
|
+
|
|
217
|
+
// SECURITY(medium) regression guard: the query string can carry tokens
|
|
218
|
+
// (password-reset links, OAuth codes). requestLogger must log only the
|
|
219
|
+
// pathname, never the search params.
|
|
220
|
+
test("never logs the query string (token leak guard)", async () => {
|
|
221
|
+
const logs: string[] = [];
|
|
222
|
+
const original = console.log;
|
|
223
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
|
224
|
+
const mw = requestLogger();
|
|
225
|
+
const ctx = makeCtx({
|
|
226
|
+
request: new Request("http://localhost/reset?token=SUPER_SECRET_TOKEN&code=abc123"),
|
|
227
|
+
});
|
|
228
|
+
const pipeline = new MiddlewarePipeline();
|
|
229
|
+
pipeline.use(mw);
|
|
230
|
+
await pipeline.run(ctx, ok200);
|
|
231
|
+
console.log = original;
|
|
232
|
+
const line = logs.join("\n");
|
|
233
|
+
expect(line).toContain("/reset");
|
|
234
|
+
expect(line).not.toContain("SUPER_SECRET_TOKEN");
|
|
235
|
+
expect(line).not.toContain("token=");
|
|
236
|
+
expect(line).not.toContain("abc123");
|
|
237
|
+
});
|
|
216
238
|
});
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* bun:test to exit before printing results — a known pre-existing issue.
|
|
7
7
|
* Behavioral coverage (HTTP response, HMR) lives in integration.test.ts.
|
|
8
8
|
*/
|
|
9
|
-
import { test, expect } from "bun:test";
|
|
10
|
-
import { loadUserConfig } from "../config/load.ts";
|
|
9
|
+
import { test, expect, describe } from "bun:test";
|
|
10
|
+
import { loadUserConfig, validateUserConfig } from "../config/load.ts";
|
|
11
11
|
import { runBuild } from "../build/bundler.ts";
|
|
12
12
|
import { createDevServer } from "../dev/server.ts";
|
|
13
13
|
import type { BuildConfig } from "../build/bundler.ts";
|
|
@@ -26,6 +26,45 @@ test("loadUserConfig returns an object when no bractjs.config.ts exists", async
|
|
|
26
26
|
expect(cfg).not.toBeNull();
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
+
describe("validateUserConfig", () => {
|
|
30
|
+
test("accepts an empty object", () => {
|
|
31
|
+
expect(validateUserConfig({})).toEqual({});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("accepts a well-formed config", () => {
|
|
35
|
+
const cfg = {
|
|
36
|
+
port: 4000,
|
|
37
|
+
appDir: "./app",
|
|
38
|
+
minify: false,
|
|
39
|
+
sourcemap: "inline" as const,
|
|
40
|
+
clientEnv: ["PUBLIC_API_URL"],
|
|
41
|
+
};
|
|
42
|
+
expect(validateUserConfig(cfg)).toBe(cfg);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("rejects a non-object export", () => {
|
|
46
|
+
expect(() => validateUserConfig("nope")).toThrow(/must be a config object/);
|
|
47
|
+
expect(() => validateUserConfig([])).toThrow(/must be a config object/);
|
|
48
|
+
expect(() => validateUserConfig(null)).toThrow(/must be a config object/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("rejects a string port", () => {
|
|
52
|
+
expect(() => validateUserConfig({ port: "3000" })).toThrow(/"port" must be a finite number/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("rejects a non-array clientEnv", () => {
|
|
56
|
+
expect(() => validateUserConfig({ clientEnv: "PUBLIC_API_URL" })).toThrow(/"clientEnv" must be an array/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("rejects an invalid sourcemap value", () => {
|
|
60
|
+
expect(() => validateUserConfig({ sourcemap: "yes" })).toThrow(/"sourcemap" must be/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("ignores unknown keys and undefined values", () => {
|
|
64
|
+
expect(() => validateUserConfig({ port: undefined, somethingCustom: 1 })).not.toThrow();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
29
68
|
// ── runBuild ──────────────────────────────────────────────────────────────
|
|
30
69
|
|
|
31
70
|
test("runBuild is exported from build/bundler", () => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { test, expect, describe } from "bun:test";
|
|
2
|
-
import { redirect, json, error } from "../server/response.ts";
|
|
2
|
+
import { redirect, json, error, sanitizeRedirect } from "../server/response.ts";
|
|
3
3
|
|
|
4
4
|
describe("redirect", () => {
|
|
5
5
|
test("returns 302 by default with Location header", () => {
|
|
@@ -33,6 +33,19 @@ describe("redirect", () => {
|
|
|
33
33
|
expect(() => redirect("/\\evil.com")).toThrow();
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
+
test("rejects percent-encoded authority escapes (%2f, %5c)", () => {
|
|
37
|
+
expect(() => redirect("/%2f%2fevil.com")).toThrow();
|
|
38
|
+
expect(() => redirect("/%2F/evil.com")).toThrow();
|
|
39
|
+
expect(() => redirect("/%5cevil.com")).toThrow();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("rejects control/whitespace-prefixed escapes browsers normalize", () => {
|
|
43
|
+
expect(() => redirect("/\t//evil.com")).toThrow();
|
|
44
|
+
expect(() => redirect("/\n/evil.com")).toThrow();
|
|
45
|
+
expect(() => redirect("/ /evil.com")).toThrow();
|
|
46
|
+
expect(() => redirect("\t//evil.com")).toThrow();
|
|
47
|
+
});
|
|
48
|
+
|
|
36
49
|
test("rejects javascript: and data: schemes", () => {
|
|
37
50
|
expect(() => redirect("javascript:alert(1)")).toThrow();
|
|
38
51
|
expect(() => redirect("data:text/html,x")).toThrow();
|
|
@@ -50,6 +63,46 @@ describe("redirect", () => {
|
|
|
50
63
|
});
|
|
51
64
|
});
|
|
52
65
|
|
|
66
|
+
describe("sanitizeRedirect", () => {
|
|
67
|
+
const reqUrl = "https://app.example/page";
|
|
68
|
+
|
|
69
|
+
test("passes non-redirect responses through untouched", () => {
|
|
70
|
+
const res = json({ ok: true }, { status: 200 });
|
|
71
|
+
expect(sanitizeRedirect(res, reqUrl)).toBe(res);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("passes same-origin relative Location through", () => {
|
|
75
|
+
const res = new Response(null, { status: 302, headers: { Location: "/dashboard" } });
|
|
76
|
+
const out = sanitizeRedirect(res, reqUrl);
|
|
77
|
+
expect(out.status).toBe(302);
|
|
78
|
+
expect(out.headers.get("Location")).toBe("/dashboard");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("passes same-origin absolute Location through", () => {
|
|
82
|
+
const res = new Response(null, { status: 302, headers: { Location: "https://app.example/x" } });
|
|
83
|
+
expect(sanitizeRedirect(res, reqUrl).headers.get("Location")).toBe("https://app.example/x");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("blocks raw off-origin Location → 500, no Location", () => {
|
|
87
|
+
const res = new Response(null, { status: 302, headers: { Location: "https://evil.com" } });
|
|
88
|
+
const out = sanitizeRedirect(res, reqUrl);
|
|
89
|
+
expect(out.status).toBe(500);
|
|
90
|
+
expect(out.headers.get("Location")).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("blocks raw protocol-relative Location", () => {
|
|
94
|
+
const res = new Response(null, { status: 302, headers: { Location: "//evil.com/x" } });
|
|
95
|
+
expect(sanitizeRedirect(res, reqUrl).status).toBe(500);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("respects allowExternal opt-in branding from redirect()", () => {
|
|
99
|
+
const res = redirect("https://allowed.example/cb", 302, undefined, { allowExternal: true });
|
|
100
|
+
const out = sanitizeRedirect(res, reqUrl);
|
|
101
|
+
expect(out.status).toBe(302);
|
|
102
|
+
expect(out.headers.get("Location")).toBe("https://allowed.example/cb");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
53
106
|
describe("json", () => {
|
|
54
107
|
test("sets Content-Type to application/json", () => {
|
|
55
108
|
const res = json({ ok: true });
|
|
@@ -11,6 +11,7 @@ import { createCookieSession } from "../server/session.ts";
|
|
|
11
11
|
import { MiddlewarePipeline, type MiddlewareContext } from "../server/middleware.ts";
|
|
12
12
|
import { serveStatic } from "../server/static.ts";
|
|
13
13
|
import { handleImageRequest } from "../image/handler.ts";
|
|
14
|
+
import { isAllowedMutation } from "../server/csrf.ts";
|
|
14
15
|
|
|
15
16
|
const ACTION_TMP = resolve(import.meta.dir, ".tmp-security-action");
|
|
16
17
|
let registeredActionId = "";
|
|
@@ -167,6 +168,40 @@ describe("CSRF — cross-origin mutation", () => {
|
|
|
167
168
|
});
|
|
168
169
|
});
|
|
169
170
|
|
|
171
|
+
describe("CSRF — Sec-Fetch-Site (isAllowedMutation)", () => {
|
|
172
|
+
function req(headers: Record<string, string>): Request {
|
|
173
|
+
return new Request("http://localhost/_action?id=abc", { method: "POST", headers });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
test("Sec-Fetch-Site: cross-site is rejected even with a forged X-BractJS-Action header", () => {
|
|
177
|
+
expect(isAllowedMutation(req({ "Sec-Fetch-Site": "cross-site", "X-BractJS-Action": "1" }))).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("Sec-Fetch-Site: same-site is rejected even with a forged X-BractJS-Action header", () => {
|
|
181
|
+
expect(isAllowedMutation(req({ "Sec-Fetch-Site": "same-site", "X-BractJS-Action": "1" }))).toBe(false);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("Sec-Fetch-Site: cross-site is rejected even with a matching Origin", () => {
|
|
185
|
+
expect(isAllowedMutation(req({ "Sec-Fetch-Site": "cross-site", Origin: "http://localhost" }))).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("Sec-Fetch-Site: same-origin with custom header is allowed", () => {
|
|
189
|
+
expect(isAllowedMutation(req({ "Sec-Fetch-Site": "same-origin", "X-BractJS-Action": "1" }))).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("Sec-Fetch-Site: none (direct navigation) with same-origin Origin is allowed", () => {
|
|
193
|
+
expect(isAllowedMutation(req({ "Sec-Fetch-Site": "none", Origin: "http://localhost" }))).toBe(true);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("no headers at all is rejected (non-browser client must opt in)", () => {
|
|
197
|
+
expect(isAllowedMutation(req({}))).toBe(false);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("same-origin Sec-Fetch-Site with no Origin and no custom header is allowed", () => {
|
|
201
|
+
expect(isAllowedMutation(req({ "Sec-Fetch-Site": "same-origin" }))).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
170
205
|
// ── Item 5 — safeStringify ───────────────────────────────────────────────
|
|
171
206
|
|
|
172
207
|
describe("safeStringify", () => {
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { test, expect, describe, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { serverModuleStubPlugin, serverOnlyPlugin } from "../build/env-plugin.ts";
|
|
6
|
+
|
|
7
|
+
let dir = "";
|
|
8
|
+
|
|
9
|
+
beforeAll(async () => {
|
|
10
|
+
dir = join(tmpdir(), `bract-server-stub-${Date.now()}`);
|
|
11
|
+
await mkdir(dir, { recursive: true });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterAll(async () => {
|
|
15
|
+
await rm(dir, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
async function bundleClient(entry: string, plugin: typeof serverModuleStubPlugin): Promise<string> {
|
|
19
|
+
const out = await Bun.build({
|
|
20
|
+
entrypoints: [entry],
|
|
21
|
+
target: "browser",
|
|
22
|
+
minify: false,
|
|
23
|
+
plugins: [plugin],
|
|
24
|
+
});
|
|
25
|
+
if (!out.success) throw new Error(out.logs.join("\n"));
|
|
26
|
+
return await out.outputs[0].text();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// A realistic server module: imports a Bun builtin and exposes DB-ish helpers.
|
|
30
|
+
const SERVER_SRC = `import { Database } from "bun:sqlite";
|
|
31
|
+
const db = new Database(":memory:");
|
|
32
|
+
db.run("CREATE TABLE todos (id TEXT, secret TEXT)");
|
|
33
|
+
export const SUPER_SECRET = "do-not-ship-me";
|
|
34
|
+
export function listTodos() { return db.query("SELECT * FROM todos").all(); }
|
|
35
|
+
export async function addTodo(title) { db.run("INSERT INTO todos VALUES (?, ?)", [title, SUPER_SECRET]); }
|
|
36
|
+
export default { db };
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
describe("serverModuleStubPlugin", () => {
|
|
40
|
+
test("a route importing a *.server.ts (with bun:sqlite) builds instead of hard-failing", async () => {
|
|
41
|
+
const server = join(dir, "store.server.ts");
|
|
42
|
+
await writeFile(server, SERVER_SRC);
|
|
43
|
+
const route = join(dir, "route-ok.tsx");
|
|
44
|
+
await writeFile(
|
|
45
|
+
route,
|
|
46
|
+
`import { addTodo, listTodos } from "./store.server.ts";
|
|
47
|
+
export async function loader() { return { todos: listTodos() }; }
|
|
48
|
+
export async function action(fd) { await addTodo(fd.get("title")); return {}; }
|
|
49
|
+
export default function Page() { return null; }
|
|
50
|
+
`,
|
|
51
|
+
);
|
|
52
|
+
// Must not throw — the old serverOnlyPlugin would reject this import.
|
|
53
|
+
const code = await bundleClient(route, serverModuleStubPlugin);
|
|
54
|
+
expect(code).toContain("__bractServerStub");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("zero server source reaches the client bundle", async () => {
|
|
58
|
+
const server = join(dir, "store2.server.ts");
|
|
59
|
+
await writeFile(server, SERVER_SRC);
|
|
60
|
+
const route = join(dir, "route-leak.tsx");
|
|
61
|
+
await writeFile(
|
|
62
|
+
route,
|
|
63
|
+
`import { addTodo, listTodos, SUPER_SECRET } from "./store2.server.ts";
|
|
64
|
+
export async function loader() { return { todos: listTodos(), s: SUPER_SECRET }; }
|
|
65
|
+
export async function action(fd) { await addTodo(fd.get("title")); return {}; }
|
|
66
|
+
export default function Page() { return null; }
|
|
67
|
+
`,
|
|
68
|
+
);
|
|
69
|
+
const code = await bundleClient(route, serverModuleStubPlugin);
|
|
70
|
+
// None of the server internals may appear in the client output.
|
|
71
|
+
expect(code).not.toContain("bun:sqlite");
|
|
72
|
+
expect(code).not.toContain("do-not-ship-me");
|
|
73
|
+
expect(code).not.toContain("INSERT INTO");
|
|
74
|
+
expect(code).not.toContain("CREATE TABLE");
|
|
75
|
+
expect(code).not.toContain("new Database");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("named + default exports are preserved as resolvable stubs", async () => {
|
|
79
|
+
const server = join(dir, "store3.server.ts");
|
|
80
|
+
await writeFile(server, SERVER_SRC);
|
|
81
|
+
const route = join(dir, "route-default.tsx");
|
|
82
|
+
await writeFile(
|
|
83
|
+
route,
|
|
84
|
+
`import store, { listTodos } from "./store3.server.ts";
|
|
85
|
+
export const a = typeof listTodos;
|
|
86
|
+
export const b = typeof store;
|
|
87
|
+
export default function Page() { return null; }
|
|
88
|
+
`,
|
|
89
|
+
);
|
|
90
|
+
// If default/named stubs weren't emitted, the bundle would fail to resolve.
|
|
91
|
+
const code = await bundleClient(route, serverModuleStubPlugin);
|
|
92
|
+
expect(code).toContain("__bractServerStub");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a stub throws a clear error if actually invoked on the client", async () => {
|
|
96
|
+
const server = join(dir, "store4.server.ts");
|
|
97
|
+
await writeFile(server, SERVER_SRC);
|
|
98
|
+
// Bundle just the server module so we can import and exercise the stub.
|
|
99
|
+
const out = await Bun.build({
|
|
100
|
+
entrypoints: [server],
|
|
101
|
+
target: "browser",
|
|
102
|
+
minify: false,
|
|
103
|
+
format: "esm",
|
|
104
|
+
plugins: [serverModuleStubPlugin],
|
|
105
|
+
});
|
|
106
|
+
if (!out.success) throw new Error(out.logs.join("\n"));
|
|
107
|
+
const js = await out.outputs[0].text();
|
|
108
|
+
// Import the bundled stub via a data: URL so we exercise the real emitted
|
|
109
|
+
// code without depending on filesystem module resolution.
|
|
110
|
+
const dataUrl = "data:text/javascript;base64," + Buffer.from(js).toString("base64");
|
|
111
|
+
const mod = (await import(dataUrl)) as { addTodo: (...a: unknown[]) => unknown };
|
|
112
|
+
expect(() => mod.addTodo("x")).toThrow(/server\.ts|not.*available in the browser/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("legacy serverOnlyPlugin still hard-fails the same import", async () => {
|
|
116
|
+
const server = join(dir, "store5.server.ts");
|
|
117
|
+
await writeFile(server, SERVER_SRC);
|
|
118
|
+
const route = join(dir, "route-legacy.tsx");
|
|
119
|
+
// The import must be *used* (and have a side effect Bun can't drop), or Bun
|
|
120
|
+
// tree-shakes the unused import and never loads the server module — meaning
|
|
121
|
+
// the guard's throwing onLoad never fires.
|
|
122
|
+
await writeFile(
|
|
123
|
+
route,
|
|
124
|
+
`import { listTodos } from "./store5.server.ts";
|
|
125
|
+
export async function loader() { return { todos: listTodos() }; }
|
|
126
|
+
export default function Page() { return null; }
|
|
127
|
+
`,
|
|
128
|
+
);
|
|
129
|
+
// The guard's onLoad throws when the (used) server import is loaded. Bun
|
|
130
|
+
// surfaces that as a rejected build, so assert the build does not succeed.
|
|
131
|
+
let failed = false;
|
|
132
|
+
try {
|
|
133
|
+
const out = await Bun.build({
|
|
134
|
+
entrypoints: [route],
|
|
135
|
+
target: "browser",
|
|
136
|
+
minify: false,
|
|
137
|
+
plugins: [serverOnlyPlugin],
|
|
138
|
+
});
|
|
139
|
+
failed = !out.success;
|
|
140
|
+
} catch {
|
|
141
|
+
failed = true; // onLoad throw propagated as a rejection
|
|
142
|
+
}
|
|
143
|
+
expect(failed).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { handleStreamRequest } from "../server/stream-handler.ts";
|
|
3
|
+
|
|
4
|
+
const VALID_ID = "0123456789abcdef"; // 16 lowercase hex chars (passes the id regex)
|
|
5
|
+
|
|
6
|
+
function streamReq(headers: Record<string, string>, id = VALID_ID): Request {
|
|
7
|
+
return new Request(`http://localhost/_stream?id=${id}`, { method: "GET", headers });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("handleStreamRequest — CSRF gate", () => {
|
|
11
|
+
test("non-/_stream path returns null (falls through)", async () => {
|
|
12
|
+
const res = await handleStreamRequest(new Request("http://localhost/other"));
|
|
13
|
+
expect(res).toBeNull();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("missing X-BractJS-Action → 403 even with a same-origin Origin", async () => {
|
|
17
|
+
const res = await handleStreamRequest(streamReq({ Origin: "http://localhost" }));
|
|
18
|
+
expect(res?.status).toBe(403);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("missing X-BractJS-Action → 403 with no headers", async () => {
|
|
22
|
+
const res = await handleStreamRequest(streamReq({}));
|
|
23
|
+
expect(res?.status).toBe(403);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("with X-BractJS-Action but unknown id → 404 (passes the gate)", async () => {
|
|
27
|
+
const res = await handleStreamRequest(streamReq({ "X-BractJS-Action": "1" }));
|
|
28
|
+
// Gate passed; unknown action id resolves to 404 (not 403).
|
|
29
|
+
expect(res?.status).toBe(404);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("with X-BractJS-Action but malformed id → 400 (passes the gate)", async () => {
|
|
33
|
+
const res = await handleStreamRequest(streamReq({ "X-BractJS-Action": "1" }, "NOT-HEX"));
|
|
34
|
+
expect(res?.status).toBe(400);
|
|
35
|
+
});
|
|
36
|
+
});
|