codebase-onboarder 0.1.0

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 (216) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +275 -0
  3. package/bin/onboarder.js +14 -0
  4. package/bin/postinstall.js +6 -0
  5. package/cli/commands.js +436 -0
  6. package/cli/main.js +129 -0
  7. package/cli/prompt.js +101 -0
  8. package/cli/ui.js +55 -0
  9. package/cli/wizard.js +325 -0
  10. package/package.json +48 -0
  11. package/public/app.js +1339 -0
  12. package/public/index.html +455 -0
  13. package/public/js/about.js +284 -0
  14. package/public/js/aiDraft.js +163 -0
  15. package/public/js/analysisPanel.js +270 -0
  16. package/public/js/analysisReport.js +154 -0
  17. package/public/js/api.js +264 -0
  18. package/public/js/atlas.js +98 -0
  19. package/public/js/blameView.js +41 -0
  20. package/public/js/codeTab.js +256 -0
  21. package/public/js/codeViewer.js +184 -0
  22. package/public/js/components/FileChip.js +102 -0
  23. package/public/js/components/MetricSparkline.js +89 -0
  24. package/public/js/components/RiskBadge.js +105 -0
  25. package/public/js/deepAnalysisView.js +496 -0
  26. package/public/js/diagramPane.js +154 -0
  27. package/public/js/diffView.js +352 -0
  28. package/public/js/docsView.js +566 -0
  29. package/public/js/fileSourceBrowser.js +47 -0
  30. package/public/js/flameGraph.js +75 -0
  31. package/public/js/forceGraph.js +592 -0
  32. package/public/js/heatmap.js +185 -0
  33. package/public/js/highlight.js +122 -0
  34. package/public/js/html.js +50 -0
  35. package/public/js/insightsView.js +236 -0
  36. package/public/js/inspector.js +535 -0
  37. package/public/js/llm.js +345 -0
  38. package/public/js/mapView.js +159 -0
  39. package/public/js/markdown.js +119 -0
  40. package/public/js/mindmap.js +289 -0
  41. package/public/js/repoFiles.js +44 -0
  42. package/public/js/sbomView.js +142 -0
  43. package/public/js/scanCache.js +76 -0
  44. package/public/js/search.js +874 -0
  45. package/public/js/serverSettings.js +169 -0
  46. package/public/js/state.js +192 -0
  47. package/public/js/tour.js +17 -0
  48. package/public/js/transitions.js +15 -0
  49. package/public/js/tree.js +200 -0
  50. package/public/js/workflowsView.js +95 -0
  51. package/public/styles.css +2767 -0
  52. package/public/vendor/mermaid.min.js +3587 -0
  53. package/public/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
  54. package/public/vendor/monaco/vs/base/worker/workerMain.js +31 -0
  55. package/public/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
  56. package/public/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
  57. package/public/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
  58. package/public/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
  59. package/public/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
  60. package/public/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
  61. package/public/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
  62. package/public/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
  63. package/public/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
  64. package/public/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
  65. package/public/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
  66. package/public/vendor/monaco/vs/basic-languages/css/css.js +12 -0
  67. package/public/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
  68. package/public/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
  69. package/public/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
  70. package/public/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
  71. package/public/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
  72. package/public/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
  73. package/public/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
  74. package/public/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
  75. package/public/vendor/monaco/vs/basic-languages/go/go.js +10 -0
  76. package/public/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
  77. package/public/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
  78. package/public/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
  79. package/public/vendor/monaco/vs/basic-languages/html/html.js +10 -0
  80. package/public/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
  81. package/public/vendor/monaco/vs/basic-languages/java/java.js +10 -0
  82. package/public/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
  83. package/public/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
  84. package/public/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
  85. package/public/vendor/monaco/vs/basic-languages/less/less.js +11 -0
  86. package/public/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
  87. package/public/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
  88. package/public/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
  89. package/public/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
  90. package/public/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
  91. package/public/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
  92. package/public/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
  93. package/public/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
  94. package/public/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
  95. package/public/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
  96. package/public/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
  97. package/public/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
  98. package/public/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
  99. package/public/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
  100. package/public/vendor/monaco/vs/basic-languages/php/php.js +10 -0
  101. package/public/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
  102. package/public/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
  103. package/public/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
  104. package/public/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
  105. package/public/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
  106. package/public/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
  107. package/public/vendor/monaco/vs/basic-languages/python/python.js +10 -0
  108. package/public/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
  109. package/public/vendor/monaco/vs/basic-languages/r/r.js +10 -0
  110. package/public/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
  111. package/public/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
  112. package/public/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
  113. package/public/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
  114. package/public/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
  115. package/public/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
  116. package/public/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
  117. package/public/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
  118. package/public/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
  119. package/public/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
  120. package/public/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
  121. package/public/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
  122. package/public/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
  123. package/public/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
  124. package/public/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
  125. package/public/vendor/monaco/vs/basic-languages/st/st.js +10 -0
  126. package/public/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
  127. package/public/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
  128. package/public/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
  129. package/public/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
  130. package/public/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
  131. package/public/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
  132. package/public/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
  133. package/public/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
  134. package/public/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
  135. package/public/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
  136. package/public/vendor/monaco/vs/editor/editor.main.css +8 -0
  137. package/public/vendor/monaco/vs/editor/editor.main.js +798 -0
  138. package/public/vendor/monaco/vs/language/css/cssMode.js +13 -0
  139. package/public/vendor/monaco/vs/language/css/cssWorker.js +77 -0
  140. package/public/vendor/monaco/vs/language/html/htmlMode.js +13 -0
  141. package/public/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
  142. package/public/vendor/monaco/vs/language/json/jsonMode.js +19 -0
  143. package/public/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
  144. package/public/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
  145. package/public/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
  146. package/public/vendor/monaco/vs/loader.js +11 -0
  147. package/public/vendor/monaco/worker-boot.js +9 -0
  148. package/server/.fuse_hidden0000000800000001 +36 -0
  149. package/server/apiDiff.js +25 -0
  150. package/server/apiDocs.js +67 -0
  151. package/server/apiFile.js +36 -0
  152. package/server/apiGitBlame.js +63 -0
  153. package/server/apiMcp.js +110 -0
  154. package/server/apiScan.js +119 -0
  155. package/server/apiSearch.js +253 -0
  156. package/server/apiSettings.js +118 -0
  157. package/server/apiTools.js +90 -0
  158. package/server/config.js +227 -0
  159. package/server/fileSourceNode.js +44 -0
  160. package/server/gitClone.js +95 -0
  161. package/server/gitDiff.js +184 -0
  162. package/server/gitHistory.js +110 -0
  163. package/server/htmlText.js +44 -0
  164. package/server/http.js +55 -0
  165. package/server/httpGuards.js +87 -0
  166. package/server/index.js +156 -0
  167. package/server/llmProxy.js +162 -0
  168. package/server/logger.js +29 -0
  169. package/server/mcp/analysis.js +209 -0
  170. package/server/mcp/http.js +213 -0
  171. package/server/mcp/runner.js +278 -0
  172. package/server/mcp/server.js +241 -0
  173. package/server/mcp/standalone.js +42 -0
  174. package/server/mcp/tools.js +683 -0
  175. package/server/paths.js +39 -0
  176. package/server/router.js +218 -0
  177. package/server/searchIndex.js +118 -0
  178. package/server/sessions.js +163 -0
  179. package/server/static.js +59 -0
  180. package/server/tools/install.js +246 -0
  181. package/server/tools/parse.js +170 -0
  182. package/server/tools/platform.js +91 -0
  183. package/server/tools/registry.js +212 -0
  184. package/server/tools/scan.js +136 -0
  185. package/server/tools.js +212 -0
  186. package/server/tunnel.js +92 -0
  187. package/shared/analyzer/docs.js +135 -0
  188. package/shared/analyzer/explainLocal.js +163 -0
  189. package/shared/analyzer/graph.js +461 -0
  190. package/shared/analyzer/health.js +215 -0
  191. package/shared/analyzer/history.js +146 -0
  192. package/shared/analyzer/languages/csharp.js +39 -0
  193. package/shared/analyzer/languages/generic.js +131 -0
  194. package/shared/analyzer/languages/go.js +70 -0
  195. package/shared/analyzer/languages/index.js +43 -0
  196. package/shared/analyzer/languages/java.js +39 -0
  197. package/shared/analyzer/languages/javascript.js +240 -0
  198. package/shared/analyzer/languages/python.js +120 -0
  199. package/shared/analyzer/languages/rust.js +42 -0
  200. package/shared/analyzer/languages/typescript.js +138 -0
  201. package/shared/analyzer/licenses.js +151 -0
  202. package/shared/analyzer/metrics.js +120 -0
  203. package/shared/analyzer/pathUtil.js +69 -0
  204. package/shared/analyzer/patterns.js +272 -0
  205. package/shared/analyzer/scan.js +473 -0
  206. package/shared/analyzer/security.js +187 -0
  207. package/shared/analyzer/services.js +157 -0
  208. package/shared/analyzer/stack.js +173 -0
  209. package/shared/analyzer/tour.js +69 -0
  210. package/shared/analyzer/util.js +123 -0
  211. package/shared/analyzer/workflows.js +143 -0
  212. package/shared/diagram/aiFacts.js +145 -0
  213. package/shared/diagram/aiMermaid.js +59 -0
  214. package/shared/diagram/atlas.js +93 -0
  215. package/shared/diagram/mermaid.js +459 -0
  216. package/shared/search/query.js +518 -0
@@ -0,0 +1,39 @@
1
+ // Path containment, in one place. Anything that turns a string the client sent
2
+ // into a filesystem path goes through here first.
3
+
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+
7
+ // Is `abs` the root itself, or something inside it?
8
+ //
9
+ // The obvious `abs.startsWith(root)` is the wrong check: with a root of
10
+ // `/home/me/repo` it also says yes to `/home/me/repo-secrets/.env`, because
11
+ // the prefix matches before the separator does. The separator has to be part
12
+ // of the comparison.
13
+ export function isInside(rootAbs, abs) {
14
+ const root = path.resolve(rootAbs);
15
+ const target = path.resolve(abs);
16
+ if (target === root) return true;
17
+ return target.startsWith(root.endsWith(path.sep) ? root : root + path.sep);
18
+ }
19
+
20
+ // Resolve a repo-relative POSIX path (`src/app.js`) against a root, or return
21
+ // null if it doesn't land inside that root.
22
+ //
23
+ // `path.join` normalizes `..` away before we look, so this judges a path by
24
+ // where it ends up rather than by what it looks like — there's no spelling of
25
+ // `..` to outsmart, and a leading `/` becomes an empty segment instead of an
26
+ // absolute path.
27
+ export function resolveInside(rootAbs, relPath) {
28
+ if (typeof relPath !== 'string' || relPath === '') return null;
29
+ if (relPath.includes('\0')) return null; // fs throws on these; a 400 reads better
30
+ const abs = path.join(rootAbs, ...relPath.split('/'));
31
+ return isInside(rootAbs, abs) ? abs : null;
32
+ }
33
+
34
+ // `~` and `~/code/thing` are what people actually type into the path box.
35
+ export function expandHome(p) {
36
+ if (p === '~') return os.homedir();
37
+ if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
38
+ return path.resolve(p);
39
+ }
@@ -0,0 +1,218 @@
1
+ // The route table and the two gates in front of it.
2
+ //
3
+ // Everything arrives here: one function decides whether a request is allowed to
4
+ // be answered at all, then which handler answers it. The routes are a list rather
5
+ // than a ladder of `if` statements so that the whole surface of the server is
6
+ // visible in one screen — six endpoints, and everything else is a static file.
7
+ //
8
+ // Neither gate is authentication. A local server with no accounts cannot
9
+ // authenticate anyone; what it can do is refuse requests that a browser on some
10
+ // other site made on the person's behalf. `server/httpGuards.js` explains both
11
+ // attacks; the short version is that `Host` stops DNS rebinding from turning
12
+ // `evil.com` into our own origin, and `Origin`/`Sec-Fetch-Site` stops a page the
13
+ // person happened to have open from driving the API.
14
+
15
+ import { handleDocs } from './apiDocs.js';
16
+ import { handleFile } from './apiFile.js';
17
+ import { handleCleanup, handleScan } from './apiScan.js';
18
+ import { sendError, sendJSON, readBody } from './http.js';
19
+ import { crossOriginReason, rebindingReason } from './httpGuards.js';
20
+ import { proxyChat } from './llmProxy.js';
21
+ import { serveStatic } from './static.js';
22
+ import { handleSearch } from './apiSearch.js';
23
+ import { handleBlame } from './apiGitBlame.js';
24
+ import { handleDiff, handleDiffRefs } from './apiDiff.js';
25
+ import { handleToolsInstall, handleToolsRun, handleToolsStatus } from './apiTools.js';
26
+ import { handleMcpStart, handleMcpStatus, handleMcpStop, handleMcpCommand } from './apiMcp.js';
27
+ import { handleGetSettings, handleRotateAccessKey, handleUpdateSettings } from './apiSettings.js';
28
+ import { allowedHosts, authReason, DEFAULT_SETTINGS } from './config.js';
29
+
30
+ const ROUTES = [
31
+ {
32
+ method: 'GET', path: '/api/diff/refs', sameOrigin: true,
33
+ run: ({ res, url }) => handleDiffRefs(res, url.searchParams.get('scan')),
34
+ },
35
+ {
36
+ method: 'GET', path: '/api/diff', sameOrigin: true,
37
+ run: ({ res, url }) => handleDiff(res, url.searchParams.get('scan'), url.searchParams),
38
+ },
39
+ {
40
+ method: 'POST', path: '/api/search', body: true,
41
+ run: ({ res, body }) => handleSearch(res, body),
42
+ },
43
+ {
44
+ method: 'GET', path: '/api/blame', sameOrigin: true,
45
+ run: ({ res, url }) => handleBlame(res, url.searchParams.get('scan'), url.searchParams.get('path')),
46
+ },
47
+ {
48
+ method: 'POST', path: '/api/scan', body: true,
49
+ run: ({ res, body, config }) => handleScan(res, body, config),
50
+ },
51
+ {
52
+ method: 'DELETE', prefix: '/api/scan/',
53
+ run: ({ res, rest }) => handleCleanup(res, decodeURIComponent(rest)),
54
+ },
55
+ {
56
+ method: 'GET', path: '/api/file', sameOrigin: true,
57
+ run: ({ res, url }) => handleFile(res, url.searchParams.get('scan'), url.searchParams.get('path')),
58
+ },
59
+ {
60
+ // The proxy is the handler; there is no wrapper to write. It forwards the
61
+ // browser's key to the endpoint the browser chose and never keeps either.
62
+ method: 'POST', path: '/api/explain', body: true,
63
+ run: ({ res, body }) => proxyChat(res, body),
64
+ },
65
+ {
66
+ method: 'POST', path: '/api/doc', body: true,
67
+ run: ({ res, body }) => handleDocs(res, body),
68
+ },
69
+ {
70
+ // Reports which analyzers are installed — environment information a foreign
71
+ // page has no business reading, so it is guarded like /api/file.
72
+ method: 'GET', path: '/api/tools', sameOrigin: true,
73
+ run: ({ res }) => handleToolsStatus(res),
74
+ },
75
+ {
76
+ // Runs real analyzers over a scanned root. `body: true` applies the size
77
+ // limit; the session check in the handler is the capability gate.
78
+ method: 'POST', path: '/api/tools/run', body: true,
79
+ run: ({ res, body }) => handleToolsRun(res, body),
80
+ },
81
+ {
82
+ // Installs a missing analyzer from the GUI. Same-origin like every POST;
83
+ // the tool name is validated against the registry and the install plans
84
+ // are fixed data, so the request body never reaches a command line.
85
+ method: 'POST', path: '/api/tools/install', body: true,
86
+ run: ({ res, body }) => handleToolsInstall(res, body),
87
+ },
88
+ {
89
+ // The MCP button's state, and the command an agent harness is configured with.
90
+ // The config is not secret, but it is a path into this machine and a foreign
91
+ // page has no business reading either one.
92
+ method: 'GET', path: '/api/mcp', sameOrigin: true,
93
+ run: ({ req, res, config }) => handleMcpStatus(req, res, config),
94
+ },
95
+ {
96
+ method: 'GET', path: '/api/mcp/command', sameOrigin: true,
97
+ run: ({ req, res, config }) => handleMcpCommand(req, res, config),
98
+ },
99
+ {
100
+ // Starting and stopping a process is a side effect on the person's machine, so
101
+ // these take a body like every other POST and are checked like every other POST.
102
+ method: 'POST', path: '/api/mcp/start', body: true,
103
+ run: ({ req, res, config }) => handleMcpStart(req, res, config),
104
+ },
105
+ {
106
+ method: 'POST', path: '/api/mcp/stop', body: true,
107
+ run: ({ req, res, config }) => handleMcpStop(req, res, config),
108
+ },
109
+ {
110
+ method: 'GET', path: '/api/health',
111
+ run: ({ res }) => sendJSON(res, 200, { ok: true }),
112
+ },
113
+ {
114
+ // The settings drawer and the CLI read the same public shape: everything
115
+ // about the configuration except the access key itself.
116
+ method: 'GET', path: '/api/settings', sameOrigin: true,
117
+ run: ({ req, res, config }) => handleGetSettings(req, res, config),
118
+ },
119
+ {
120
+ // PATCH semantics over a small allow-listed key set; the validation that
121
+ // rejects a bad port or a lawless mode lives in `server/config.js` and is
122
+ // shared with the CLI wizard, so both surfaces enforce one schema.
123
+ method: 'PUT', path: '/api/settings', body: true,
124
+ run: ({ res, body, config }) => handleUpdateSettings(res, body, config),
125
+ },
126
+ {
127
+ // The only way the key changes: generated on the server, shown once in
128
+ // this response, never readable again. In self-hosted mode this endpoint
129
+ // is itself behind the current key, so rotation requires possession.
130
+ method: 'POST', path: '/api/settings/access-key', body: true,
131
+ run: ({ res, config }) => handleRotateAccessKey(res, config),
132
+ },
133
+ ];
134
+
135
+ // The table itself, for the test that walks it. Exported read-only: the routes
136
+ // are decided here, not assembled by whoever imports this.
137
+ export const routes = Object.freeze(ROUTES.map((r) => Object.freeze({ ...r })));
138
+
139
+ export function matchRoute(method, pathname) {
140
+ for (const route of ROUTES) {
141
+ if (route.method !== method) continue;
142
+ if (route.path === pathname) return { route, rest: '' };
143
+ if (route.prefix && pathname.startsWith(route.prefix)) {
144
+ return { route, rest: pathname.slice(route.prefix.length) };
145
+ }
146
+ }
147
+ return null;
148
+ }
149
+
150
+ // `config` carries the directories the handlers need — the project root to scan
151
+ // for the demo, and the two roots static serving maps into. Passed in rather
152
+ // than resolved here so a test can point the server somewhere else, and so this
153
+ // module has nothing to say about where it was installed.
154
+ //
155
+ // Settings arrive the same way: `config.getSettings` is read on every request,
156
+ // so a key rotated or a domain changed through the API takes effect on the next
157
+ // request without a restart. A bare `createServer()` (the tests) has no getter
158
+ // and sees the local-mode defaults — never somebody's home directory.
159
+ async function liveSettings(config) {
160
+ if (typeof config?.getSettings !== 'function') return DEFAULT_SETTINGS;
161
+ try {
162
+ return await config.getSettings();
163
+ } catch {
164
+ return null; // unreadable config: fail closed, every /api call gets a 500
165
+ }
166
+ }
167
+
168
+ export function createRouter(config) {
169
+ return async function handleRequest(req, res) {
170
+ try {
171
+ const url = new URL(req.url, 'http://127.0.0.1');
172
+
173
+ const settings = await liveSettings(config);
174
+ if (!settings) {
175
+ return sendError(res, 500, 'The settings file could not be read — run `onboarder doctor` to find out why.');
176
+ }
177
+
178
+ const wrongHost = rebindingReason(req, allowedHosts(settings));
179
+ if (wrongHost) {
180
+ const scope = settings.mode === 'self-hosted'
181
+ ? 'Onboarder only answers to its configured host and domain. '
182
+ : 'Onboarder only answers to localhost. ';
183
+ return sendError(res, 403, scope + wrongHost);
184
+ }
185
+
186
+ const found = matchRoute(req.method, url.pathname);
187
+ if (req.method !== 'GET' || found?.route.sameOrigin) {
188
+ const foreign = crossOriginReason(req);
189
+ if (foreign) {
190
+ return sendError(res, 403, 'That request did not come from Onboarder’s own page. ' + foreign);
191
+ }
192
+ }
193
+
194
+ // The self-hosted gate. It is authentication, unlike the two guards
195
+ // above: the mode says the network can reach us, so every API call
196
+ // proves it holds the access key. `/api/health` stays open — a tunnel
197
+ // or uptime check has no key and tells an attacker nothing.
198
+ if (found && url.pathname.startsWith('/api/') && url.pathname !== '/api/health') {
199
+ const denied = authReason(req, settings);
200
+ if (denied) return sendError(res, 401, denied);
201
+ }
202
+
203
+ if (found) {
204
+ const body = found.route.body ? await readBody(req) : null;
205
+ return await found.route.run({ req, res, url, body, rest: found.rest, config });
206
+ }
207
+
208
+ if (req.method === 'GET') return await serveStatic(res, url.pathname, config);
209
+
210
+ sendError(res, 404, 'Nothing lives at ' + url.pathname + '.');
211
+ } catch (err) {
212
+ // The last resort. Handlers turn their own expected failures into a status
213
+ // that says something useful; anything reaching here is a bug or a broken
214
+ // request body, and the message is more use to the person than "500".
215
+ sendError(res, 500, err.message || 'Something went sideways.');
216
+ }
217
+ };
218
+ }
@@ -0,0 +1,118 @@
1
+ // The search index lives with the scan, not behind a request. Building it
2
+ // here means:
3
+ //
4
+ // * The first /api/search call is fast. The user is searching — make it feel
5
+ // that way; the work that would have been a request-blocking second walk
6
+ // is already done.
7
+ // * The index reads exactly the files the scanner did. The caller hands us
8
+ // `scan.files`, the list of paths the scanner accepted; we never walk
9
+ // the tree ourselves, so the index can never disagree with the scan
10
+ // about which files exist.
11
+ // * The index has a hard cap. A repo with a single 200 MB SQL dump used to
12
+ // hold its full content in the process for the lifetime of the session;
13
+ // now it stops indexing at the cap and records what it skipped, so a
14
+ // future "why doesn't my file show up?" question has a real answer.
15
+ //
16
+ // The on-disk cost of the work the old /api/search did — re-listing every
17
+ // directory, re-reading every file, parsing its tokens — is gone. The
18
+ // per-request cost is one Map lookup plus the scoring pass over matching
19
+ // documents, which is bounded by the index size, not the repo size.
20
+
21
+ const DEFAULTS = {
22
+ // Aggregate content size. Files beyond this are not indexed, only counted.
23
+ // 200 MB is enough to index the source of a large monorepo comfortably;
24
+ // past that the search would be matching against vendor copies and
25
+ // generated code more often than against code people wrote.
26
+ maxTotalBytes: 200 * 1024 * 1024,
27
+ // Per-file ceiling. Files larger than this are skipped (the scanner has
28
+ // its own ceiling for the same reason).
29
+ maxFileBytes: 1024 * 1024,
30
+ // Reads in flight at once. Same shape as `scan.js`'s read-ahead — a file
31
+ // is held in memory whole before the next one starts.
32
+ width: 8,
33
+ };
34
+
35
+ const BINARY_HEAD_BYTES = 1000;
36
+
37
+ function looksBinary(content) {
38
+ // NUL in the first KB is a reliable, cheap heuristic for "this is not
39
+ // text the search would help with". The first KB also covers the BOM and
40
+ // the shebang without missing the start of the file.
41
+ return content.slice(0, BINARY_HEAD_BYTES).includes('\0');
42
+ }
43
+
44
+ // Read each path's text up to width at a time. Failures are skipped, the
45
+ // same way `scan.js:readAhead` handles them: one unreadable file should not
46
+ // end an index build, and the caller counts it in `skipped.readFailed`.
47
+ async function* readAhead(source, paths, width) {
48
+ const inFlight = [];
49
+ let next = 0;
50
+ const read = (p) =>
51
+ Promise.resolve()
52
+ .then(() => source.read(p))
53
+ .then(
54
+ (text) => ({ path: p, text, failed: false }),
55
+ () => ({ path: p, text: '', failed: true }),
56
+ );
57
+
58
+ while (next < paths.length && inFlight.length < width) inFlight.push(read(paths[next++]));
59
+ while (inFlight.length) {
60
+ const settled = inFlight.shift();
61
+ if (next < paths.length) inFlight.push(read(paths[next++]));
62
+ yield await settled;
63
+ }
64
+ }
65
+
66
+ // Build the search index from the file list the scanner already produced.
67
+ // The scanner has read these files once for parsing; indexing reads them a
68
+ // second time for tokenizing, which is the cheapest place to do it — a
69
+ // refactor that streamed text from the scanner into both consumers would
70
+ // save one disk pass at the cost of a much larger refactor. This is the
71
+ // smallest change that closes the audit's three real concerns (first-search
72
+ // latency, no aggregate cap, no concurrency).
73
+ export async function buildSearchIndex(source, codeFiles, options = {}) {
74
+ const opts = { ...DEFAULTS, ...options };
75
+ const index = new Map();
76
+ const docCounts = new Map();
77
+ let totalDocs = 0;
78
+ let totalBytes = 0;
79
+
80
+ // Files the indexer cannot or will not index, broken down by reason. The
81
+ // shape matches `scan.js:skips` so a future UI surface can render one
82
+ // unified "what was left out" panel without translation.
83
+ const skipped = {
84
+ tooLarge: 0, // one file over maxFileBytes
85
+ overCap: 0, // a later file pushed the aggregate over maxTotalBytes
86
+ readFailed: 0, // the file would not read
87
+ binary: 0, // NUL in the first KB
88
+ noTokens: 0, // the file read, but tokenizing produced nothing useful
89
+ };
90
+
91
+ for await (const { path, text, failed } of readAhead(source, codeFiles, opts.width)) {
92
+ if (failed) { skipped.readFailed++; continue; }
93
+
94
+ if (text.length > opts.maxFileBytes) { skipped.tooLarge++; continue; }
95
+ if (totalBytes + text.length > opts.maxTotalBytes) { skipped.overCap++; continue; }
96
+ if (looksBinary(text)) { skipped.binary++; continue; }
97
+
98
+ const tokens = text.toLowerCase().split(/\W+/).filter((t) => t.length > 1);
99
+ if (!tokens.length) { skipped.noTokens++; continue; }
100
+
101
+ const tf = new Map();
102
+ for (const t of tokens) tf.set(t, (tf.get(t) || 0) + 1);
103
+ for (const t of tf.keys()) docCounts.set(t, (docCounts.get(t) || 0) + 1);
104
+
105
+ index.set(path, { tf, content: text, tokenCount: tokens.length });
106
+ totalDocs++;
107
+ totalBytes += text.length;
108
+ }
109
+
110
+ return {
111
+ index,
112
+ docCounts,
113
+ totalDocs,
114
+ totalBytes,
115
+ skipped,
116
+ cap: { maxFileBytes: opts.maxFileBytes, maxTotalBytes: opts.maxTotalBytes },
117
+ };
118
+ }
@@ -0,0 +1,163 @@
1
+ // What the server remembers between requests: which directory each scan came
2
+ // from. `/api/file` needs it — the browser asks for `server/paths.js` and the
3
+ // server has to know which root that is relative to — and the git-URL flow needs
4
+ // it to find a clone again when the page says it is done with one.
5
+ //
6
+ // A scan id is the capability. Handing the browser a path and trusting it back
7
+ // would make `/api/file` a "read any file on this machine" endpoint; handing it
8
+ // an opaque id means the only readable roots are ones the person chose to scan
9
+ // during this run.
10
+ //
11
+ // Two things the inline Map this replaces got wrong:
12
+ //
13
+ // * It never forgot. Every scan added an entry and nothing removed one, so an
14
+ // afternoon of scanning left a growing list of roots still being served
15
+ // from. A cap with oldest-out eviction bounds it, and evicting a cloned repo
16
+ // deletes the clone with it.
17
+ // * It leaked clones on exit. A clone lives in the OS temp dir until someone
18
+ // removes it, and only an explicit `DELETE /api/scan/:id` did. Ctrl-C left
19
+ // every clone of the session behind — hundreds of megabytes, silently.
20
+
21
+ import crypto from 'node:crypto';
22
+ import path from 'node:path';
23
+ import { realpathSync } from 'node:fs';
24
+
25
+ import { isCloneDir, removeClone, removeCloneSync } from './gitClone.js';
26
+
27
+ // Twelve repos is far more than anyone holds open at once, and each entry is a
28
+ // couple of strings — the cap is about not serving from a root the person has
29
+ // forgotten scanning, not about memory.
30
+ export const MAX_SESSIONS = 12;
31
+
32
+ // scanId -> { root, cloneDir, at, searchIndex? }. Insertion-ordered, which is
33
+ // what makes "oldest first" a `keys().next()` rather than a sort.
34
+ //
35
+ // `searchIndex`, when present, is the TF-IDF index built at scan time
36
+ // (`searchIndex.js`). It is held on the session for the same reason `root`
37
+ // is — `/api/search` is a request that has to be answered against this
38
+ // particular scan — and is dropped when the session is evicted. Reopening
39
+ // the same repo would rebuild it; the cost of one scan's worth of work is
40
+ // far below the cost of re-fetching the repo.
41
+ const sessions = new Map();
42
+
43
+ export function openSession({ root, cloneDir = null, searchIndex = null }) {
44
+ const scanId = crypto.randomBytes(8).toString('hex');
45
+ sessions.set(scanId, { root: canonicalRoot(root), cloneDir, at: Date.now(), searchIndex });
46
+ evictBeyondCap();
47
+ return scanId;
48
+ }
49
+
50
+ // Resolve symlinks once, so the root every consumer sees is the real one. On
51
+ // macOS `/tmp` is a symlink to `/private/tmp`, and an analyzer run against the
52
+ // alias reports paths rooted at the target — without this, `/api/tools/run`
53
+ // findings and `/api/file` lookups would silently miss every file in a repo
54
+ // scanned through a symlinked path. Failing to resolve (a path that is gone)
55
+ // is not fatal: the original is used and the scan's own error path reports it.
56
+ function canonicalRoot(root) {
57
+ if (typeof root !== 'string' || !root) return root;
58
+ try {
59
+ return realpathSync(root);
60
+ } catch {
61
+ return root;
62
+ }
63
+ }
64
+
65
+ // Reading from a scan keeps it alive. Without the touch, opening files in the
66
+ // first repo you scanned is what evicts it — you would lose the one you are
67
+ // actually using and keep eleven you are not.
68
+ export function getSession(scanId) {
69
+ if (!scanId) return null;
70
+ const session = sessions.get(scanId);
71
+ if (!session) return null;
72
+ session.at = Date.now();
73
+ sessions.delete(scanId);
74
+ sessions.set(scanId, session);
75
+ return session;
76
+ }
77
+
78
+ // Clone ids are the 12 hex characters `gitClone` puts after `onboarder-`. The
79
+ // match is on the exact directory name: `endsWith(cloneId)` treated an empty id
80
+ // as a match against every clone, so `DELETE /api/scan/` used to delete
81
+ // whichever clone the map happened to yield first.
82
+ export function isCloneId(id) {
83
+ return /^[0-9a-f]{12}$/.test(id || '');
84
+ }
85
+
86
+ // Removes the clone with this id and forgets every scan pointing at it. Returns
87
+ // whether anything was there — a repeat DELETE is not an error; the page sends
88
+ // one on unload and may well be the second to arrive.
89
+ export async function closeClone(cloneId) {
90
+ const dirName = 'onboarder-' + cloneId;
91
+ let found = null;
92
+ for (const [scanId, session] of sessions) {
93
+ if (session.cloneDir && path.basename(session.cloneDir) === dirName) {
94
+ found = session.cloneDir;
95
+ sessions.delete(scanId);
96
+ }
97
+ }
98
+ if (found) await removeClone(found);
99
+ return Boolean(found);
100
+ }
101
+
102
+ function evictBeyondCap() {
103
+ while (sessions.size > MAX_SESSIONS) {
104
+ const [scanId, session] = sessions.entries().next().value;
105
+ sessions.delete(scanId);
106
+ if (session.cloneDir && !stillReferenced(session.cloneDir)) sweep(session.cloneDir);
107
+ }
108
+ }
109
+
110
+ // Eviction happens inside a scan request, and the person is waiting on the scan,
111
+ // not on a directory being swept — so the removal is started and not awaited. The
112
+ // promises are chained rather than dropped so that a caller who does need to know
113
+ // when the disk is quiet can wait for it; the only one that does is a test.
114
+ let removals = Promise.resolve();
115
+
116
+ function sweep(dir) {
117
+ removals = removals.then(() => removeClone(dir));
118
+ }
119
+
120
+ export function removalsSettled() {
121
+ return removals;
122
+ }
123
+
124
+ // Two sessions can name the same clone — scan a git URL, then scan the temp path
125
+ // it landed in. Do not delete a directory another live session still reads from.
126
+ function stillReferenced(dir) {
127
+ for (const session of sessions.values()) if (session.cloneDir === dir) return true;
128
+ return false;
129
+ }
130
+
131
+ // Synchronous on purpose: this is what an exit handler can finish.
132
+ export function sweepClonesSync() {
133
+ for (const [scanId, session] of sessions) {
134
+ sessions.delete(scanId);
135
+ if (isCloneDir(session.cloneDir)) removeCloneSync(session.cloneDir);
136
+ }
137
+ }
138
+
139
+ // Called from the `node server/index.js` path only, never on import. The tests
140
+ // drive the real router in-process, and a module that added signal handlers just
141
+ // by being imported would take Ctrl-C away from the test runner.
142
+ export function installExitCleanup(proc = process) {
143
+ proc.on('exit', sweepClonesSync);
144
+ // A signal does not run `exit` handlers on its own — the process dies where it
145
+ // stands. Sweeping in the signal handler and then exiting is what gets the
146
+ // clones removed.
147
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
148
+ proc.on(signal, () => {
149
+ sweepClonesSync();
150
+ proc.exit(0);
151
+ });
152
+ }
153
+ }
154
+
155
+ // Tests only. The store is module state, so a test that opens sessions would
156
+ // otherwise leak them into the next one.
157
+ export function _resetSessions() {
158
+ sessions.clear();
159
+ }
160
+
161
+ export function _sessionCount() {
162
+ return sessions.size;
163
+ }
@@ -0,0 +1,59 @@
1
+ // Serving the app itself: `public/` is the site root, and `/shared/` is mapped to
2
+ // the repo's `shared/` directory so the analyzer the server imports and the
3
+ // analyzer the browser imports are the same files. That mapping is the reason
4
+ // nothing under `shared/` may touch `fs` or `window` — both runtimes load it.
5
+ //
6
+ // It also means no relative specifier can point from `public/js/` into `shared/`
7
+ // and work in both places, which is why browser modules reach for `/shared/...`
8
+ // and pure logic that Node needs to test lives in `shared/` rather than beside
9
+ // its caller.
10
+
11
+ import { promises as fs } from 'node:fs';
12
+ import path from 'node:path';
13
+
14
+ import { sendError } from './http.js';
15
+ import { resolveInside } from './paths.js';
16
+
17
+ const MIME = {
18
+ '.html': 'text/html; charset=utf-8',
19
+ '.js': 'text/javascript; charset=utf-8',
20
+ '.mjs': 'text/javascript; charset=utf-8',
21
+ '.css': 'text/css; charset=utf-8',
22
+ '.json': 'application/json; charset=utf-8',
23
+ '.svg': 'image/svg+xml',
24
+ '.png': 'image/png',
25
+ '.ico': 'image/x-icon',
26
+ '.map': 'application/json',
27
+ '.woff2': 'font/woff2',
28
+ };
29
+
30
+ // `null` for anything that resolves outside the directory it was mapped into —
31
+ // `resolveInside` is the traversal guard, and a request for `/../../etc/passwd`
32
+ // has to fail here rather than reach `readFile`.
33
+ export function resolveAsset(urlPath, { publicDir, sharedDir }) {
34
+ if (urlPath === '/' || urlPath === '/index.html') return path.join(publicDir, 'index.html');
35
+ if (urlPath.startsWith('/shared/')) return resolveInside(sharedDir, urlPath.slice('/shared/'.length));
36
+ return resolveInside(publicDir, urlPath);
37
+ }
38
+
39
+ // Everything we author is served no-cache: the UI and the engine ship as loose
40
+ // modules with no build step and no content hashes, so a stale app.js against a
41
+ // fresh index.html breaks the whole page. Vendored builds are the exception —
42
+ // they are versioned by the directory they sit in and never edited in place.
43
+ export function cacheControlFor(file) {
44
+ return file.includes(path.sep + 'vendor' + path.sep) ? 'public, max-age=86400' : 'no-cache';
45
+ }
46
+
47
+ export async function serveStatic(res, urlPath, dirs) {
48
+ const file = resolveAsset(urlPath, dirs);
49
+ if (!file) return sendError(res, 403, 'Not from here.');
50
+
51
+ const data = await fs.readFile(file).catch(() => null);
52
+ if (data === null) return sendError(res, 404, 'Not found: ' + urlPath);
53
+
54
+ res.writeHead(200, {
55
+ 'content-type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream',
56
+ 'cache-control': cacheControlFor(file),
57
+ });
58
+ res.end(data);
59
+ }