toiljs 0.0.56 → 0.0.57
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/README.md +72 -14
- package/build/backend/.tsbuildinfo +1 -1
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +284 -137
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/dev/devtools.js +1 -1
- package/build/client/routing/hooks.js +2 -2
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/template-build.d.ts +5 -2
- package/build/compiler/template-build.js +18 -6
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/database.d.ts +1 -1
- package/build/devserver/database.js +2 -0
- package/build/devserver/host.js +18 -1
- package/examples/basic/server/routes/Auth.ts +2 -7
- package/examples/basic/server/routes/Guestbook.ts +2 -4
- package/package.json +26 -26
- package/src/cli/create.ts +19 -4
- package/src/cli/diagnostics.ts +48 -0
- package/src/cli/doctor.ts +145 -6
- package/src/client/dev/devtools.tsx +5 -1
- package/src/client/routing/hooks.ts +5 -3
- package/src/compiler/template-build.ts +41 -4
- package/src/devserver/database.ts +19 -1
- package/src/devserver/host.ts +32 -1
- package/test/devserver-secrets.test.ts +59 -0
- package/test/doctor.test.ts +30 -0
package/src/cli/diagnostics.ts
CHANGED
|
@@ -503,6 +503,54 @@ export function checkRestDispatch(f: RestFacts): Check {
|
|
|
503
503
|
};
|
|
504
504
|
}
|
|
505
505
|
|
|
506
|
+
/**
|
|
507
|
+
* Whether the server's tsconfig wires the toilscript language-service plugin. The compiler turns
|
|
508
|
+
* each `@collection` field into a STATIC handle (`GuestbookDb.totals`) and injects the `@data`
|
|
509
|
+
* codec / `@user` members, none of which stock TypeScript can see, so without the plugin the editor
|
|
510
|
+
* false-flags them as TS2339. The plugin (editor-only; never runs under `tsc`) clears them.
|
|
511
|
+
*/
|
|
512
|
+
export function checkServerTsPlugin(present: boolean): Check {
|
|
513
|
+
return present
|
|
514
|
+
? { id: 'server-ts-plugin', label: 'toilscript editor plugin', status: 'pass' }
|
|
515
|
+
: {
|
|
516
|
+
id: 'server-ts-plugin',
|
|
517
|
+
label: 'toilscript editor plugin',
|
|
518
|
+
status: 'warn',
|
|
519
|
+
detail: 'server tsconfig is missing the toilscript LS plugin, so the editor wrongly flags @database static collections (e.g. GuestbookDb.totals) and @data members as TS2339',
|
|
520
|
+
fix: 'Run `toiljs doctor --fix` to add { "plugins": [{ "name": "toilscript/std/ts-plugin.cjs" }] } to your server tsconfig, then pick the workspace TypeScript version and restart the TS server.',
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// --- Security -------------------------------------------------------------------------------------
|
|
525
|
+
|
|
526
|
+
/** Whether the project uses the auth primitive, and whether its session secret is configured. */
|
|
527
|
+
export interface AuthFacts {
|
|
528
|
+
/** A server source references the auth primitive (`AuthService` / `@user` / `@auth`). */
|
|
529
|
+
readonly usesAuth: boolean;
|
|
530
|
+
/** `AUTH_SESSION_SECRET` is assigned a non-empty value in the local secrets source. */
|
|
531
|
+
readonly sessionSecretSet: boolean;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Flags the silent insecure default behind the auth primitive. When a project uses sessions but
|
|
536
|
+
* never sets `AUTH_SESSION_SECRET`, the server falls back to a PUBLISHED dev key (see
|
|
537
|
+
* `server/globals/auth.ts`), so anyone can forge a session cookie and skip login. doctor can only
|
|
538
|
+
* see the local secrets source, so it WARNS (the real secret may live on the deploy target) rather
|
|
539
|
+
* than failing CI on a false positive.
|
|
540
|
+
*/
|
|
541
|
+
export function checkAuthSecrets(f: AuthFacts): Check {
|
|
542
|
+
if (!f.usesAuth || f.sessionSecretSet) {
|
|
543
|
+
return { id: 'auth-secrets', label: 'Session secret', status: 'pass' };
|
|
544
|
+
}
|
|
545
|
+
return {
|
|
546
|
+
id: 'auth-secrets',
|
|
547
|
+
label: 'Session secret',
|
|
548
|
+
status: 'warn',
|
|
549
|
+
detail: 'auth is used but AUTH_SESSION_SECRET is unset: sessions fall back to a PUBLISHED key, so anyone can forge a session cookie and skip login',
|
|
550
|
+
fix: 'Set AUTH_SESSION_SECRET to a long random value in .env.secrets (local) and on your deploy target (also AUTH_OPRF_SEED / AUTH_KEM_SK if you use password login). Generate one: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))".',
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
506
554
|
// --- Summary --------------------------------------------------------------------------------------
|
|
507
555
|
|
|
508
556
|
export function summarize(groups: readonly CheckGroup[]): DoctorSummary {
|
package/src/cli/doctor.ts
CHANGED
|
@@ -18,7 +18,9 @@ import {
|
|
|
18
18
|
} from 'toiljs/compiler';
|
|
19
19
|
|
|
20
20
|
import {
|
|
21
|
+
type AuthFacts,
|
|
21
22
|
type Check,
|
|
23
|
+
checkAuthSecrets,
|
|
22
24
|
checkBasePath,
|
|
23
25
|
checkConfigLoads,
|
|
24
26
|
checkDevScripts,
|
|
@@ -37,6 +39,7 @@ import {
|
|
|
37
39
|
checkRpcWiring,
|
|
38
40
|
checkSeoUrl,
|
|
39
41
|
checkServerEntry,
|
|
42
|
+
checkServerTsPlugin,
|
|
40
43
|
type CheckStatus,
|
|
41
44
|
checkStyling,
|
|
42
45
|
checkToilconfig,
|
|
@@ -231,6 +234,61 @@ function gatherRestFacts(root: string, toilconfig: Record<string, unknown> | nul
|
|
|
231
234
|
return { hasControllers, dispatched };
|
|
232
235
|
}
|
|
233
236
|
|
|
237
|
+
/** Whether `.env.secrets` assigns `key` a non-empty value (a bare `KEY=` counts as unset). */
|
|
238
|
+
function secretDefined(root: string, key: string): boolean {
|
|
239
|
+
const raw = readFile(path.join(root, '.env.secrets'));
|
|
240
|
+
if (raw === null) return false;
|
|
241
|
+
return new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=\\s*\\S`, 'm').test(raw);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Whether the server uses the auth primitive (so a missing session secret matters) and whether
|
|
246
|
+
* `AUTH_SESSION_SECRET` is set locally. `getSecure` reads ONLY the `.env.secrets` bucket, so that
|
|
247
|
+
* is the source we check; on a deploy target the secret lives on the dashboard instead.
|
|
248
|
+
*/
|
|
249
|
+
function gatherAuthFacts(root: string, toilconfig: Record<string, unknown> | null): AuthFacts {
|
|
250
|
+
let usesAuth = false;
|
|
251
|
+
for (const src of serverSources(root, toilconfig)) {
|
|
252
|
+
if (/\bAuthService\b/.test(src) || /@user\b/.test(src) || /@auth\b/.test(src)) {
|
|
253
|
+
usesAuth = true;
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return { usesAuth, sessionSecretSet: secretDefined(root, 'AUTH_SESSION_SECRET') };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The toilscript language-service plugin id wired into a server tsconfig. */
|
|
261
|
+
const TS_PLUGIN_NAME = 'toilscript/std/ts-plugin.cjs';
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The server's tsconfig.json (the one beside a toilconfig entry, conventionally
|
|
265
|
+
* `server/tsconfig.json`), or null if none exists. That is the project the editor uses for server
|
|
266
|
+
* files, so it is where the toilscript LS plugin must live.
|
|
267
|
+
*/
|
|
268
|
+
function serverTsconfigPath(root: string, toilconfig: Record<string, unknown> | null): string | null {
|
|
269
|
+
const dirs = new Set<string>();
|
|
270
|
+
const entries = Array.isArray(toilconfig?.entries)
|
|
271
|
+
? (toilconfig.entries as unknown[]).filter((e): e is string => typeof e === 'string')
|
|
272
|
+
: [];
|
|
273
|
+
for (const e of entries) dirs.add(path.dirname(path.resolve(root, e)));
|
|
274
|
+
if (dirs.size === 0) dirs.add(path.join(root, 'server'));
|
|
275
|
+
for (const dir of dirs) {
|
|
276
|
+
const p = path.join(dir, 'tsconfig.json');
|
|
277
|
+
if (fs.existsSync(p)) return p;
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Whether a parsed tsconfig's `compilerOptions.plugins` references the toilscript LS plugin. */
|
|
283
|
+
function tsconfigHasToilPlugin(tsconfig: Record<string, unknown> | null): boolean {
|
|
284
|
+
const plugins = asRecord(tsconfig?.compilerOptions)?.plugins;
|
|
285
|
+
if (!Array.isArray(plugins)) return false;
|
|
286
|
+
return plugins.some((p) => {
|
|
287
|
+
const name = asRecord(p)?.name;
|
|
288
|
+
return typeof name === 'string' && name.includes('ts-plugin');
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
234
292
|
interface RpcFixResult {
|
|
235
293
|
/** Files written. */
|
|
236
294
|
readonly changed: string[];
|
|
@@ -471,6 +529,64 @@ function applyPrettierFix(root: string, pkg: Record<string, unknown> | null): Rp
|
|
|
471
529
|
return { changed, skipped };
|
|
472
530
|
}
|
|
473
531
|
|
|
532
|
+
/**
|
|
533
|
+
* Wires the editor side of the toilscript server: adds the LS plugin to the server tsconfig (so the
|
|
534
|
+
* editor stops false-flagging `@database` static collections / `@data` members), and points VS Code
|
|
535
|
+
* at the workspace TypeScript (so it actually loads that plugin). Idempotent; only writes real
|
|
536
|
+
* changes, and skips (with a note) configs it can't safely edit (a tsconfig with comments).
|
|
537
|
+
*/
|
|
538
|
+
function applyServerEditorFix(root: string, toilconfig: Record<string, unknown> | null): RpcFixResult {
|
|
539
|
+
const changed: string[] = [];
|
|
540
|
+
const skipped: string[] = [];
|
|
541
|
+
|
|
542
|
+
// 1. The LS plugin in the server tsconfig.
|
|
543
|
+
const tsPath = serverTsconfigPath(root, toilconfig);
|
|
544
|
+
if (tsPath === null) {
|
|
545
|
+
skipped.push('server/tsconfig.json (not found; add the toilscript ts-plugin by hand)');
|
|
546
|
+
} else {
|
|
547
|
+
const rel = path.relative(root, tsPath);
|
|
548
|
+
const raw = readFile(tsPath);
|
|
549
|
+
const parsed = raw !== null ? readJsonObject(tsPath) : null;
|
|
550
|
+
if (parsed === null) {
|
|
551
|
+
skipped.push(`${rel} (JSON with comments; add the "${TS_PLUGIN_NAME}" plugin by hand)`);
|
|
552
|
+
} else if (!tsconfigHasToilPlugin(parsed)) {
|
|
553
|
+
const co = asRecord(parsed.compilerOptions) ?? {};
|
|
554
|
+
const existingPlugins: unknown[] = Array.isArray(co.plugins)
|
|
555
|
+
? (co.plugins as unknown[])
|
|
556
|
+
: [];
|
|
557
|
+
co.plugins = [...existingPlugins, { name: TS_PLUGIN_NAME }];
|
|
558
|
+
parsed.compilerOptions = co;
|
|
559
|
+
writeFile(tsPath, JSON.stringify(parsed, null, 4) + '\n');
|
|
560
|
+
changed.push(rel);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// 2. Make VS Code use the workspace TypeScript, so it loads the plugin above.
|
|
565
|
+
const vsPath = path.join(root, '.vscode', 'settings.json');
|
|
566
|
+
const vsRaw = readFile(vsPath);
|
|
567
|
+
const vs = vsRaw !== null ? readJsonObject(vsPath) : {};
|
|
568
|
+
if (vs === null) {
|
|
569
|
+
skipped.push('.vscode/settings.json (unparseable; set typescript.tsdk by hand)');
|
|
570
|
+
} else {
|
|
571
|
+
let touched = false;
|
|
572
|
+
if (vs['typescript.tsdk'] !== 'node_modules/typescript/lib') {
|
|
573
|
+
vs['typescript.tsdk'] = 'node_modules/typescript/lib';
|
|
574
|
+
touched = true;
|
|
575
|
+
}
|
|
576
|
+
if (vs['typescript.enablePromptUseWorkspaceTsdk'] !== true) {
|
|
577
|
+
vs['typescript.enablePromptUseWorkspaceTsdk'] = true;
|
|
578
|
+
touched = true;
|
|
579
|
+
}
|
|
580
|
+
if (touched) {
|
|
581
|
+
fs.mkdirSync(path.dirname(vsPath), { recursive: true });
|
|
582
|
+
writeFile(vsPath, JSON.stringify(vs, null, 4) + '\n');
|
|
583
|
+
changed.push('.vscode/settings.json');
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return { changed, skipped };
|
|
588
|
+
}
|
|
589
|
+
|
|
474
590
|
/** Reads the framework's own package.json (engines + peerDependencies) for the requirements. */
|
|
475
591
|
function frameworkMeta(): { node: string; peers: Record<string, string> } {
|
|
476
592
|
const pkgPath = path.resolve(
|
|
@@ -631,20 +747,37 @@ export async function runDoctor(opts: DoctorOptions): Promise<void> {
|
|
|
631
747
|
const peerName = (n: string): Check => checkPeer(n, deps[n] ?? null, meta.peers[n] ?? '*');
|
|
632
748
|
const peerChecks = Object.keys(meta.peers).map(peerName);
|
|
633
749
|
|
|
634
|
-
// Server tooling (RPC wiring + the prettier plugin): optionally fix in
|
|
750
|
+
// Server tooling (RPC wiring + the prettier plugin + the editor LS plugin): optionally fix in
|
|
751
|
+
// place, then re-read.
|
|
635
752
|
const rpcFix = serverPresent && opts.fix ? applyRpcFix(root) : null;
|
|
636
753
|
const prettierFix = serverPresent && opts.fix ? applyPrettierFix(root, projectPkg) : null;
|
|
754
|
+
const editorFix = serverPresent && opts.fix ? applyServerEditorFix(root, toilconfig) : null;
|
|
637
755
|
const rpcFacts = gatherRpcFacts(root);
|
|
638
756
|
const restFacts = gatherRestFacts(root, toilconfig);
|
|
757
|
+
const authFacts = gatherAuthFacts(root, toilconfig);
|
|
639
758
|
const prettierPresent = prettierPluginPresent(
|
|
640
759
|
root,
|
|
641
760
|
readJsonObject(path.join(root, 'package.json')),
|
|
642
761
|
);
|
|
762
|
+
// Only assess the LS plugin when a server tsconfig exists and parses; an absent or
|
|
763
|
+
// commented one is left to the user rather than warned on.
|
|
764
|
+
const serverTsPath = serverPresent ? serverTsconfigPath(root, toilconfig) : null;
|
|
765
|
+
const serverTsParsed = serverTsPath ? readJsonObject(serverTsPath) : null;
|
|
766
|
+
const serverTsPluginPresent =
|
|
767
|
+
serverTsPath === null || serverTsParsed === null ? true : tsconfigHasToilPlugin(serverTsParsed);
|
|
643
768
|
const serverFix =
|
|
644
|
-
rpcFix || prettierFix
|
|
769
|
+
rpcFix || prettierFix || editorFix
|
|
645
770
|
? {
|
|
646
|
-
changed: [
|
|
647
|
-
|
|
771
|
+
changed: [
|
|
772
|
+
...(rpcFix?.changed ?? []),
|
|
773
|
+
...(prettierFix?.changed ?? []),
|
|
774
|
+
...(editorFix?.changed ?? []),
|
|
775
|
+
],
|
|
776
|
+
skipped: [
|
|
777
|
+
...(rpcFix?.skipped ?? []),
|
|
778
|
+
...(prettierFix?.skipped ?? []),
|
|
779
|
+
...(editorFix?.skipped ?? []),
|
|
780
|
+
],
|
|
648
781
|
}
|
|
649
782
|
: null;
|
|
650
783
|
|
|
@@ -706,11 +839,17 @@ export async function runDoctor(opts: DoctorOptions): Promise<void> {
|
|
|
706
839
|
checkRpcWiring(rpcFacts),
|
|
707
840
|
checkRestDispatch(restFacts),
|
|
708
841
|
checkPrettierPlugin(prettierPresent),
|
|
842
|
+
checkServerTsPlugin(serverTsPluginPresent),
|
|
709
843
|
]
|
|
710
844
|
: [checkToilconfig(false)],
|
|
711
845
|
},
|
|
712
846
|
];
|
|
713
847
|
|
|
848
|
+
// Security checks only apply to a server (no server, no sessions to forge).
|
|
849
|
+
if (serverPresent) {
|
|
850
|
+
groups.push({ title: 'Security', checks: [checkAuthSecrets(authFacts)] });
|
|
851
|
+
}
|
|
852
|
+
|
|
714
853
|
const summary = summarize(groups);
|
|
715
854
|
if (opts.json) {
|
|
716
855
|
process.stdout.write(JSON.stringify({ groups, summary, fixed: serverFix }, null, 2) + '\n');
|
|
@@ -731,14 +870,14 @@ export async function runDoctor(opts: DoctorOptions): Promise<void> {
|
|
|
731
870
|
function renderRpcFix(result: RpcFixResult): void {
|
|
732
871
|
const out: string[] = [];
|
|
733
872
|
if (result.changed.length > 0) {
|
|
734
|
-
out.push(' ' + success('fixed
|
|
873
|
+
out.push(' ' + success('fixed server wiring') + dim(` ${result.changed.join(', ')}`));
|
|
735
874
|
if (result.changed.includes('package.json')) {
|
|
736
875
|
out.push(
|
|
737
876
|
' ' + dim('run your installer (npm/pnpm/yarn) if the toilscript version changed.'),
|
|
738
877
|
);
|
|
739
878
|
}
|
|
740
879
|
} else {
|
|
741
|
-
out.push(' ' + dim('
|
|
880
|
+
out.push(' ' + dim('server wiring already in place, nothing to fix.'));
|
|
742
881
|
}
|
|
743
882
|
for (const item of result.skipped) out.push(' ' + warn('skipped') + dim(` ${item}`));
|
|
744
883
|
process.stdout.write(out.join('\n') + '\n\n');
|
|
@@ -171,7 +171,11 @@ function loadPrefs(): Prefs {
|
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
|
|
174
|
+
// Gate on `window`, not `localStorage`: the devtools are browser-only, and merely
|
|
175
|
+
// touching the bare `localStorage` global under SSR/Node trips its experimental-API
|
|
176
|
+
// warning ("localStorage is not available because --localstorage-file ..."). In the
|
|
177
|
+
// browser `loadPrefs()` reads it; under SSR we keep the defaults and never touch it.
|
|
178
|
+
let prefs: Prefs = typeof window !== 'undefined' ? loadPrefs() : defaultPrefs;
|
|
175
179
|
const prefListeners = new Set<() => void>();
|
|
176
180
|
function setPrefs(next: Partial<Prefs>): void {
|
|
177
181
|
prefs = { ...prefs, ...next };
|
|
@@ -101,10 +101,12 @@ function useLocationSubscription(): void {
|
|
|
101
101
|
);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
/** Subscribes to and returns the current `location.pathname`.
|
|
104
|
+
/** Subscribes to and returns the current `location.pathname`. SSR-safe: during a
|
|
105
|
+
* server render (build-time template extraction / edge SSR) there is no `window`,
|
|
106
|
+
* so it reports `/`; the client recomputes on hydration. */
|
|
105
107
|
export function useLocation(): string {
|
|
106
108
|
useLocationSubscription();
|
|
107
|
-
return window.location.pathname;
|
|
109
|
+
return typeof window === 'undefined' ? '/' : window.location.pathname;
|
|
108
110
|
}
|
|
109
111
|
|
|
110
112
|
/** Alias of {@link useLocation}: the current `location.pathname`. */
|
|
@@ -115,7 +117,7 @@ export function usePathname(): string {
|
|
|
115
117
|
/** The current query string as a `URLSearchParams`, re-read on every navigation. */
|
|
116
118
|
export function useSearchParams(): URLSearchParams {
|
|
117
119
|
useLocationSubscription();
|
|
118
|
-
const search = window.location.search;
|
|
120
|
+
const search = typeof window === 'undefined' ? '' : window.location.search;
|
|
119
121
|
return useMemo(() => new URLSearchParams(search), [search]);
|
|
120
122
|
}
|
|
121
123
|
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import fs from 'node:fs';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
20
21
|
import path from 'node:path';
|
|
21
22
|
|
|
22
23
|
import { type ComponentType, type Context, createElement, type ReactNode } from 'react';
|
|
@@ -55,6 +56,14 @@ export interface RouteRenderInput {
|
|
|
55
56
|
setSsrBuild: (on: boolean) => void;
|
|
56
57
|
/** The built HTML shell (with hashed script tags) to splice into. */
|
|
57
58
|
shell: string;
|
|
59
|
+
/** React's `createElement` from the SAME instance the page imports (the Vite
|
|
60
|
+
* SSR graph), so element creation and the components' hooks share one React.
|
|
61
|
+
* Defaults to the compiler's own React when omitted (unit tests). Mixing two
|
|
62
|
+
* React copies leaves the hook dispatcher null ("Cannot read properties of
|
|
63
|
+
* null (reading 'useRef')"). */
|
|
64
|
+
createElement?: typeof createElement;
|
|
65
|
+
/** `renderToStaticMarkup` paired with {@link createElement}'s React. */
|
|
66
|
+
renderToStaticMarkup?: typeof renderToStaticMarkup;
|
|
58
67
|
}
|
|
59
68
|
|
|
60
69
|
export interface TemplateArtifacts {
|
|
@@ -76,13 +85,14 @@ export function assembleRouteElement(
|
|
|
76
85
|
layouts: ComponentType<{ children?: ReactNode }>[],
|
|
77
86
|
loaderData: unknown,
|
|
78
87
|
loaderContext: Context<unknown> | null,
|
|
88
|
+
h: typeof createElement = createElement,
|
|
79
89
|
): ReactNode {
|
|
80
|
-
let node: ReactNode =
|
|
90
|
+
let node: ReactNode = h(Page);
|
|
81
91
|
if (loaderContext) {
|
|
82
|
-
node =
|
|
92
|
+
node = h(loaderContext.Provider, { value: loaderData }, node);
|
|
83
93
|
}
|
|
84
94
|
for (let i = layouts.length - 1; i >= 0; i--) {
|
|
85
|
-
node =
|
|
95
|
+
node = h(layouts[i], null, node);
|
|
86
96
|
}
|
|
87
97
|
return node;
|
|
88
98
|
}
|
|
@@ -98,16 +108,19 @@ export function injectIntoShell(shell: string, routeHtml: string): string {
|
|
|
98
108
|
|
|
99
109
|
/** Render one route to its template artifacts (pure given its inputs). */
|
|
100
110
|
export function extractRouteTemplate(input: RouteRenderInput): TemplateArtifacts {
|
|
111
|
+
const h = input.createElement ?? createElement;
|
|
112
|
+
const render = input.renderToStaticMarkup ?? renderToStaticMarkup;
|
|
101
113
|
const element = assembleRouteElement(
|
|
102
114
|
input.Page,
|
|
103
115
|
input.layouts,
|
|
104
116
|
input.loaderData,
|
|
105
117
|
input.loaderContext,
|
|
118
|
+
h,
|
|
106
119
|
);
|
|
107
120
|
input.setSsrBuild(true);
|
|
108
121
|
let routeHtml: string;
|
|
109
122
|
try {
|
|
110
|
-
routeHtml =
|
|
123
|
+
routeHtml = render(element);
|
|
111
124
|
} finally {
|
|
112
125
|
input.setSsrBuild(false);
|
|
113
126
|
}
|
|
@@ -188,6 +201,28 @@ export async function extractTemplates(
|
|
|
188
201
|
LoaderDataContext: Context<unknown>;
|
|
189
202
|
};
|
|
190
203
|
|
|
204
|
+
// Install the ambient `Toil` global (+ registered pages / transitions) exactly
|
|
205
|
+
// as the client entry does, by evaluating the generated globals module. Without
|
|
206
|
+
// it, any layout or component using `Toil` (e.g. `<Toil.Head>`, `<Toil.Link>`)
|
|
207
|
+
// throws "Toil is not defined" during extraction, so the route is silently
|
|
208
|
+
// skipped and falls back to client rendering.
|
|
209
|
+
const globalsModule = path.join(cfg.toilDir, 'globals.ts');
|
|
210
|
+
if (fs.existsSync(globalsModule)) {
|
|
211
|
+
await server.ssrLoadModule(globalsModule);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Render with the SAME React the components import. Vite externalizes react
|
|
215
|
+
// (CommonJS) for SSR and resolves it from the app root, so resolve it the same
|
|
216
|
+
// way (from cfg.root) rather than using the compiler's own copy. Two React
|
|
217
|
+
// copies leave the hook dispatcher null, so a layout/component hook
|
|
218
|
+
// (`useLocation` -> `useRef`) throws. (`ssrLoadModule('react')` can't be used:
|
|
219
|
+
// Vite's SSR runner cannot evaluate the CJS module -> "module is not defined".)
|
|
220
|
+
const appRequire = createRequire(path.join(cfg.root, 'package.json'));
|
|
221
|
+
const react = appRequire('react') as { createElement: typeof createElement };
|
|
222
|
+
const reactDomServer = appRequire('react-dom/server') as {
|
|
223
|
+
renderToStaticMarkup: typeof renderToStaticMarkup;
|
|
224
|
+
};
|
|
225
|
+
|
|
191
226
|
const ssrDir = path.join(outDir, '_ssr');
|
|
192
227
|
const hostsTmplDir = path.join(cfg.root, 'hosts', hostName, '_tmpl');
|
|
193
228
|
const generated: string[] = [];
|
|
@@ -232,6 +267,8 @@ export async function extractTemplates(
|
|
|
232
267
|
loaderContext: client.LoaderDataContext,
|
|
233
268
|
setSsrBuild: client.__setSsrBuild,
|
|
234
269
|
shell,
|
|
270
|
+
createElement: react.createElement,
|
|
271
|
+
renderToStaticMarkup: reactDomServer.renderToStaticMarkup,
|
|
235
272
|
});
|
|
236
273
|
writeTemplateArtifacts(ssrDir, art);
|
|
237
274
|
fs.mkdirSync(hostsTmplDir, { recursive: true });
|
|
@@ -110,7 +110,7 @@ function collOf(db: DbDevState, handle: number): string | null {
|
|
|
110
110
|
export function buildDatabaseImports(
|
|
111
111
|
ref: MemoryRef,
|
|
112
112
|
db: DbDevState,
|
|
113
|
-
): Record<string, (...args: number[]) => number> {
|
|
113
|
+
): Record<string, (...args: number[]) => number | bigint> {
|
|
114
114
|
return {
|
|
115
115
|
'data.resolve_collection': (
|
|
116
116
|
namePtr: number,
|
|
@@ -586,6 +586,24 @@ export function buildDatabaseImports(
|
|
|
586
586
|
db.lastResult = null;
|
|
587
587
|
return v.length;
|
|
588
588
|
},
|
|
589
|
+
|
|
590
|
+
// `data.result_schema_version() -> i64`: the schema version the last
|
|
591
|
+
// value-returning read's row was written under, so the guest decoder can
|
|
592
|
+
// default new fields / reject an unknown layout. The production edge
|
|
593
|
+
// surfaces the real per-row version; this single-process, single-version
|
|
594
|
+
// dev store has no historical versions (data is always the current
|
|
595
|
+
// layout), so it returns -1 ("no version tracked"), which the decoder
|
|
596
|
+
// treats as "decode with the current layout". An i64 result returns a
|
|
597
|
+
// BigInt in Node's WASM ABI. (Per-row versions in dev would need catalog
|
|
598
|
+
// decoding; a follow-up if dev must exercise cross-version decode.)
|
|
599
|
+
'data.result_schema_version': (): bigint => -1n,
|
|
600
|
+
|
|
601
|
+
// `data.write_allowed() -> i32`: 1 if the current call may write. Used by
|
|
602
|
+
// the rewrite-on-read convergence after a lazy migration. The dev store is
|
|
603
|
+
// single-version, so result_schema_version always returns -1 and no
|
|
604
|
+
// migration dispatch ever fires - the convergence write is never reached
|
|
605
|
+
// here regardless. Returns 1 (the dev store permits writes) for parity.
|
|
606
|
+
'data.write_allowed': (): number => 1,
|
|
589
607
|
};
|
|
590
608
|
}
|
|
591
609
|
|
package/src/devserver/host.ts
CHANGED
|
@@ -112,6 +112,34 @@ function readGuestString(ref: MemoryRef, ptr: number): string {
|
|
|
112
112
|
return m.toString('utf16le', ptr, ptr + byteLen);
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Framework auth secrets that, when unset, SILENTLY fall back to a published,
|
|
117
|
+
* well-known dev default inside the guest (see `server/globals/auth.ts`). Reading
|
|
118
|
+
* one that is absent means the wasm is about to sign sessions / derive keys under
|
|
119
|
+
* a value anyone can read off npm, so we surface it. Harmless for local dev; a
|
|
120
|
+
* deployed node MUST set these out of band (`.env.secrets` / the dashboard).
|
|
121
|
+
*/
|
|
122
|
+
const INSECURE_DEFAULT_SECRETS: Record<string, string> = {
|
|
123
|
+
AUTH_SESSION_SECRET:
|
|
124
|
+
'session cookies will be signed with a PUBLISHED key, so anyone can forge one and skip login',
|
|
125
|
+
AUTH_OPRF_SEED: 'the password OPRF seed will be the published dev value',
|
|
126
|
+
AUTH_KEM_SK: 'the server ML-KEM secret key will be the published dev value',
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** Warned-once set, keyed by secret name, so a hot path cannot spam the log. */
|
|
130
|
+
const warnedInsecureSecrets = new Set<string>();
|
|
131
|
+
|
|
132
|
+
/** Warn (once per process) that an absent framework secret falls back to a public default. */
|
|
133
|
+
function warnInsecureSecretFallback(key: string): void {
|
|
134
|
+
if (warnedInsecureSecrets.has(key)) return;
|
|
135
|
+
warnedInsecureSecrets.add(key);
|
|
136
|
+
process.stdout.write(
|
|
137
|
+
` ⚠ ${key} is not set: ${INSECURE_DEFAULT_SECRETS[key]}. ` +
|
|
138
|
+
`Fine for local dev, but a deployed node MUST set it in .env.secrets (or on your deploy target). ` +
|
|
139
|
+
`Generate one: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"\n`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
115
143
|
/**
|
|
116
144
|
* Resolve one `Environment.get`/`getSecure` lookup against the dev env source
|
|
117
145
|
* and write it into the guest buffer, with the edge's return protocol: the value
|
|
@@ -128,7 +156,10 @@ function envLookup(
|
|
|
128
156
|
): number {
|
|
129
157
|
const key = readBytes(ref, keyPtr, keyLen).toString('utf8');
|
|
130
158
|
const val = secure ? devEnvGetSecure(key) : devEnvGet(key);
|
|
131
|
-
if (val === null)
|
|
159
|
+
if (val === null) {
|
|
160
|
+
if (secure && key in INSECURE_DEFAULT_SECRETS) warnInsecureSecretFallback(key);
|
|
161
|
+
return -2; // ABSENT
|
|
162
|
+
}
|
|
132
163
|
const bytes = Buffer.from(val, 'utf8');
|
|
133
164
|
if (bytes.length > outCap) return -1; // TOO_SMALL
|
|
134
165
|
const m = mem(ref);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { buildHostImports, freshDispatchState, type MemoryRef } from '../src/devserver/host.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The dev host warns (once) when the guest reads a framework auth secret that is unset, since the
|
|
7
|
+
* wasm then falls back to a PUBLISHED dev key (see `server/globals/auth.ts`). This is the visible
|
|
8
|
+
* counterpart to the silent fallback: without it, a server running on the dev host would sign
|
|
9
|
+
* sessions under a forgeable key with no signal. The repo root has no `.env.secrets`, so every
|
|
10
|
+
* lookup below is absent.
|
|
11
|
+
*/
|
|
12
|
+
function setup() {
|
|
13
|
+
const memory = new WebAssembly.Memory({ initial: 1 });
|
|
14
|
+
const ref: MemoryRef = { memory };
|
|
15
|
+
const env = buildHostImports(ref, freshDispatchState()).env as Record<
|
|
16
|
+
string,
|
|
17
|
+
(...a: number[]) => number
|
|
18
|
+
>;
|
|
19
|
+
const buf = Buffer.from(memory.buffer);
|
|
20
|
+
return { env, buf };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Writes `key` at offset 0 and runs `env_get_secure`, returning its status code. */
|
|
24
|
+
function getSecure(env: Record<string, (...a: number[]) => number>, buf: Buffer, key: string): number {
|
|
25
|
+
const b = Buffer.from(key, 'utf8');
|
|
26
|
+
b.copy(buf, 0);
|
|
27
|
+
return env.env_get_secure(0, b.length, 256, 256);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
vi.restoreAllMocks();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('dev host secret fallback warning', () => {
|
|
35
|
+
it('warns once that an unset AUTH_SESSION_SECRET falls back to a published key', () => {
|
|
36
|
+
const write = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
|
|
37
|
+
const { env, buf } = setup();
|
|
38
|
+
|
|
39
|
+
expect(getSecure(env, buf, 'AUTH_SESSION_SECRET')).toBe(-2); // ABSENT
|
|
40
|
+
// Repeated reads (a fresh wasm instance per request hits this every time) warn only once.
|
|
41
|
+
expect(getSecure(env, buf, 'AUTH_SESSION_SECRET')).toBe(-2);
|
|
42
|
+
|
|
43
|
+
const warnings = write.mock.calls
|
|
44
|
+
.map((c) => String(c[0]))
|
|
45
|
+
.filter((s) => s.includes('AUTH_SESSION_SECRET'));
|
|
46
|
+
expect(warnings).toHaveLength(1);
|
|
47
|
+
expect(warnings[0]).toContain('forge');
|
|
48
|
+
expect(warnings[0]).toContain('deployed node');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('does not warn for an ordinary (non-framework) absent secret', () => {
|
|
52
|
+
const write = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
|
|
53
|
+
const { env, buf } = setup();
|
|
54
|
+
|
|
55
|
+
expect(getSecure(env, buf, 'STRIPE_KEY')).toBe(-2); // ABSENT, no published default
|
|
56
|
+
const warned = write.mock.calls.some((c) => String(c[0]).includes('STRIPE_KEY'));
|
|
57
|
+
expect(warned).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
});
|
package/test/doctor.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
checkAuthSecrets,
|
|
4
5
|
checkBasePath,
|
|
5
6
|
checkDevScripts,
|
|
6
7
|
checkDuplicatePatterns,
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
checkRootElement,
|
|
15
16
|
checkRpcWiring,
|
|
16
17
|
checkSeoUrl,
|
|
18
|
+
checkServerTsPlugin,
|
|
17
19
|
checkStyling,
|
|
18
20
|
findRelativeAssets,
|
|
19
21
|
satisfiesMin,
|
|
@@ -189,6 +191,34 @@ describe('checkPrettierPlugin', () => {
|
|
|
189
191
|
});
|
|
190
192
|
});
|
|
191
193
|
|
|
194
|
+
describe('checkServerTsPlugin', () => {
|
|
195
|
+
it('passes when the toilscript LS plugin is wired', () => {
|
|
196
|
+
expect(checkServerTsPlugin(true).status).toBe('pass');
|
|
197
|
+
});
|
|
198
|
+
it('warns (not fails) when missing, naming the TS2339 false positive and --fix', () => {
|
|
199
|
+
const c = checkServerTsPlugin(false);
|
|
200
|
+
expect(c.status).toBe('warn');
|
|
201
|
+
expect(c.detail).toContain('TS2339');
|
|
202
|
+
expect(c.fix).toContain('--fix');
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('checkAuthSecrets', () => {
|
|
207
|
+
it('passes when auth is not used', () => {
|
|
208
|
+
expect(checkAuthSecrets({ usesAuth: false, sessionSecretSet: false }).status).toBe('pass');
|
|
209
|
+
});
|
|
210
|
+
it('passes when auth is used and the session secret is set', () => {
|
|
211
|
+
expect(checkAuthSecrets({ usesAuth: true, sessionSecretSet: true }).status).toBe('pass');
|
|
212
|
+
});
|
|
213
|
+
it('warns about session forgery when auth is used but the secret is unset', () => {
|
|
214
|
+
const c = checkAuthSecrets({ usesAuth: true, sessionSecretSet: false });
|
|
215
|
+
expect(c.status).toBe('warn');
|
|
216
|
+
expect(c.detail).toContain('AUTH_SESSION_SECRET');
|
|
217
|
+
expect(c.detail).toContain('forge');
|
|
218
|
+
expect(c.fix).toContain('.env.secrets');
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
192
222
|
describe('summarize', () => {
|
|
193
223
|
it('tallies pass/warn/fail across groups', () => {
|
|
194
224
|
const groups: CheckGroup[] = [
|