@swissjs/swite 0.4.1 → 0.4.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/__tests__/import-rewriter-bug.test.ts +122 -122
  3. package/__tests__/security-r001-r002.test.ts +190 -190
  4. package/dist/cli.js +0 -0
  5. package/dist/dev-engine/hmr/hmr-client-template.js +111 -111
  6. package/dist/dev-engine/middleware/middleware-setup.js +4 -3
  7. package/docs/architecture/build-pipeline.md +97 -97
  8. package/docs/architecture/dev-server.md +87 -87
  9. package/docs/architecture/hmr.md +78 -78
  10. package/docs/architecture/import-rewriting.md +101 -101
  11. package/docs/architecture/index.md +16 -16
  12. package/docs/architecture/python-integration.md +93 -93
  13. package/docs/architecture/resolution.md +92 -92
  14. package/docs/cli/build.md +78 -78
  15. package/docs/cli/dev.md +90 -90
  16. package/docs/cli/index.md +15 -15
  17. package/docs/cli/start.md +45 -45
  18. package/docs/development/contributing.md +74 -74
  19. package/docs/development/index.md +12 -12
  20. package/docs/development/internals.md +101 -101
  21. package/docs/guide/configuration.md +89 -89
  22. package/docs/guide/index.md +13 -13
  23. package/docs/guide/project-structure.md +75 -75
  24. package/docs/guide/quickstart.md +113 -113
  25. package/docs/index.md +16 -16
  26. package/package.json +10 -9
  27. package/src/config/env.ts +98 -98
  28. package/src/dev-engine/handlers/ui-handler.ts +30 -30
  29. package/src/dev-engine/handlers/uix-handler.ts +21 -21
  30. package/src/dev-engine/hmr/hmr-client-template.ts +122 -122
  31. package/src/dev-engine/middleware/middleware-setup.ts +354 -354
  32. package/src/dev-engine/middleware/static-files.ts +813 -813
  33. package/src/resolution/cdn/cdn-fallback.ts +40 -40
  34. package/src/resolution/path/path-fixup.ts +27 -27
  35. package/src/resolution/rewriting/import-rewriter.ts +237 -237
  36. package/src/resolution/symlink-registry.ts +114 -114
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.2
4
+
5
+ ### Patch Changes
6
+
7
+ - fix(security): update @swissjs/\* deps to 1.2.1/1.2.3, add pnpm security overrides, fix build script to use standalone tsc (no project references), eliminate as-any casts in middleware
8
+
3
9
  ## 0.4.1
4
10
 
5
11
  ### Patch Changes
@@ -15,6 +21,7 @@
15
21
  - fix(S-03): Python service health check timeout corrected from 15s → 30s per DIRECTIVE spec. Python services with slow startup (e.g. model loading, DB connection pool warmup) were being killed before they became healthy.
16
22
 
17
23
  - fix(css-modules): CSS import handling in the compile pipeline now distinguishes three cases:
24
+
18
25
  - Named/default imports (`import styles from "./x.module.css"`) → `const styles = {}` — no more `undefined` at runtime
19
26
  - Side-effect imports (`import "./x.css"`) → silently stripped
20
27
  - Dynamic imports (`import("./x.css")`) → `({})` instead of `undefined`
@@ -1,122 +1,122 @@
1
- import { describe, it, before } from 'node:test';
2
- import assert from 'node:assert';
3
- import { rewriteImports } from '../src/resolution/rewriting/import-rewriter.js';
4
- import { ModuleResolver } from '../src/resolution/resolver.js';
5
- import { resetPackageRegistry } from '../src/kernel/package-registry.js';
6
-
7
- // Reset the registry singleton before every describe block so tests don't
8
- // inherit a workspace scan from a prior test run. This prevents the
9
- // ModuleResolver from scanning the full filesystem (which can take 30+ minutes
10
- // when the fake root resolves to /).
11
- function makeResolver(): ModuleResolver {
12
- resetPackageRegistry();
13
- // Use the swite repo root so findWorkspaceRoot terminates quickly
14
- // (pnpm-workspace.yaml is in the parent dir, not the swite root itself,
15
- // but the resolver gracefully handles a root without a workspace file).
16
- return new ModuleResolver(new URL('../', import.meta.url).pathname);
17
- }
18
-
19
- describe('Import Rewriter — bare imports', () => {
20
- before(() => resetPackageRegistry());
21
-
22
- it('rewrites unknown bare import to /node_modules/ path', async () => {
23
- const resolver = makeResolver();
24
- const code = `import { something } from '@unknown-org/nonexistent-pkg'`;
25
- const result = await rewriteImports(code, '/fake/src/index.uix', resolver);
26
-
27
- // Package not in workspace → falls back to /node_modules/ URL
28
- assert(
29
- result.includes('/node_modules/@unknown-org/nonexistent-pkg') ||
30
- result.includes('cdn.jsdelivr.net'),
31
- `Expected /node_modules/ or CDN fallback, got: ${result}`,
32
- );
33
- // Original bare specifier is gone
34
- assert(!result.includes("'@unknown-org/nonexistent-pkg'"), 'Bare specifier should be rewritten');
35
- });
36
-
37
- it('does not create malformed imports when rewriting multiple bare imports', async () => {
38
- const resolver = makeResolver();
39
- const code = [
40
- `import { SwissApp } from '@swissjs/core'`,
41
- `import { App } from './App.uix'`,
42
- `import { PosAgent } from '@swiss-enterprise/ai-agents'`,
43
- `import { registerBusinessModules } from './modules/index.ui'`,
44
- ].join('\n');
45
-
46
- const result = await rewriteImports(code, '/fake/src/index.ui', resolver);
47
-
48
- // No malformed patterns — quote/import collision
49
- assert(!result.includes('@"'), `Malformed @" pattern found in: ${result}`);
50
- // Use [ \t]* (not \s*) — \s* would match newlines and falsely fire on consecutive
51
- // import statements on separate lines, which is perfectly valid.
52
- assert(!/from[ \t]+"[^"]*"[ \t]*import/.test(result), 'Double-quote before import keyword on same line');
53
-
54
- // Relative imports are left intact (they start with ./)
55
- assert(result.includes('./App.uix'), 'Relative .uix import should be preserved');
56
- assert(result.includes('./modules/index.ui'), 'Relative .ui import should be preserved');
57
- });
58
- });
59
-
60
- describe('Import Rewriter — relative extension fixes', () => {
61
- before(() => resetPackageRegistry());
62
-
63
- it('converts .js extension to .uix when importing from a .uix file', async () => {
64
- const resolver = makeResolver();
65
- const code = `import { updatePageTitle } from './utils/seo.js'`;
66
- const result = await rewriteImports(code, '/fake/src/App.uix', resolver);
67
-
68
- // .js imports from .uix files are rewritten to .uix (or .ui if only .ui exists)
69
- assert(
70
- result.includes('./utils/seo.uix') || result.includes('./utils/seo.ui') || result.includes('./utils/seo.ts'),
71
- `Expected extension rewrite, got: ${result}`,
72
- );
73
- assert(!result.includes('./utils/seo.js'), '.js extension should be replaced');
74
- });
75
-
76
- it('converts .js extension to .ui when importing from a .ui file', async () => {
77
- const resolver = makeResolver();
78
- const code = `import { helper } from './helpers/dom.js'`;
79
- const result = await rewriteImports(code, '/fake/src/App.ui', resolver);
80
-
81
- // .js imports from .ui files are rewritten to .ui
82
- assert(
83
- result.includes('./helpers/dom.ui') || result.includes('./helpers/dom.uix') || result.includes('./helpers/dom.ts'),
84
- `Expected extension rewrite, got: ${result}`,
85
- );
86
- assert(!result.includes('./helpers/dom.js'), '.js extension should be replaced');
87
- });
88
- });
89
-
90
- describe('Import Rewriter — CSS imports are skipped', () => {
91
- before(() => resetPackageRegistry());
92
-
93
- it('passes through CSS imports unchanged (stripping is done in base-handler)', async () => {
94
- const resolver = makeResolver();
95
- // Note: CSS stripping is done in base-handler.ts BEFORE rewriteImports is called.
96
- // rewriteImports itself skips CSS imports (does not attempt to resolve them).
97
- // This test verifies the skip-not-crash behaviour.
98
- const code = [
99
- `import { App } from './App.uix'`,
100
- `import './styles/globals.css'`,
101
- `import './styles/theme.css'`,
102
- `export default App`,
103
- ].join('\n');
104
-
105
- const result = await rewriteImports(code, '/fake/src/index.uix', resolver);
106
-
107
- // rewriteImports skips CSS — they remain in output (base-handler strips them)
108
- assert(result.includes('./App.uix'), 'Non-CSS import should still be present');
109
- // No crash — CSS present or absent, but no exception thrown
110
- });
111
- });
112
-
113
- describe('Import Rewriter — code with no imports is returned as-is', () => {
114
- before(() => resetPackageRegistry());
115
-
116
- it('returns code unchanged when there are no imports', async () => {
117
- const resolver = makeResolver();
118
- const code = `const x = 42;\nexport default x;`;
119
- const result = await rewriteImports(code, '/fake/src/mod.ts', resolver);
120
- assert.strictEqual(result, code);
121
- });
122
- });
1
+ import { describe, it, before } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { rewriteImports } from '../src/resolution/rewriting/import-rewriter.js';
4
+ import { ModuleResolver } from '../src/resolution/resolver.js';
5
+ import { resetPackageRegistry } from '../src/kernel/package-registry.js';
6
+
7
+ // Reset the registry singleton before every describe block so tests don't
8
+ // inherit a workspace scan from a prior test run. This prevents the
9
+ // ModuleResolver from scanning the full filesystem (which can take 30+ minutes
10
+ // when the fake root resolves to /).
11
+ function makeResolver(): ModuleResolver {
12
+ resetPackageRegistry();
13
+ // Use the swite repo root so findWorkspaceRoot terminates quickly
14
+ // (pnpm-workspace.yaml is in the parent dir, not the swite root itself,
15
+ // but the resolver gracefully handles a root without a workspace file).
16
+ return new ModuleResolver(new URL('../', import.meta.url).pathname);
17
+ }
18
+
19
+ describe('Import Rewriter — bare imports', () => {
20
+ before(() => resetPackageRegistry());
21
+
22
+ it('rewrites unknown bare import to /node_modules/ path', async () => {
23
+ const resolver = makeResolver();
24
+ const code = `import { something } from '@unknown-org/nonexistent-pkg'`;
25
+ const result = await rewriteImports(code, '/fake/src/index.uix', resolver);
26
+
27
+ // Package not in workspace → falls back to /node_modules/ URL
28
+ assert(
29
+ result.includes('/node_modules/@unknown-org/nonexistent-pkg') ||
30
+ result.includes('cdn.jsdelivr.net'),
31
+ `Expected /node_modules/ or CDN fallback, got: ${result}`,
32
+ );
33
+ // Original bare specifier is gone
34
+ assert(!result.includes("'@unknown-org/nonexistent-pkg'"), 'Bare specifier should be rewritten');
35
+ });
36
+
37
+ it('does not create malformed imports when rewriting multiple bare imports', async () => {
38
+ const resolver = makeResolver();
39
+ const code = [
40
+ `import { SwissApp } from '@swissjs/core'`,
41
+ `import { App } from './App.uix'`,
42
+ `import { PosAgent } from '@swiss-enterprise/ai-agents'`,
43
+ `import { registerBusinessModules } from './modules/index.ui'`,
44
+ ].join('\n');
45
+
46
+ const result = await rewriteImports(code, '/fake/src/index.ui', resolver);
47
+
48
+ // No malformed patterns — quote/import collision
49
+ assert(!result.includes('@"'), `Malformed @" pattern found in: ${result}`);
50
+ // Use [ \t]* (not \s*) — \s* would match newlines and falsely fire on consecutive
51
+ // import statements on separate lines, which is perfectly valid.
52
+ assert(!/from[ \t]+"[^"]*"[ \t]*import/.test(result), 'Double-quote before import keyword on same line');
53
+
54
+ // Relative imports are left intact (they start with ./)
55
+ assert(result.includes('./App.uix'), 'Relative .uix import should be preserved');
56
+ assert(result.includes('./modules/index.ui'), 'Relative .ui import should be preserved');
57
+ });
58
+ });
59
+
60
+ describe('Import Rewriter — relative extension fixes', () => {
61
+ before(() => resetPackageRegistry());
62
+
63
+ it('converts .js extension to .uix when importing from a .uix file', async () => {
64
+ const resolver = makeResolver();
65
+ const code = `import { updatePageTitle } from './utils/seo.js'`;
66
+ const result = await rewriteImports(code, '/fake/src/App.uix', resolver);
67
+
68
+ // .js imports from .uix files are rewritten to .uix (or .ui if only .ui exists)
69
+ assert(
70
+ result.includes('./utils/seo.uix') || result.includes('./utils/seo.ui') || result.includes('./utils/seo.ts'),
71
+ `Expected extension rewrite, got: ${result}`,
72
+ );
73
+ assert(!result.includes('./utils/seo.js'), '.js extension should be replaced');
74
+ });
75
+
76
+ it('converts .js extension to .ui when importing from a .ui file', async () => {
77
+ const resolver = makeResolver();
78
+ const code = `import { helper } from './helpers/dom.js'`;
79
+ const result = await rewriteImports(code, '/fake/src/App.ui', resolver);
80
+
81
+ // .js imports from .ui files are rewritten to .ui
82
+ assert(
83
+ result.includes('./helpers/dom.ui') || result.includes('./helpers/dom.uix') || result.includes('./helpers/dom.ts'),
84
+ `Expected extension rewrite, got: ${result}`,
85
+ );
86
+ assert(!result.includes('./helpers/dom.js'), '.js extension should be replaced');
87
+ });
88
+ });
89
+
90
+ describe('Import Rewriter — CSS imports are skipped', () => {
91
+ before(() => resetPackageRegistry());
92
+
93
+ it('passes through CSS imports unchanged (stripping is done in base-handler)', async () => {
94
+ const resolver = makeResolver();
95
+ // Note: CSS stripping is done in base-handler.ts BEFORE rewriteImports is called.
96
+ // rewriteImports itself skips CSS imports (does not attempt to resolve them).
97
+ // This test verifies the skip-not-crash behaviour.
98
+ const code = [
99
+ `import { App } from './App.uix'`,
100
+ `import './styles/globals.css'`,
101
+ `import './styles/theme.css'`,
102
+ `export default App`,
103
+ ].join('\n');
104
+
105
+ const result = await rewriteImports(code, '/fake/src/index.uix', resolver);
106
+
107
+ // rewriteImports skips CSS — they remain in output (base-handler strips them)
108
+ assert(result.includes('./App.uix'), 'Non-CSS import should still be present');
109
+ // No crash — CSS present or absent, but no exception thrown
110
+ });
111
+ });
112
+
113
+ describe('Import Rewriter — code with no imports is returned as-is', () => {
114
+ before(() => resetPackageRegistry());
115
+
116
+ it('returns code unchanged when there are no imports', async () => {
117
+ const resolver = makeResolver();
118
+ const code = `const x = 42;\nexport default x;`;
119
+ const result = await rewriteImports(code, '/fake/src/mod.ts', resolver);
120
+ assert.strictEqual(result, code);
121
+ });
122
+ });
@@ -1,190 +1,190 @@
1
- /**
2
- * Regression tests for security fixes R-001 and R-002.
3
- *
4
- * R-001: Dev server must bind to the literal requested host; "localhost" must
5
- * NOT be silently rewritten to "0.0.0.0".
6
- *
7
- * R-002: HMR WebSocket server must reject connections from disallowed origins.
8
- *
9
- * Both tests use only the public constructor/method surface — no internal
10
- * mocking required — and are runnable with:
11
- *
12
- * node --import tsx --test __tests__/security-r001-r002.test.ts
13
- */
14
-
15
- import { describe, it } from "node:test";
16
- import assert from "node:assert/strict";
17
-
18
- // ---------------------------------------------------------------------------
19
- // R-001 — host binding logic
20
- // ---------------------------------------------------------------------------
21
- // We test the decision logic extracted from SwiteServer. The full server
22
- // cannot be started in a unit test (requires filesystem, network, Express,
23
- // etc.) so we replicate the binding expression from server.ts:171 directly.
24
- // Any future regression (reintroducing the localhost→0.0.0.0 rewrite) will
25
- // break these assertions before it reaches production.
26
-
27
- function resolveBind(configHost: string): string {
28
- // This is the expression that lives in server.ts after the R-001 fix:
29
- // const bindHost = this.config.host;
30
- // i.e. the host is used literally with NO rewriting.
31
- return configHost;
32
- }
33
-
34
- // We also test the WRONG old behaviour to confirm the test would catch it.
35
- function resolveBindOLD(configHost: string): string {
36
- // This was the pre-fix expression (the bug):
37
- // const bindHost = host === "localhost" ? "0.0.0.0" : host;
38
- return configHost === "localhost" ? "0.0.0.0" : configHost;
39
- }
40
-
41
- describe("R-001 — dev server bind host", () => {
42
- it("default (localhost) stays as localhost — not rewritten to 0.0.0.0", () => {
43
- const result = resolveBind("localhost");
44
- assert.strictEqual(result, "localhost", "localhost must not be rewritten to 0.0.0.0");
45
- assert.notStrictEqual(result, "0.0.0.0", "loopback request must stay loopback");
46
- });
47
-
48
- it("127.0.0.1 stays as 127.0.0.1", () => {
49
- assert.strictEqual(resolveBind("127.0.0.1"), "127.0.0.1");
50
- });
51
-
52
- it("::1 (IPv6 loopback) stays as ::1", () => {
53
- assert.strictEqual(resolveBind("::1"), "::1");
54
- });
55
-
56
- it("explicit 0.0.0.0 opt-in is honoured (all-interfaces bind)", () => {
57
- // This is the ONLY case where 0.0.0.0 should appear.
58
- assert.strictEqual(resolveBind("0.0.0.0"), "0.0.0.0");
59
- });
60
-
61
- it("explicit non-loopback host (e.g. 192.168.1.5) is honoured", () => {
62
- assert.strictEqual(resolveBind("192.168.1.5"), "192.168.1.5");
63
- });
64
-
65
- // -------------------------------------------------------------------------
66
- // Regression guard: prove the old (buggy) implementation would FAIL above.
67
- // If someone accidentally re-introduces the old logic these assertions flip.
68
- // -------------------------------------------------------------------------
69
- it("[old-behaviour guard] OLD code wrongly rewrites localhost → 0.0.0.0", () => {
70
- // The old code produced 0.0.0.0 for localhost — that is the bug.
71
- assert.strictEqual(
72
- resolveBindOLD("localhost"),
73
- "0.0.0.0",
74
- "Confirm the old buggy expression for documentation purposes",
75
- );
76
- });
77
-
78
- it("[old-behaviour guard] OLD code would NOT produce localhost for localhost", () => {
79
- assert.notStrictEqual(
80
- resolveBindOLD("localhost"),
81
- "localhost",
82
- "Under the old code, localhost was never kept as localhost",
83
- );
84
- });
85
- });
86
-
87
- // ---------------------------------------------------------------------------
88
- // R-002 — HMR WebSocket Origin validation
89
- // ---------------------------------------------------------------------------
90
- // We test `isOriginAllowed` by extracting its logic into a standalone
91
- // function that mirrors the implementation in hmr.ts exactly. The actual
92
- // HMREngine class is not instantiated (it tries to bind a network port in
93
- // the constructor area which is inappropriate for unit tests).
94
-
95
- function buildAllowedOriginsSet(allowedOrigins: string[]): Set<string> {
96
- return new Set(allowedOrigins);
97
- }
98
-
99
- function isOriginAllowed(allowedOrigins: Set<string>, origin: string | undefined): boolean {
100
- if (!origin) return false;
101
- const normalise = (o: string) => o.replace(/\/$/, "").toLowerCase();
102
- const candidate = normalise(origin);
103
- for (const allowed of allowedOrigins) {
104
- if (normalise(allowed) === candidate) return true;
105
- }
106
- return false;
107
- }
108
-
109
- /** Mirrors SwiteServer.buildHmrAllowedOrigins() from server.ts */
110
- function buildHmrAllowedOrigins(host: string, port: number): string[] {
111
- const origins: string[] = [];
112
- const add = (h: string) => origins.push(`http://${h}:${port}`);
113
- add(host);
114
- if (host === "localhost") add("127.0.0.1");
115
- else if (host === "127.0.0.1") add("localhost");
116
- return origins;
117
- }
118
-
119
- describe("R-002 — HMR WebSocket origin validation", () => {
120
- // -------------------------------------------------------------------------
121
- // Default dev setup: host=localhost, port=3000
122
- // -------------------------------------------------------------------------
123
- it("allows same-origin connection from http://localhost:3000", () => {
124
- const origins = buildAllowedOriginsSet(buildHmrAllowedOrigins("localhost", 3000));
125
- assert.ok(isOriginAllowed(origins, "http://localhost:3000"), "same-origin must be allowed");
126
- });
127
-
128
- it("also allows http://127.0.0.1:3000 when configured host is localhost", () => {
129
- // Browser may send the numeric form depending on what user typed.
130
- const origins = buildAllowedOriginsSet(buildHmrAllowedOrigins("localhost", 3000));
131
- assert.ok(isOriginAllowed(origins, "http://127.0.0.1:3000"), "loopback alias must be allowed");
132
- });
133
-
134
- it("allows http://localhost:3000 when configured host is 127.0.0.1", () => {
135
- const origins = buildAllowedOriginsSet(buildHmrAllowedOrigins("127.0.0.1", 3000));
136
- assert.ok(isOriginAllowed(origins, "http://localhost:3000"));
137
- });
138
-
139
- it("rejects a foreign origin (cross-site WebSocket hijack attempt)", () => {
140
- const origins = buildAllowedOriginsSet(buildHmrAllowedOrigins("localhost", 3000));
141
- assert.ok(
142
- !isOriginAllowed(origins, "http://evil.example.com"),
143
- "foreign origin must be rejected",
144
- );
145
- });
146
-
147
- it("rejects a subdomain of localhost (not the same origin)", () => {
148
- const origins = buildAllowedOriginsSet(buildHmrAllowedOrigins("localhost", 3000));
149
- assert.ok(!isOriginAllowed(origins, "http://sub.localhost:3000"), "subdomain must be rejected");
150
- });
151
-
152
- it("rejects a connection on the correct host but wrong port", () => {
153
- const origins = buildAllowedOriginsSet(buildHmrAllowedOrigins("localhost", 3000));
154
- assert.ok(
155
- !isOriginAllowed(origins, "http://localhost:9000"),
156
- "different port must be rejected",
157
- );
158
- });
159
-
160
- it("rejects a connection with no Origin header (non-browser WebSocket client)", () => {
161
- const origins = buildAllowedOriginsSet(buildHmrAllowedOrigins("localhost", 3000));
162
- assert.ok(!isOriginAllowed(origins, undefined), "absent Origin must be rejected");
163
- });
164
-
165
- it("rejects an empty-string Origin", () => {
166
- const origins = buildAllowedOriginsSet(buildHmrAllowedOrigins("localhost", 3000));
167
- assert.ok(!isOriginAllowed(origins, ""), "empty Origin must be rejected");
168
- });
169
-
170
- it("origin check is case-insensitive on scheme and host", () => {
171
- const origins = buildAllowedOriginsSet(buildHmrAllowedOrigins("localhost", 3000));
172
- // RFC 6454 §6.1: scheme+host are case-insensitive
173
- assert.ok(isOriginAllowed(origins, "HTTP://LOCALHOST:3000"), "case-insensitive match must work");
174
- });
175
-
176
- it("explicit 0.0.0.0 dev server only adds 0.0.0.0 as allowed origin (not localhost)", () => {
177
- // When the dev explicitly opted in to 0.0.0.0 binding, only that literal
178
- // host is in the origin allowlist — "localhost" is NOT auto-added.
179
- const origins = buildHmrAllowedOrigins("0.0.0.0", 3000);
180
- assert.ok(origins.includes("http://0.0.0.0:3000"), "0.0.0.0 origin present");
181
- assert.ok(!origins.includes("http://localhost:3000"), "localhost not auto-added for 0.0.0.0 config");
182
- });
183
-
184
- it("custom non-loopback host only allows that host origin", () => {
185
- const origins = buildAllowedOriginsSet(buildHmrAllowedOrigins("192.168.1.5", 4000));
186
- assert.ok(isOriginAllowed(origins, "http://192.168.1.5:4000"));
187
- assert.ok(!isOriginAllowed(origins, "http://localhost:4000"));
188
- assert.ok(!isOriginAllowed(origins, "http://192.168.1.5:3000"), "different port rejected");
189
- });
190
- });
1
+ /**
2
+ * Regression tests for security fixes R-001 and R-002.
3
+ *
4
+ * R-001: Dev server must bind to the literal requested host; "localhost" must
5
+ * NOT be silently rewritten to "0.0.0.0".
6
+ *
7
+ * R-002: HMR WebSocket server must reject connections from disallowed origins.
8
+ *
9
+ * Both tests use only the public constructor/method surface — no internal
10
+ * mocking required — and are runnable with:
11
+ *
12
+ * node --import tsx --test __tests__/security-r001-r002.test.ts
13
+ */
14
+
15
+ import { describe, it } from "node:test";
16
+ import assert from "node:assert/strict";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // R-001 — host binding logic
20
+ // ---------------------------------------------------------------------------
21
+ // We test the decision logic extracted from SwiteServer. The full server
22
+ // cannot be started in a unit test (requires filesystem, network, Express,
23
+ // etc.) so we replicate the binding expression from server.ts:171 directly.
24
+ // Any future regression (reintroducing the localhost→0.0.0.0 rewrite) will
25
+ // break these assertions before it reaches production.
26
+
27
+ function resolveBind(configHost: string): string {
28
+ // This is the expression that lives in server.ts after the R-001 fix:
29
+ // const bindHost = this.config.host;
30
+ // i.e. the host is used literally with NO rewriting.
31
+ return configHost;
32
+ }
33
+
34
+ // We also test the WRONG old behaviour to confirm the test would catch it.
35
+ function resolveBindOLD(configHost: string): string {
36
+ // This was the pre-fix expression (the bug):
37
+ // const bindHost = host === "localhost" ? "0.0.0.0" : host;
38
+ return configHost === "localhost" ? "0.0.0.0" : configHost;
39
+ }
40
+
41
+ describe("R-001 — dev server bind host", () => {
42
+ it("default (localhost) stays as localhost — not rewritten to 0.0.0.0", () => {
43
+ const result = resolveBind("localhost");
44
+ assert.strictEqual(result, "localhost", "localhost must not be rewritten to 0.0.0.0");
45
+ assert.notStrictEqual(result, "0.0.0.0", "loopback request must stay loopback");
46
+ });
47
+
48
+ it("127.0.0.1 stays as 127.0.0.1", () => {
49
+ assert.strictEqual(resolveBind("127.0.0.1"), "127.0.0.1");
50
+ });
51
+
52
+ it("::1 (IPv6 loopback) stays as ::1", () => {
53
+ assert.strictEqual(resolveBind("::1"), "::1");
54
+ });
55
+
56
+ it("explicit 0.0.0.0 opt-in is honoured (all-interfaces bind)", () => {
57
+ // This is the ONLY case where 0.0.0.0 should appear.
58
+ assert.strictEqual(resolveBind("0.0.0.0"), "0.0.0.0");
59
+ });
60
+
61
+ it("explicit non-loopback host (e.g. 192.168.1.5) is honoured", () => {
62
+ assert.strictEqual(resolveBind("192.168.1.5"), "192.168.1.5");
63
+ });
64
+
65
+ // -------------------------------------------------------------------------
66
+ // Regression guard: prove the old (buggy) implementation would FAIL above.
67
+ // If someone accidentally re-introduces the old logic these assertions flip.
68
+ // -------------------------------------------------------------------------
69
+ it("[old-behaviour guard] OLD code wrongly rewrites localhost → 0.0.0.0", () => {
70
+ // The old code produced 0.0.0.0 for localhost — that is the bug.
71
+ assert.strictEqual(
72
+ resolveBindOLD("localhost"),
73
+ "0.0.0.0",
74
+ "Confirm the old buggy expression for documentation purposes",
75
+ );
76
+ });
77
+
78
+ it("[old-behaviour guard] OLD code would NOT produce localhost for localhost", () => {
79
+ assert.notStrictEqual(
80
+ resolveBindOLD("localhost"),
81
+ "localhost",
82
+ "Under the old code, localhost was never kept as localhost",
83
+ );
84
+ });
85
+ });
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // R-002 — HMR WebSocket Origin validation
89
+ // ---------------------------------------------------------------------------
90
+ // We test `isOriginAllowed` by extracting its logic into a standalone
91
+ // function that mirrors the implementation in hmr.ts exactly. The actual
92
+ // HMREngine class is not instantiated (it tries to bind a network port in
93
+ // the constructor area which is inappropriate for unit tests).
94
+
95
+ function buildAllowedOriginsSet(allowedOrigins: string[]): Set<string> {
96
+ return new Set(allowedOrigins);
97
+ }
98
+
99
+ function isOriginAllowed(allowedOrigins: Set<string>, origin: string | undefined): boolean {
100
+ if (!origin) return false;
101
+ const normalise = (o: string) => o.replace(/\/$/, "").toLowerCase();
102
+ const candidate = normalise(origin);
103
+ for (const allowed of allowedOrigins) {
104
+ if (normalise(allowed) === candidate) return true;
105
+ }
106
+ return false;
107
+ }
108
+
109
+ /** Mirrors SwiteServer.buildHmrAllowedOrigins() from server.ts */
110
+ function buildHmrAllowedOrigins(host: string, port: number): string[] {
111
+ const origins: string[] = [];
112
+ const add = (h: string) => origins.push(`http://${h}:${port}`);
113
+ add(host);
114
+ if (host === "localhost") add("127.0.0.1");
115
+ else if (host === "127.0.0.1") add("localhost");
116
+ return origins;
117
+ }
118
+
119
+ describe("R-002 — HMR WebSocket origin validation", () => {
120
+ // -------------------------------------------------------------------------
121
+ // Default dev setup: host=localhost, port=3000
122
+ // -------------------------------------------------------------------------
123
+ it("allows same-origin connection from http://localhost:3000", () => {
124
+ const origins = buildAllowedOriginsSet(buildHmrAllowedOrigins("localhost", 3000));
125
+ assert.ok(isOriginAllowed(origins, "http://localhost:3000"), "same-origin must be allowed");
126
+ });
127
+
128
+ it("also allows http://127.0.0.1:3000 when configured host is localhost", () => {
129
+ // Browser may send the numeric form depending on what user typed.
130
+ const origins = buildAllowedOriginsSet(buildHmrAllowedOrigins("localhost", 3000));
131
+ assert.ok(isOriginAllowed(origins, "http://127.0.0.1:3000"), "loopback alias must be allowed");
132
+ });
133
+
134
+ it("allows http://localhost:3000 when configured host is 127.0.0.1", () => {
135
+ const origins = buildAllowedOriginsSet(buildHmrAllowedOrigins("127.0.0.1", 3000));
136
+ assert.ok(isOriginAllowed(origins, "http://localhost:3000"));
137
+ });
138
+
139
+ it("rejects a foreign origin (cross-site WebSocket hijack attempt)", () => {
140
+ const origins = buildAllowedOriginsSet(buildHmrAllowedOrigins("localhost", 3000));
141
+ assert.ok(
142
+ !isOriginAllowed(origins, "http://evil.example.com"),
143
+ "foreign origin must be rejected",
144
+ );
145
+ });
146
+
147
+ it("rejects a subdomain of localhost (not the same origin)", () => {
148
+ const origins = buildAllowedOriginsSet(buildHmrAllowedOrigins("localhost", 3000));
149
+ assert.ok(!isOriginAllowed(origins, "http://sub.localhost:3000"), "subdomain must be rejected");
150
+ });
151
+
152
+ it("rejects a connection on the correct host but wrong port", () => {
153
+ const origins = buildAllowedOriginsSet(buildHmrAllowedOrigins("localhost", 3000));
154
+ assert.ok(
155
+ !isOriginAllowed(origins, "http://localhost:9000"),
156
+ "different port must be rejected",
157
+ );
158
+ });
159
+
160
+ it("rejects a connection with no Origin header (non-browser WebSocket client)", () => {
161
+ const origins = buildAllowedOriginsSet(buildHmrAllowedOrigins("localhost", 3000));
162
+ assert.ok(!isOriginAllowed(origins, undefined), "absent Origin must be rejected");
163
+ });
164
+
165
+ it("rejects an empty-string Origin", () => {
166
+ const origins = buildAllowedOriginsSet(buildHmrAllowedOrigins("localhost", 3000));
167
+ assert.ok(!isOriginAllowed(origins, ""), "empty Origin must be rejected");
168
+ });
169
+
170
+ it("origin check is case-insensitive on scheme and host", () => {
171
+ const origins = buildAllowedOriginsSet(buildHmrAllowedOrigins("localhost", 3000));
172
+ // RFC 6454 §6.1: scheme+host are case-insensitive
173
+ assert.ok(isOriginAllowed(origins, "HTTP://LOCALHOST:3000"), "case-insensitive match must work");
174
+ });
175
+
176
+ it("explicit 0.0.0.0 dev server only adds 0.0.0.0 as allowed origin (not localhost)", () => {
177
+ // When the dev explicitly opted in to 0.0.0.0 binding, only that literal
178
+ // host is in the origin allowlist — "localhost" is NOT auto-added.
179
+ const origins = buildHmrAllowedOrigins("0.0.0.0", 3000);
180
+ assert.ok(origins.includes("http://0.0.0.0:3000"), "0.0.0.0 origin present");
181
+ assert.ok(!origins.includes("http://localhost:3000"), "localhost not auto-added for 0.0.0.0 config");
182
+ });
183
+
184
+ it("custom non-loopback host only allows that host origin", () => {
185
+ const origins = buildAllowedOriginsSet(buildHmrAllowedOrigins("192.168.1.5", 4000));
186
+ assert.ok(isOriginAllowed(origins, "http://192.168.1.5:4000"));
187
+ assert.ok(!isOriginAllowed(origins, "http://localhost:4000"));
188
+ assert.ok(!isOriginAllowed(origins, "http://192.168.1.5:3000"), "different port rejected");
189
+ });
190
+ });