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.
@@ -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 place, then re-read.
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: [...(rpcFix?.changed ?? []), ...(prettierFix?.changed ?? [])],
647
- skipped: [...(rpcFix?.skipped ?? []), ...(prettierFix?.skipped ?? [])],
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 RPC wiring') + dim(` ${result.changed.join(', ')}`));
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('RPC wiring already in place, nothing to fix.'));
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
- let prefs: Prefs = typeof localStorage !== 'undefined' ? loadPrefs() : defaultPrefs;
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 = createElement(Page);
90
+ let node: ReactNode = h(Page);
81
91
  if (loaderContext) {
82
- node = createElement(loaderContext.Provider, { value: loaderData }, node);
92
+ node = h(loaderContext.Provider, { value: loaderData }, node);
83
93
  }
84
94
  for (let i = layouts.length - 1; i >= 0; i--) {
85
- node = createElement(layouts[i], null, 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 = renderToStaticMarkup(element);
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
 
@@ -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) return -2; // ABSENT
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
+ });
@@ -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[] = [