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.
- package/CHANGELOG.md +10 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +7 -3
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.d.ts +3 -0
- package/build/compiler/config.js +13 -1
- package/build/compiler/index.js +14 -3
- package/package.json +1 -1
- package/server/auth/AuthController.ts +166 -73
- package/src/cli/index.ts +15 -3
- package/src/compiler/config.ts +30 -3
- package/src/compiler/index.ts +21 -3
- package/test/config-host-port.test.ts +59 -0
- package/test/pqauth-email.test.ts +85 -3
package/src/compiler/config.ts
CHANGED
|
@@ -51,8 +51,13 @@ export interface ClientConfig {
|
|
|
51
51
|
readonly outDir?: string;
|
|
52
52
|
/** Public base path. Default `/`. */
|
|
53
53
|
readonly base?: string;
|
|
54
|
-
/**
|
|
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
|
-
|
|
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:
|
package/src/compiler/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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',
|
|
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"
|
|
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
|
});
|