toiljs 0.0.91 → 0.0.93

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.
@@ -51,8 +51,13 @@ export interface ClientConfig {
51
51
  readonly outDir?: string;
52
52
  /** Public base path. Default `/`. */
53
53
  readonly base?: string;
54
- /** Dev server port. Default `3000`. */
54
+ /** Port the dev server AND the self-host (`toiljs start`) listen on. Default `3000`.
55
+ * Overridden by the `--port` CLI flag and the `PORT` env var (in that precedence). */
55
56
  readonly port?: number;
57
+ /** Bind host for the dev server AND the self-host. Default `127.0.0.1` (loopback only).
58
+ * Set to `0.0.0.0` (or a specific interface) to accept LAN / public connections.
59
+ * Overridden by the `--host` CLI flag and the `HOST` env var (in that precedence). */
60
+ readonly host?: string;
56
61
  /**
57
62
  * Optimize imported images at build time (resize/convert via `vite-imagetools` + sharp): an
58
63
  * import like `logo.png?w=400;800&format=webp&as=srcset` emits resized, compressed variants.
@@ -194,6 +199,8 @@ export interface ResolvedToilConfig {
194
199
  readonly outDir: string;
195
200
  readonly base: string;
196
201
  readonly port: number;
202
+ /** Bind host for the dev server + self-host (`toiljs start`). Default `127.0.0.1`. */
203
+ readonly host: string;
197
204
  /** Whether build-time image optimization (`vite-imagetools`) is enabled. */
198
205
  readonly images: boolean;
199
206
  /** Whether build-time font preloading is enabled. */
@@ -246,7 +253,7 @@ function resolveRuntimePath(): string {
246
253
 
247
254
  /** Finds and loads `toil.config.*` or `toiljs.config.*` from `root`, then resolves defaults. */
248
255
  export async function loadConfig(
249
- opts: { root?: string; port?: number } = {},
256
+ opts: { root?: string; port?: number; host?: string } = {},
250
257
  ): Promise<ResolvedToilConfig> {
251
258
  const root = path.resolve(opts.root ?? process.cwd());
252
259
 
@@ -278,7 +285,12 @@ export async function loadConfig(
278
285
  toilDir: path.join(root, '.toil'),
279
286
  outDir: client.outDir ?? 'build/client',
280
287
  base: client.base ?? '/',
281
- port: opts.port ?? client.port ?? 3000,
288
+ // Port + bind host for the dev server and the self-host, resolved with the
289
+ // precedence: CLI flag (opts) > env (PORT/HOST) > config (client.*) > default.
290
+ // `PORT`/`HOST` are the conventional PaaS env vars; a platform that injects
291
+ // `PORT` (Heroku, Render, Fly, containers) works with zero config.
292
+ port: opts.port ?? envPort() ?? client.port ?? 3000,
293
+ host: opts.host ?? envHost() ?? client.host ?? '127.0.0.1',
282
294
  images: client.images ?? true,
283
295
  fonts: client.fonts ?? true,
284
296
  viewTransitions: client.viewTransitions ?? false,
@@ -299,6 +311,21 @@ export async function loadConfig(
299
311
  };
300
312
  }
301
313
 
314
+ /** The `PORT` env var as a positive integer, or `undefined` when unset/invalid
315
+ * (so it falls through to the config value / default rather than binding to 0). */
316
+ function envPort(): number | undefined {
317
+ const v = process.env.PORT;
318
+ if (v == null || v.trim() === '') return undefined;
319
+ const n = Number(v);
320
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
321
+ }
322
+
323
+ /** The `HOST` env var (trimmed), or `undefined` when unset/blank. */
324
+ function envHost(): string | undefined {
325
+ const v = process.env.HOST;
326
+ return v != null && v.trim() !== '' ? v.trim() : undefined;
327
+ }
328
+
302
329
  const DEV_NODE_MODES: readonly DevNodeMode[] = ['hot', 'regional', 'continental', 'daemon', 'all'];
303
330
 
304
331
  /** A `nodeMode` outside the enum falls back to `all` with a warning (fail-soft:
@@ -845,6 +845,17 @@ async function collectDevCss(server: ViteDevServer): Promise<string> {
845
845
  return css;
846
846
  }
847
847
 
848
+ /** The host to show in a clickable URL: a wildcard bind (`0.0.0.0`/`::`/empty) is
849
+ * not universally openable, so present `localhost` for those; otherwise the host. */
850
+ function displayHost(host: string): string {
851
+ return host === '0.0.0.0' || host === '::' || host === '' ? 'localhost' : host;
852
+ }
853
+
854
+ /** Whether the bind host exposes the server beyond loopback (worth flagging to the user). */
855
+ function isExposedHost(host: string): boolean {
856
+ return host !== '127.0.0.1' && host !== 'localhost' && host !== '::1';
857
+ }
858
+
848
859
  export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer> {
849
860
  const cfg = await loadConfig(opts);
850
861
  // Server first: build it (regenerating shared/server.ts) before the client dev server starts.
@@ -921,6 +932,7 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
921
932
  const front = await startDevServer({
922
933
  root: cfg.root,
923
934
  port: cfg.port,
935
+ host: cfg.host,
924
936
  wasmFile: serverWasmFile(cfg.root),
925
937
  // The daemon (cold) emulator drives `release-cold.wasm` per `nodeMode`; absent for a
926
938
  // project with no `@daemon` (the cold artifact never gets built, so the host stays idle).
@@ -938,17 +950,21 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
938
950
  server.httpServer?.once('close', () => {
939
951
  void front.close();
940
952
  });
953
+ const shownHost = displayHost(front.host);
941
954
  process.stdout.write(
942
955
  '\n ' +
943
956
  pc.green('➜') +
944
957
  ' ' +
945
958
  pc.bold('Local') +
946
959
  ': ' +
947
- pc.cyan(`http://localhost:${pc.bold(String(front.port))}/`) +
960
+ pc.cyan(`http://${shownHost}:${pc.bold(String(front.port))}/`) +
948
961
  pc.dim(' (wasm server + vite)') +
962
+ (isExposedHost(front.host)
963
+ ? pc.yellow(`\n ⚠ exposed on ${front.host}:${String(front.port)} (not just loopback)`)
964
+ : '') +
949
965
  '\n',
950
966
  );
951
- printEmailsUrl(cfg, `http://localhost:${String(front.port)}/`);
967
+ printEmailsUrl(cfg, `http://${shownHost}:${String(front.port)}/`);
952
968
 
953
969
  // Rebuild the server on server-file changes; Vite HMRs the regenerated shared/server.ts
954
970
  // and the dev server hot-swaps the recompiled wasm module.
@@ -1044,7 +1060,9 @@ export async function start(opts: ToilCommandOptions = {}): Promise<RunningBacke
1044
1060
  daemon: cfg.daemon,
1045
1061
  threads: opts.threads ?? cfg.threads,
1046
1062
  port: cfg.port,
1047
- host: opts.host,
1063
+ // `cfg.host` already folds in the `--host` flag, `HOST` env, and `client.host`
1064
+ // config (CLI > env > config > 127.0.0.1); see loadConfig.
1065
+ host: cfg.host,
1048
1066
  email: cfg.email ?? undefined,
1049
1067
  });
1050
1068
  }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * loadConfig host/port resolution: the dev server AND the self-host (`toiljs start`)
3
+ * bind to a host+port resolved with the precedence CLI flag > env (PORT/HOST) >
4
+ * config (client.host/port) > default (127.0.0.1:3000). These lock that precedence
5
+ * and the env parsing (a blank/garbage PORT must fall through, never bind to 0).
6
+ */
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+
11
+ import { afterEach, describe, expect, it } from 'vitest';
12
+
13
+ import { loadConfig } from '../src/compiler/config.js';
14
+
15
+ // An empty dir with no toil.config so loadConfig exercises the env/opts/default path.
16
+ const emptyRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'toil-cfg-'));
17
+
18
+ describe('loadConfig host/port precedence', () => {
19
+ const savedPort = process.env.PORT;
20
+ const savedHost = process.env.HOST;
21
+ afterEach(() => {
22
+ if (savedPort === undefined) delete process.env.PORT;
23
+ else process.env.PORT = savedPort;
24
+ if (savedHost === undefined) delete process.env.HOST;
25
+ else process.env.HOST = savedHost;
26
+ });
27
+
28
+ it('defaults to 127.0.0.1:3000 with no flag / env / config', async () => {
29
+ delete process.env.PORT;
30
+ delete process.env.HOST;
31
+ const cfg = await loadConfig({ root: emptyRoot });
32
+ expect(cfg.port).toBe(3000);
33
+ expect(cfg.host).toBe('127.0.0.1');
34
+ });
35
+
36
+ it('reads the PORT / HOST env vars', async () => {
37
+ process.env.PORT = '8080';
38
+ process.env.HOST = '0.0.0.0';
39
+ const cfg = await loadConfig({ root: emptyRoot });
40
+ expect(cfg.port).toBe(8080);
41
+ expect(cfg.host).toBe('0.0.0.0');
42
+ });
43
+
44
+ it('CLI opts win over env (CLI > env)', async () => {
45
+ process.env.PORT = '8080';
46
+ process.env.HOST = '0.0.0.0';
47
+ const cfg = await loadConfig({ root: emptyRoot, port: 4321, host: '192.168.1.5' });
48
+ expect(cfg.port).toBe(4321);
49
+ expect(cfg.host).toBe('192.168.1.5');
50
+ });
51
+
52
+ it('a blank / non-numeric PORT env falls through to the default (never binds to 0)', async () => {
53
+ process.env.PORT = '';
54
+ delete process.env.HOST;
55
+ expect((await loadConfig({ root: emptyRoot })).port).toBe(3000);
56
+ process.env.PORT = 'not-a-number';
57
+ expect((await loadConfig({ root: emptyRoot })).port).toBe(3000);
58
+ });
59
+ });
@@ -40,17 +40,20 @@ function loadModule(): WasmServerModule {
40
40
  return m;
41
41
  }
42
42
 
43
- /** Route the client's `fetch(path, {body})` into the dev wasm dispatcher (with a cookie jar). */
43
+ /** Route the client's `fetch(path, {body})` into the dev wasm dispatcher (with a cookie jar).
44
+ * The Host header the client's requests carry is settable (`setHost`) so a test can drive
45
+ * the same client against DIFFERENT tenant domains and prove per-host auth isolation. */
44
46
  function installFetchShim(m: WasmServerModule): () => void {
45
47
  const original = globalThis.fetch;
46
48
  const jar = new Map<string, string>();
49
+ let host = 'localhost:3000';
47
50
  globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
48
51
  const url = typeof input === 'string' ? input : input.toString();
49
52
  const pathname = new URL(url, 'http://localhost').pathname;
50
53
  const bodyBytes =
51
54
  init?.body == null ? new Uint8Array(0) : new Uint8Array(init.body as ArrayBuffer);
52
55
  const headers: [string, string][] = [
53
- ['host', 'localhost:3000'],
56
+ ['host', host],
54
57
  ['content-type', 'application/octet-stream'],
55
58
  ];
56
59
  if (jar.size > 0)
@@ -80,6 +83,9 @@ function installFetchShim(m: WasmServerModule): () => void {
80
83
  globalThis.fetch = original;
81
84
  },
82
85
  jar,
86
+ setHost: (h: string) => {
87
+ host = h;
88
+ },
83
89
  };
84
90
  }
85
91
 
@@ -136,6 +142,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
136
142
  let restoreFetch: () => void;
137
143
  let mod: WasmServerModule;
138
144
  let jar: Map<string, string>;
145
+ let setHost: (h: string) => void;
139
146
 
140
147
  beforeEach(() => {
141
148
  __resetDbForTests();
@@ -148,6 +155,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
148
155
  const shim = installFetchShim(mod);
149
156
  restoreFetch = shim.restore;
150
157
  jar = shim.jar;
158
+ setHost = shim.setHost;
151
159
  });
152
160
  afterEach(() => {
153
161
  restoreFetch();
@@ -179,10 +187,13 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
179
187
  it(
180
188
  'reset link is NOT poisonable via a crafted Host header (#6 host-header reset-poisoning)',
181
189
  async () => {
190
+ // grace lives on the victim tenant (realm = normalized Host "victim.com").
191
+ setHost('victim.com');
182
192
  await Auth.register('grace', 'pw-grace-correct', 'grace@x.com');
183
193
  __clearSentEmails();
184
194
  // Attacker triggers reset/request with a Host that routes to the victim
185
- // (the edge strip_port -> "victim.com") but, dropped raw into a link,
195
+ // (the edge strip_port -> "victim.com", which is grace's realm, so the
196
+ // request DOES resolve her account) but, dropped raw into a link,
186
197
  // reparents the URL authority to attacker.com.
187
198
  const body = new DataWriter().writeString('grace@x.com').toBytes();
188
199
  const r = mod.dispatch({
@@ -634,4 +645,75 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
634
645
  },
635
646
  120_000,
636
647
  );
648
+
649
+ it(
650
+ '(g) per-domain isolation: a confirm/reset/2FA code minted on domain A is USELESS on domain B',
651
+ async () => {
652
+ const A = 'a.example.com';
653
+ const B = 'b.example.com';
654
+
655
+ // ---- confirm token: minted on A, unredeemable on B ----
656
+ setConfirmation(true);
657
+ setHost(A);
658
+ await Auth.register('mia', 'mia-pw-strong', 'mia@x.com'); // realm A + confirm token emailed
659
+ const confirmTok = tokenFromEmail('confirm', 'mia@x.com');
660
+
661
+ setHost(B);
662
+ __resetRatelimitForTests();
663
+ // Same raw token, different realm -> tokenId(B, raw) is a different key -> miss.
664
+ await expect(Auth.confirmEmail(confirmTok)).rejects.toThrow();
665
+
666
+ setHost(A);
667
+ __resetRatelimitForTests();
668
+ await Auth.confirmEmail(confirmTok); // realm A -> confirms
669
+ setConfirmation(false);
670
+
671
+ // ---- reset token: minted on A, unredeemable on B ----
672
+ setHost(A);
673
+ __clearSentEmails();
674
+ __resetRatelimitForTests();
675
+ await Auth.requestPasswordReset('mia@x.com');
676
+ const resetTok = tokenFromEmail('reset', 'mia@x.com');
677
+
678
+ setHost(B);
679
+ __resetRatelimitForTests();
680
+ await expect(Auth.resetPassword(resetTok, 'mia-pw-new')).rejects.toThrow(); // realm B miss
681
+
682
+ setHost(A);
683
+ __resetRatelimitForTests();
684
+ await Auth.resetPassword(resetTok, 'mia-pw-new'); // realm A -> resets
685
+
686
+ // ---- 2FA login code: minted on A, unusable on B ----
687
+ setHost(A);
688
+ __resetRatelimitForTests();
689
+ await Auth.login('mia', 'mia-pw-new'); // session on A (2FA still off)
690
+ __clearSentEmails();
691
+ await Auth.setupTwoFactor(Auth.TwoFactorMethod.Email);
692
+ await Auth.confirmTwoFactorSetup(codeFromEmail('mia@x.com')); // 2FA enabled on A
693
+
694
+ __clearSentEmails();
695
+ jar.clear();
696
+ __resetRatelimitForTests();
697
+ let err: unknown;
698
+ try {
699
+ await Auth.login('mia', 'mia-pw-new'); // -> 2FA challenge (twoFaId + code, realm A)
700
+ } catch (e) {
701
+ err = e;
702
+ }
703
+ const twoFaId = (err as TwoFactorRequiredError).twoFaId;
704
+ const loginCode = codeFromEmail('mia@x.com');
705
+
706
+ setHost(B);
707
+ __resetRatelimitForTests();
708
+ // The twoFaId lives in twoFaLogins under realm A; on B its key (and the
709
+ // realm-bound codeHash) miss -> a code from A cannot mint a session on B.
710
+ await expect(Auth.verifyTwoFactor(twoFaId, loginCode)).rejects.toThrow();
711
+
712
+ setHost(A);
713
+ __resetRatelimitForTests();
714
+ const session = await Auth.verifyTwoFactor(twoFaId, loginCode); // realm A -> mints
715
+ expect(session.length).toBeGreaterThan(0);
716
+ },
717
+ 180_000,
718
+ );
637
719
  });