toiljs 0.0.55 → 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.
Files changed (99) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +72 -14
  3. package/build/backend/.tsbuildinfo +1 -1
  4. package/build/cli/.tsbuildinfo +1 -1
  5. package/build/cli/index.js +293 -142
  6. package/build/client/.tsbuildinfo +1 -1
  7. package/build/client/auth.js +1 -1
  8. package/build/client/components/Image.d.ts +1 -1
  9. package/build/client/dev/devtools.js +4 -2
  10. package/build/client/index.d.ts +2 -2
  11. package/build/client/index.js +2 -2
  12. package/build/client/routing/Router.js +1 -1
  13. package/build/client/routing/hooks.js +2 -2
  14. package/build/client/routing/mount.js +1 -1
  15. package/build/compiler/.tsbuildinfo +1 -1
  16. package/build/compiler/docs.js +1 -1
  17. package/build/compiler/seo.js +1 -3
  18. package/build/compiler/template-build.d.ts +5 -2
  19. package/build/compiler/template-build.js +19 -7
  20. package/build/devserver/.tsbuildinfo +1 -1
  21. package/build/devserver/cache.js +0 -0
  22. package/build/devserver/crypto.js +45 -17
  23. package/build/devserver/database.d.ts +1 -1
  24. package/build/devserver/database.js +84 -0
  25. package/build/devserver/email/caps.js +0 -0
  26. package/build/devserver/email/config.js +7 -2
  27. package/build/devserver/email/validate.js +1 -4
  28. package/build/devserver/host.js +18 -1
  29. package/build/devserver/index.d.ts +1 -1
  30. package/build/devserver/index.js +3 -2
  31. package/build/devserver/module.js +51 -12
  32. package/build/devserver/proxy.js +2 -1
  33. package/build/io/.tsbuildinfo +1 -1
  34. package/build/io/codec.d.ts +5 -5
  35. package/build/io/codec.js +193 -77
  36. package/examples/basic/client/components/HoneycombBackground.tsx +1 -1
  37. package/examples/basic/client/public/images/logo.svg +37 -34
  38. package/examples/basic/client/public/index.html +14 -14
  39. package/examples/basic/client/routes/auth.tsx +18 -10
  40. package/examples/basic/client/routes/cookies.tsx +15 -24
  41. package/examples/basic/client/routes/crypto.tsx +4 -5
  42. package/examples/basic/client/routes/features/template/template.tsx +1 -1
  43. package/examples/basic/client/routes/hello.tsx +1 -1
  44. package/examples/basic/client/routes/pq.tsx +14 -14
  45. package/examples/basic/client/routes/rest.tsx +1 -3
  46. package/examples/basic/client/styles/main.css +25 -22
  47. package/examples/basic/client/toil.tsx +1 -1
  48. package/examples/basic/server/README.md +8 -8
  49. package/examples/basic/server/core/AppHandler.ts +4 -7
  50. package/examples/basic/server/routes/Auth.ts +13 -10
  51. package/examples/basic/server/routes/EnvDemo.ts +9 -3
  52. package/examples/basic/server/routes/Guestbook.ts +2 -4
  53. package/package.json +26 -26
  54. package/src/backend/index.ts +4 -2
  55. package/src/cli/create.ts +19 -4
  56. package/src/cli/diagnostics.ts +48 -0
  57. package/src/cli/doctor.ts +155 -9
  58. package/src/cli/notify.ts +1 -6
  59. package/src/cli/ui.ts +3 -3
  60. package/src/cli/version-check.ts +5 -1
  61. package/src/client/auth.ts +33 -10
  62. package/src/client/components/Form.tsx +2 -2
  63. package/src/client/components/Image.tsx +1 -1
  64. package/src/client/components/Script.tsx +1 -1
  65. package/src/client/components/Slot.tsx +1 -1
  66. package/src/client/dev/devtools.tsx +126 -55
  67. package/src/client/dev/error-overlay.tsx +7 -1
  68. package/src/client/head/metadata.ts +1 -1
  69. package/src/client/index.ts +13 -2
  70. package/src/client/routing/Router.tsx +2 -2
  71. package/src/client/routing/error-boundary.tsx +1 -1
  72. package/src/client/routing/hooks.ts +5 -3
  73. package/src/client/routing/loader.ts +2 -2
  74. package/src/client/routing/mount.tsx +5 -6
  75. package/src/compiler/docs.ts +1 -1
  76. package/src/compiler/email-preview.ts +1 -1
  77. package/src/compiler/generate.ts +1 -1
  78. package/src/compiler/seo.ts +1 -3
  79. package/src/compiler/ssg.ts +10 -4
  80. package/src/compiler/template-build.ts +43 -11
  81. package/src/compiler/template.ts +1 -4
  82. package/src/compiler/vite.ts +1 -1
  83. package/src/devserver/cache.ts +0 -0
  84. package/src/devserver/crypto.ts +140 -51
  85. package/src/devserver/database.ts +168 -9
  86. package/src/devserver/dotenv.ts +10 -2
  87. package/src/devserver/email/caps.ts +0 -0
  88. package/src/devserver/email/config.ts +8 -2
  89. package/src/devserver/email/index.ts +3 -3
  90. package/src/devserver/email/validate.ts +1 -4
  91. package/src/devserver/envelope.ts +3 -3
  92. package/src/devserver/host.ts +46 -6
  93. package/src/devserver/index.ts +15 -6
  94. package/src/devserver/module.ts +56 -14
  95. package/src/devserver/proxy.ts +5 -7
  96. package/src/io/codec.ts +226 -83
  97. package/test/devserver-database.test.ts +60 -0
  98. package/test/devserver-secrets.test.ts +59 -0
  99. package/test/doctor.test.ts +30 -0
@@ -1,6 +1,5 @@
1
1
  import { Response, RouteContext } from 'toiljs/server/runtime';
2
2
  import { DataReader, DataWriter } from 'data';
3
- import { Record } from 'toildb';
4
3
 
5
4
  import { encodeSessionUser } from './Session';
6
5
 
@@ -64,11 +63,6 @@ function deriveSalt(username: string): Uint8Array {
64
63
  return crypto.sha256Text('toil-demo-salt-v1:' + username).slice(0, 16);
65
64
  }
66
65
 
67
-
68
- // ToilDB collections (the `kv.*` dev placeholder is gone). The key + value are
69
- // `@data` types: the binary codec is generated, the host marshals it, and the
70
- // challenge is consumed exactly once with `getDelete`.
71
-
72
66
  @data
73
67
  class Username {
74
68
  name: string = '';
@@ -106,8 +100,8 @@ class Challenge {
106
100
 
107
101
  @database
108
102
  class AuthDb {
109
- @collection accounts!: Record<AuthAccount, Username>;
110
- @collection challenges!: Record<Challenge, ChallengeId>;
103
+ @collection static accounts: Documents<Username, AuthAccount>;
104
+ @collection static challenges: Documents<ChallengeId, Challenge>;
111
105
  }
112
106
 
113
107
  @rest('auth')
@@ -237,8 +231,17 @@ class Auth {
237
231
  const acct = AuthDb.accounts.get(new Username(ch.username));
238
232
  if (acct == null) return fail();
239
233
  const message = AuthService.buildLoginMessage(
240
- ch.username, AUD, cid, ch.nonce, ch.iat, ch.exp,
241
- ct, DEMO_MEM_KIB, DEMO_ITERS, DEMO_PAR, AuthService.serverKemKeyId(),
234
+ ch.username,
235
+ AUD,
236
+ cid,
237
+ ch.nonce,
238
+ ch.iat,
239
+ ch.exp,
240
+ ct,
241
+ DEMO_MEM_KIB,
242
+ DEMO_ITERS,
243
+ DEMO_PAR,
244
+ AuthService.serverKemKeyId()
242
245
  );
243
246
  if (!AuthService.verifyLogin(acct.publicKey, message, sig)) return fail();
244
247
 
@@ -34,9 +34,15 @@ class EnvDemo {
34
34
  const apiKeySet = Environment.getSecure('DEMO_API_KEY') != null;
35
35
 
36
36
  const body =
37
- 'PUBLIC_GREETING=' + greeting + '\n' +
38
- 'REGION=' + region + '\n' +
39
- 'DEMO_API_KEY set=' + (apiKeySet ? 'yes' : 'no') + '\n';
37
+ 'PUBLIC_GREETING=' +
38
+ greeting +
39
+ '\n' +
40
+ 'REGION=' +
41
+ region +
42
+ '\n' +
43
+ 'DEMO_API_KEY set=' +
44
+ (apiKeySet ? 'yes' : 'no') +
45
+ '\n';
40
46
  return Response.text(body, 200);
41
47
  }
42
48
  }
@@ -1,5 +1,3 @@
1
- import { Counter, Events } from 'toildb';
2
-
3
1
  import { GuestEntry } from '../models/GuestEntry';
4
2
  import { GuestbookView } from '../models/GuestbookView';
5
3
  import { NewMessage } from '../models/NewMessage';
@@ -28,8 +26,8 @@ class GuestKey {
28
26
 
29
27
  @database
30
28
  class GuestbookDb {
31
- @collection entries!: Events<GuestEntry, GuestKey>;
32
- @collection totals!: Counter<GuestKey>;
29
+ @collection static entries: Events<GuestKey, GuestEntry>;
30
+ @collection static totals: Counter<GuestKey>;
33
31
  }
34
32
 
35
33
  /** The current total + the 10 newest entries. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "toiljs",
3
3
  "type": "module",
4
- "version": "0.0.55",
4
+ "version": "0.0.57",
5
5
  "author": "Dacely",
6
6
  "description": "The modern React framework: a file-based React frontend and a ToilScript-compiled WebAssembly backend.",
7
7
  "repository": {
@@ -120,21 +120,21 @@
120
120
  "@dacely/noble-post-quantum": "^0.6.1",
121
121
  "@noble/curves": "^2.2.0",
122
122
  "@dacely/toilscript-loader": "^0.1.0",
123
- "@eslint-react/eslint-plugin": "^5.8.8",
123
+ "@eslint-react/eslint-plugin": "^5.9.0",
124
124
  "@eslint/js": "^10.0.1",
125
- "@typescript-eslint/utils": "^8.60.0",
125
+ "@typescript-eslint/utils": "^8.61.1",
126
126
  "@vitejs/plugin-react": "^6.0.2",
127
127
  "eslint-plugin-react-hooks": "^7.1.1",
128
- "eslint-plugin-react-refresh": "^0.5.2",
128
+ "eslint-plugin-react-refresh": "^0.5.3",
129
129
  "hash-wasm": "^4.12.0",
130
- "juice": "^12.1.0",
131
- "nodemailer": "^9.0.0",
130
+ "juice": "^12.1.1",
131
+ "nodemailer": "^9.0.1",
132
132
  "picocolors": "^1.1.1",
133
- "sharp": "^0.35.0",
134
- "toilscript": "^0.1.28",
135
- "typescript-eslint": "^8.60.0",
136
- "vite": "^8.0.14",
137
- "vite-imagetools": "^10.0.0",
133
+ "sharp": "^0.35.1",
134
+ "toilscript": "^0.1.29",
135
+ "typescript-eslint": "^8.61.1",
136
+ "vite": "^8.0.16",
137
+ "vite-imagetools": "^10.0.1",
138
138
  "vite-plugin-node-polyfills": "^0.28.0"
139
139
  },
140
140
  "peerDependencies": {
@@ -145,34 +145,34 @@
145
145
  "typescript": ">=6.0.0"
146
146
  },
147
147
  "devDependencies": {
148
- "@babel/core": "^7.29.7",
149
- "@babel/preset-env": "^7.29.7",
150
- "@babel/preset-typescript": "^7.29.7",
148
+ "@babel/core": "^8.0.1",
149
+ "@babel/preset-env": "^8.0.1",
150
+ "@babel/preset-typescript": "^8.0.1",
151
151
  "@btc-vision/as-covers-assembly": "^0.4.5",
152
152
  "@btc-vision/as-covers-transform": "^0.4.5",
153
153
  "@btc-vision/as-pect-assembly": "^8.3.0",
154
154
  "@btc-vision/as-pect-cli": "^8.3.0",
155
155
  "@btc-vision/as-pect-transform": "^8.3.0",
156
- "@clack/prompts": "^1.5.0",
156
+ "@clack/prompts": "^1.5.1",
157
157
  "@microsoft/api-extractor": "7.58.9",
158
158
  "@testing-library/dom": "^10.4.1",
159
159
  "@testing-library/react": "^16.3.2",
160
- "@types/node": "^25.9.1",
160
+ "@types/node": "^26.0.0",
161
161
  "@types/nodemailer": "^8.0.1",
162
- "@types/react": "^19.2.15",
162
+ "@types/react": "^19.2.17",
163
163
  "@types/react-dom": "^19.2.3",
164
- "@vitest/coverage-v8": "^4.1.7",
165
- "@vitest/ui": "^4.1.7",
166
- "esbuild": "^0.28.0",
167
- "eslint": "^10.4.1",
164
+ "@vitest/coverage-v8": "^4.1.9",
165
+ "@vitest/ui": "^4.1.9",
166
+ "esbuild": "^0.28.1",
167
+ "eslint": "^10.5.0",
168
168
  "jsdom": "^29.1.1",
169
169
  "micromatch": "^4.0.8",
170
- "playwright": "^1.60.0",
171
- "prettier": "^3.8.3",
172
- "react": "^19.2.6",
173
- "react-dom": "^19.2.6",
170
+ "playwright": "^1.61.0",
171
+ "prettier": "^3.8.4",
172
+ "react": "^19.2.7",
173
+ "react-dom": "^19.2.7",
174
174
  "typedoc": "^0.28.19",
175
175
  "typescript": "^6.0.3",
176
- "vitest": "^4.1.7"
176
+ "vitest": "^4.1.9"
177
177
  }
178
178
  }
@@ -10,10 +10,10 @@ import fs from 'node:fs';
10
10
  import path from 'node:path';
11
11
 
12
12
  import {
13
- Server,
14
13
  type MiddlewareNext,
15
14
  type Request,
16
15
  type Response,
16
+ Server,
17
17
  type Websocket,
18
18
  } from '@dacely/hyper-express';
19
19
 
@@ -172,7 +172,9 @@ export async function startBackend(options: BackendOptions): Promise<RunningBack
172
172
  // default upgrade handler (hyper-express links it to the companion ws route). Same-origin and
173
173
  // non-browser clients pass; others get 403.
174
174
  app.upgrade(wsPath, (request: Request, response: Response) => {
175
- if (!isWsOriginAllowed(request.headers.origin, request.headers.host, options.allowedOrigins)) {
175
+ if (
176
+ !isWsOriginAllowed(request.headers.origin, request.headers.host, options.allowedOrigins)
177
+ ) {
176
178
  response.status(403).send();
177
179
  return;
178
180
  }
package/src/cli/create.ts CHANGED
@@ -114,7 +114,7 @@ function scaffold(
114
114
  '@types/react-dom': '^19.2.3',
115
115
  eslint: '^10.2.0',
116
116
  prettier: '^3.8.1',
117
- toilscript: '^0.1.27',
117
+ toilscript: '^0.1.35',
118
118
  typescript: '^6.0.3',
119
119
  };
120
120
  for (const dep of requiredPackages(features).sort()) {
@@ -165,10 +165,19 @@ function scaffold(
165
165
  '.prettierignore':
166
166
  'node_modules\nbuild\n.toil\nshared/server.ts\ntoil-env.d.ts\ntoil-routes.d.ts\nserver/_emails.ts\nserver/toil-server-env.d.ts\n',
167
167
  '.gitignore':
168
- 'node_modules\nbuild\n.toil\nshared/server.ts\ntoil-env.d.ts\ntoil-routes.d.ts\n# Local dev env vars/secrets (never commit)\n.env\n.env.secrets\n',
169
- // Use the project's pinned TypeScript (node_modules) instead of VS Code's bundled version.
168
+ 'node_modules\nbuild\n.toil\nshared/server.ts\ntoil-env.d.ts\ntoil-routes.d.ts\nhosts/*/_tmpl/\n# Local dev env vars/secrets (never commit)\n.env\n.env.secrets\n',
169
+ // Use the project's pinned TypeScript (node_modules) instead of VS Code's bundled
170
+ // version, and prompt to switch, so the editor loads the toilscript LS plugin wired
171
+ // in server/tsconfig.json (which clears the @database / @data editor false positives).
170
172
  '.vscode/settings.json':
171
- JSON.stringify({ 'typescript.tsdk': 'node_modules/typescript/lib' }, null, 4) + '\n',
173
+ JSON.stringify(
174
+ {
175
+ 'typescript.tsdk': 'node_modules/typescript/lib',
176
+ 'typescript.enablePromptUseWorkspaceTsdk': true,
177
+ },
178
+ null,
179
+ 4,
180
+ ) + '\n',
172
181
  'toil-env.d.ts': TOIL_ENV_DTS,
173
182
  // Stub typed-routes augmentation (RoutePath = string until the first dev/build regenerates it).
174
183
  'toil-routes.d.ts': '// AUTO-GENERATED by toil, do not edit.\nexport {};\n',
@@ -220,10 +229,16 @@ function scaffold(
220
229
  null,
221
230
  4,
222
231
  ) + '\n',
232
+ // The toilscript LS plugin teaches the editor about compiler-injected members
233
+ // (`@database` static collections like `Db.users`, the `@data` codec, `@user`), so
234
+ // stock TypeScript stops false-flagging them as TS2339. Editor-only; ignored by tsc.
223
235
  'server/tsconfig.json':
224
236
  JSON.stringify(
225
237
  {
226
238
  extends: 'toilscript/std/assembly.json',
239
+ compilerOptions: {
240
+ plugins: [{ name: 'toilscript/std/ts-plugin.cjs' }],
241
+ },
227
242
  include: ['./**/*.ts'],
228
243
  },
229
244
  null,
@@ -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
@@ -10,10 +10,17 @@ import { createRequire } from 'node:module';
10
10
  import path from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
12
 
13
- import { loadConfig, type ResolvedToilConfig, scanRoutes, TOIL_SERVER_ENV_DTS } from 'toiljs/compiler';
13
+ import {
14
+ loadConfig,
15
+ type ResolvedToilConfig,
16
+ scanRoutes,
17
+ TOIL_SERVER_ENV_DTS,
18
+ } from 'toiljs/compiler';
14
19
 
15
20
  import {
21
+ type AuthFacts,
16
22
  type Check,
23
+ checkAuthSecrets,
17
24
  checkBasePath,
18
25
  checkConfigLoads,
19
26
  checkDevScripts,
@@ -32,6 +39,7 @@ import {
32
39
  checkRpcWiring,
33
40
  checkSeoUrl,
34
41
  checkServerEntry,
42
+ checkServerTsPlugin,
35
43
  type CheckStatus,
36
44
  checkStyling,
37
45
  checkToilconfig,
@@ -41,8 +49,8 @@ import {
41
49
  findRelativeAssets,
42
50
  hasFailures,
43
51
  type RestFacts,
44
- type RpcFacts,
45
52
  RPC_TOILSCRIPT_MIN,
53
+ type RpcFacts,
46
54
  satisfiesMin,
47
55
  type SourceFile,
48
56
  summarize,
@@ -226,6 +234,61 @@ function gatherRestFacts(root: string, toilconfig: Record<string, unknown> | nul
226
234
  return { hasControllers, dispatched };
227
235
  }
228
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
+
229
292
  interface RpcFixResult {
230
293
  /** Files written. */
231
294
  readonly changed: string[];
@@ -353,7 +416,9 @@ function applyRpcFix(root: string): RpcFixResult {
353
416
  const serverToilconfig = readJsonObject(path.join(root, 'toilconfig.json'));
354
417
  if (serverToilconfig !== null) {
355
418
  const entries = Array.isArray(serverToilconfig.entries)
356
- ? (serverToilconfig.entries as unknown[]).filter((e): e is string => typeof e === 'string')
419
+ ? (serverToilconfig.entries as unknown[]).filter(
420
+ (e): e is string => typeof e === 'string',
421
+ )
357
422
  : [];
358
423
  const dirs = new Set<string>();
359
424
  for (const e of entries) dirs.add(path.dirname(path.resolve(root, e)));
@@ -464,6 +529,64 @@ function applyPrettierFix(root: string, pkg: Record<string, unknown> | null): Rp
464
529
  return { changed, skipped };
465
530
  }
466
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
+
467
590
  /** Reads the framework's own package.json (engines + peerDependencies) for the requirements. */
468
591
  function frameworkMeta(): { node: string; peers: Record<string, string> } {
469
592
  const pkgPath = path.resolve(
@@ -624,20 +747,37 @@ export async function runDoctor(opts: DoctorOptions): Promise<void> {
624
747
  const peerName = (n: string): Check => checkPeer(n, deps[n] ?? null, meta.peers[n] ?? '*');
625
748
  const peerChecks = Object.keys(meta.peers).map(peerName);
626
749
 
627
- // 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.
628
752
  const rpcFix = serverPresent && opts.fix ? applyRpcFix(root) : null;
629
753
  const prettierFix = serverPresent && opts.fix ? applyPrettierFix(root, projectPkg) : null;
754
+ const editorFix = serverPresent && opts.fix ? applyServerEditorFix(root, toilconfig) : null;
630
755
  const rpcFacts = gatherRpcFacts(root);
631
756
  const restFacts = gatherRestFacts(root, toilconfig);
757
+ const authFacts = gatherAuthFacts(root, toilconfig);
632
758
  const prettierPresent = prettierPluginPresent(
633
759
  root,
634
760
  readJsonObject(path.join(root, 'package.json')),
635
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);
636
768
  const serverFix =
637
- rpcFix || prettierFix
769
+ rpcFix || prettierFix || editorFix
638
770
  ? {
639
- changed: [...(rpcFix?.changed ?? []), ...(prettierFix?.changed ?? [])],
640
- 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
+ ],
641
781
  }
642
782
  : null;
643
783
 
@@ -699,11 +839,17 @@ export async function runDoctor(opts: DoctorOptions): Promise<void> {
699
839
  checkRpcWiring(rpcFacts),
700
840
  checkRestDispatch(restFacts),
701
841
  checkPrettierPlugin(prettierPresent),
842
+ checkServerTsPlugin(serverTsPluginPresent),
702
843
  ]
703
844
  : [checkToilconfig(false)],
704
845
  },
705
846
  ];
706
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
+
707
853
  const summary = summarize(groups);
708
854
  if (opts.json) {
709
855
  process.stdout.write(JSON.stringify({ groups, summary, fixed: serverFix }, null, 2) + '\n');
@@ -724,14 +870,14 @@ export async function runDoctor(opts: DoctorOptions): Promise<void> {
724
870
  function renderRpcFix(result: RpcFixResult): void {
725
871
  const out: string[] = [];
726
872
  if (result.changed.length > 0) {
727
- out.push(' ' + success('fixed RPC wiring') + dim(` ${result.changed.join(', ')}`));
873
+ out.push(' ' + success('fixed server wiring') + dim(` ${result.changed.join(', ')}`));
728
874
  if (result.changed.includes('package.json')) {
729
875
  out.push(
730
876
  ' ' + dim('run your installer (npm/pnpm/yarn) if the toilscript version changed.'),
731
877
  );
732
878
  }
733
879
  } else {
734
- out.push(' ' + dim('RPC wiring already in place, nothing to fix.'));
880
+ out.push(' ' + dim('server wiring already in place, nothing to fix.'));
735
881
  }
736
882
  for (const item of result.skipped) out.push(' ' + warn('skipped') + dim(` ${item}`));
737
883
  process.stdout.write(out.join('\n') + '\n\n');
package/src/cli/notify.ts CHANGED
@@ -13,12 +13,7 @@ import { fileURLToPath } from 'node:url';
13
13
 
14
14
  import { detectPackageManager } from './update.js';
15
15
  import { accent, bold, box, dim, version as cliVersion, warn } from './ui.js';
16
- import {
17
- findOutdated,
18
- isCacheFresh,
19
- type OutdatedRow,
20
- parseCheckCache,
21
- } from './version-check.js';
16
+ import { findOutdated, isCacheFresh, type OutdatedRow, parseCheckCache } from './version-check.js';
22
17
 
23
18
  const REGISTRY_URL = 'https://registry.npmjs.org/toiljs/latest';
24
19
  const FETCH_TIMEOUT_MS = 2000;
package/src/cli/ui.ts CHANGED
@@ -118,9 +118,7 @@ function visibleWidth(s: string): number {
118
118
  export function box(lines: readonly string[], paint: (s: string) => string = (s) => s): string {
119
119
  const width = lines.reduce((w, l) => Math.max(w, visibleWidth(l)), 0);
120
120
  const side = paint('│');
121
- const body = lines.map(
122
- (l) => ` ${side} ${l}${' '.repeat(width - visibleWidth(l))} ${side}`,
123
- );
121
+ const body = lines.map((l) => ` ${side} ${l}${' '.repeat(width - visibleWidth(l))} ${side}`);
124
122
  return [
125
123
  ' ' + paint(`╭${'─'.repeat(width + 4)}╮`),
126
124
  ...body,
@@ -167,3 +165,5 @@ export function banner(): void {
167
165
  const ver = `${dim(' v')}${brand(version())}`;
168
166
  process.stdout.write('\n' + lines.join('\n') + '\n\n ' + tagline() + ' ' + ver + '\n\n');
169
167
  }
168
+
169
+
@@ -29,7 +29,11 @@ export function parseCheckCache(raw: string): CheckCache | null {
29
29
  }
30
30
 
31
31
  /** True when the cached answer is still trustworthy (also stale if the clock went backwards). */
32
- export function isCacheFresh(cache: CheckCache, now: number, ttlMs: number = CHECK_TTL_MS): boolean {
32
+ export function isCacheFresh(
33
+ cache: CheckCache,
34
+ now: number,
35
+ ttlMs: number = CHECK_TTL_MS,
36
+ ): boolean {
33
37
  return cache.checkedAt <= now && now - cache.checkedAt < ttlMs;
34
38
  }
35
39