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.
- package/LICENSE +21 -0
- package/README.md +275 -0
- package/bin/onboarder.js +14 -0
- package/bin/postinstall.js +6 -0
- package/cli/commands.js +436 -0
- package/cli/main.js +129 -0
- package/cli/prompt.js +101 -0
- package/cli/ui.js +55 -0
- package/cli/wizard.js +325 -0
- package/package.json +48 -0
- package/public/app.js +1339 -0
- package/public/index.html +455 -0
- package/public/js/about.js +284 -0
- package/public/js/aiDraft.js +163 -0
- package/public/js/analysisPanel.js +270 -0
- package/public/js/analysisReport.js +154 -0
- package/public/js/api.js +264 -0
- package/public/js/atlas.js +98 -0
- package/public/js/blameView.js +41 -0
- package/public/js/codeTab.js +256 -0
- package/public/js/codeViewer.js +184 -0
- package/public/js/components/FileChip.js +102 -0
- package/public/js/components/MetricSparkline.js +89 -0
- package/public/js/components/RiskBadge.js +105 -0
- package/public/js/deepAnalysisView.js +496 -0
- package/public/js/diagramPane.js +154 -0
- package/public/js/diffView.js +352 -0
- package/public/js/docsView.js +566 -0
- package/public/js/fileSourceBrowser.js +47 -0
- package/public/js/flameGraph.js +75 -0
- package/public/js/forceGraph.js +592 -0
- package/public/js/heatmap.js +185 -0
- package/public/js/highlight.js +122 -0
- package/public/js/html.js +50 -0
- package/public/js/insightsView.js +236 -0
- package/public/js/inspector.js +535 -0
- package/public/js/llm.js +345 -0
- package/public/js/mapView.js +159 -0
- package/public/js/markdown.js +119 -0
- package/public/js/mindmap.js +289 -0
- package/public/js/repoFiles.js +44 -0
- package/public/js/sbomView.js +142 -0
- package/public/js/scanCache.js +76 -0
- package/public/js/search.js +874 -0
- package/public/js/serverSettings.js +169 -0
- package/public/js/state.js +192 -0
- package/public/js/tour.js +17 -0
- package/public/js/transitions.js +15 -0
- package/public/js/tree.js +200 -0
- package/public/js/workflowsView.js +95 -0
- package/public/styles.css +2767 -0
- package/public/vendor/mermaid.min.js +3587 -0
- package/public/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
- package/public/vendor/monaco/vs/base/worker/workerMain.js +31 -0
- package/public/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
- package/public/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/css/css.js +12 -0
- package/public/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
- package/public/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/go/go.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/html/html.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/java/java.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/less/less.js +11 -0
- package/public/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/php/php.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
- package/public/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/python/python.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/r/r.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
- package/public/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/st/st.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
- package/public/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
- package/public/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
- package/public/vendor/monaco/vs/editor/editor.main.css +8 -0
- package/public/vendor/monaco/vs/editor/editor.main.js +798 -0
- package/public/vendor/monaco/vs/language/css/cssMode.js +13 -0
- package/public/vendor/monaco/vs/language/css/cssWorker.js +77 -0
- package/public/vendor/monaco/vs/language/html/htmlMode.js +13 -0
- package/public/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
- package/public/vendor/monaco/vs/language/json/jsonMode.js +19 -0
- package/public/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
- package/public/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
- package/public/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
- package/public/vendor/monaco/vs/loader.js +11 -0
- package/public/vendor/monaco/worker-boot.js +9 -0
- package/server/.fuse_hidden0000000800000001 +36 -0
- package/server/apiDiff.js +25 -0
- package/server/apiDocs.js +67 -0
- package/server/apiFile.js +36 -0
- package/server/apiGitBlame.js +63 -0
- package/server/apiMcp.js +110 -0
- package/server/apiScan.js +119 -0
- package/server/apiSearch.js +253 -0
- package/server/apiSettings.js +118 -0
- package/server/apiTools.js +90 -0
- package/server/config.js +227 -0
- package/server/fileSourceNode.js +44 -0
- package/server/gitClone.js +95 -0
- package/server/gitDiff.js +184 -0
- package/server/gitHistory.js +110 -0
- package/server/htmlText.js +44 -0
- package/server/http.js +55 -0
- package/server/httpGuards.js +87 -0
- package/server/index.js +156 -0
- package/server/llmProxy.js +162 -0
- package/server/logger.js +29 -0
- package/server/mcp/analysis.js +209 -0
- package/server/mcp/http.js +213 -0
- package/server/mcp/runner.js +278 -0
- package/server/mcp/server.js +241 -0
- package/server/mcp/standalone.js +42 -0
- package/server/mcp/tools.js +683 -0
- package/server/paths.js +39 -0
- package/server/router.js +218 -0
- package/server/searchIndex.js +118 -0
- package/server/sessions.js +163 -0
- package/server/static.js +59 -0
- package/server/tools/install.js +246 -0
- package/server/tools/parse.js +170 -0
- package/server/tools/platform.js +91 -0
- package/server/tools/registry.js +212 -0
- package/server/tools/scan.js +136 -0
- package/server/tools.js +212 -0
- package/server/tunnel.js +92 -0
- package/shared/analyzer/docs.js +135 -0
- package/shared/analyzer/explainLocal.js +163 -0
- package/shared/analyzer/graph.js +461 -0
- package/shared/analyzer/health.js +215 -0
- package/shared/analyzer/history.js +146 -0
- package/shared/analyzer/languages/csharp.js +39 -0
- package/shared/analyzer/languages/generic.js +131 -0
- package/shared/analyzer/languages/go.js +70 -0
- package/shared/analyzer/languages/index.js +43 -0
- package/shared/analyzer/languages/java.js +39 -0
- package/shared/analyzer/languages/javascript.js +240 -0
- package/shared/analyzer/languages/python.js +120 -0
- package/shared/analyzer/languages/rust.js +42 -0
- package/shared/analyzer/languages/typescript.js +138 -0
- package/shared/analyzer/licenses.js +151 -0
- package/shared/analyzer/metrics.js +120 -0
- package/shared/analyzer/pathUtil.js +69 -0
- package/shared/analyzer/patterns.js +272 -0
- package/shared/analyzer/scan.js +473 -0
- package/shared/analyzer/security.js +187 -0
- package/shared/analyzer/services.js +157 -0
- package/shared/analyzer/stack.js +173 -0
- package/shared/analyzer/tour.js +69 -0
- package/shared/analyzer/util.js +123 -0
- package/shared/analyzer/workflows.js +143 -0
- package/shared/diagram/aiFacts.js +145 -0
- package/shared/diagram/aiMermaid.js +59 -0
- package/shared/diagram/atlas.js +93 -0
- package/shared/diagram/mermaid.js +459 -0
- package/shared/search/query.js +518 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// POST /api/search — runs a TF-IDF query against the index built at scan
|
|
2
|
+
// time. The work that used to live here (re-listing the tree, re-reading
|
|
3
|
+
// every file, parsing its tokens) now lives in `searchIndex.js`, which
|
|
4
|
+
// `apiScan.js` invokes once when the scan finishes. What is left in this
|
|
5
|
+
// module is the request shape: validate, look up the session, score, send.
|
|
6
|
+
//
|
|
7
|
+
// The pure scoring functions (`buildTfIdfIndex` for one-off in-memory
|
|
8
|
+
// indexes used by tests, and `searchIndex` for the score-and-snippet pass)
|
|
9
|
+
// are still exported so the front end and tests can construct indexes
|
|
10
|
+
// directly when they need to.
|
|
11
|
+
//
|
|
12
|
+
// There are two ways through, and which one a query takes is the point:
|
|
13
|
+
// `searchIndex` answers a query of bare words the forgiving way it always
|
|
14
|
+
// did — partial matches, ranked by TF-IDF — while `searchDocuments` parses
|
|
15
|
+
// the shared query language (`shared/search/query.js`, the same module the
|
|
16
|
+
// palette uses) and enforces anything the person actually decided: filters,
|
|
17
|
+
// phrases, regexes, the case toggle. A half-remembered name should still find
|
|
18
|
+
// the file; `ext:js -vendor` should not be negotiated with.
|
|
19
|
+
|
|
20
|
+
import { getSession } from './sessions.js';
|
|
21
|
+
import { sendError, sendJSON } from './http.js';
|
|
22
|
+
import {
|
|
23
|
+
isAdvanced, lineOfIndex, matchesPathFilters, parseQuery, queryIsEmpty,
|
|
24
|
+
scoreContent, scorePath, snippetAt,
|
|
25
|
+
} from '../shared/search/query.js';
|
|
26
|
+
|
|
27
|
+
export function buildTfIdfIndex(documents) {
|
|
28
|
+
// documents: [{ path, content }]
|
|
29
|
+
const index = new Map();
|
|
30
|
+
const docCounts = new Map();
|
|
31
|
+
let totalDocs = 0;
|
|
32
|
+
|
|
33
|
+
for (const doc of documents) {
|
|
34
|
+
if (!doc.content || typeof doc.content !== 'string') continue;
|
|
35
|
+
const tokens = doc.content.toLowerCase().split(/\W+/).filter((t) => t.length > 1);
|
|
36
|
+
if (!tokens.length) continue;
|
|
37
|
+
totalDocs++;
|
|
38
|
+
|
|
39
|
+
const tf = new Map();
|
|
40
|
+
for (const t of tokens) {
|
|
41
|
+
tf.set(t, (tf.get(t) || 0) + 1);
|
|
42
|
+
}
|
|
43
|
+
for (const t of tf.keys()) {
|
|
44
|
+
docCounts.set(t, (docCounts.get(t) || 0) + 1);
|
|
45
|
+
}
|
|
46
|
+
index.set(doc.path, { tf, content: doc.content, tokenCount: tokens.length });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { index, docCounts, totalDocs };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function searchIndex(indexData, queryString, limit = 10) {
|
|
53
|
+
if (!indexData || !queryString) return [];
|
|
54
|
+
const terms = queryString.toLowerCase().split(/\s+/).filter((t) => t.length > 0);
|
|
55
|
+
if (!terms.length) return [];
|
|
56
|
+
|
|
57
|
+
const { index, docCounts, totalDocs } = indexData;
|
|
58
|
+
if (!totalDocs) return [];
|
|
59
|
+
|
|
60
|
+
const idf = new Map();
|
|
61
|
+
for (const t of terms) {
|
|
62
|
+
const df = docCounts.get(t) || 0;
|
|
63
|
+
idf.set(t, df === 0 ? 0 : Math.log(1 + totalDocs / df));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const results = [];
|
|
67
|
+
for (const [docPath, data] of index.entries()) {
|
|
68
|
+
let score = 0;
|
|
69
|
+
let matchCount = 0;
|
|
70
|
+
for (const t of terms) {
|
|
71
|
+
const tf = data.tf.get(t) || 0;
|
|
72
|
+
if (tf > 0) {
|
|
73
|
+
matchCount++;
|
|
74
|
+
// Normalized TF * IDF
|
|
75
|
+
score += (tf / Math.sqrt(data.tokenCount)) * (idf.get(t) || 1);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (score > 0) {
|
|
80
|
+
// Find best snippet
|
|
81
|
+
const lowerContent = data.content.toLowerCase();
|
|
82
|
+
let bestIdx = -1;
|
|
83
|
+
for (const t of terms) {
|
|
84
|
+
const idx = lowerContent.indexOf(t);
|
|
85
|
+
if (idx !== -1 && (bestIdx === -1 || idx < bestIdx)) {
|
|
86
|
+
bestIdx = idx;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let snippet = '';
|
|
91
|
+
let line = 1;
|
|
92
|
+
if (bestIdx !== -1) {
|
|
93
|
+
const linesBefore = data.content.slice(0, bestIdx).split('\n');
|
|
94
|
+
line = linesBefore.length;
|
|
95
|
+
const allLines = data.content.split('\n');
|
|
96
|
+
const targetLine = (allLines[line - 1] || '').trim();
|
|
97
|
+
snippet = targetLine.length > 120 ? targetLine.slice(0, 117) + '…' : targetLine;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
results.push({
|
|
101
|
+
path: docPath,
|
|
102
|
+
score: score * (matchCount / terms.length), // Boost docs containing all terms
|
|
103
|
+
snippet,
|
|
104
|
+
line,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
results.sort((a, b) => b.score - a.score);
|
|
110
|
+
return results.slice(0, limit);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The strict pass: filters are gates, not hints, and every term has to land.
|
|
114
|
+
// Ranking is positional — `scorePath` and `scoreContent` reward the file whose
|
|
115
|
+
// name matches and the file where the word appears early — because a query with
|
|
116
|
+
// a regex or a filter in it is a person who knows what they want, and does not
|
|
117
|
+
// need the corpus statistics of `searchIndex` second-guessing them.
|
|
118
|
+
function strictSearch(indexData, parsed) {
|
|
119
|
+
// `kind:` is a statement about symbols, and content cannot answer it. A query
|
|
120
|
+
// that is *only* a kind filter is answered by the palette's symbol pass and
|
|
121
|
+
// by nothing here, which is the honest result rather than "every file".
|
|
122
|
+
const kindOnly = parsed.kinds.length > 0
|
|
123
|
+
&& !parsed.terms.length && !parsed.phrases.length && !parsed.regex;
|
|
124
|
+
|
|
125
|
+
const results = [];
|
|
126
|
+
for (const [docPath, doc] of indexData.index.entries()) {
|
|
127
|
+
if (kindOnly) break;
|
|
128
|
+
if (!matchesPathFilters(parsed, docPath)) continue;
|
|
129
|
+
|
|
130
|
+
const pathScore = scorePath(parsed, docPath);
|
|
131
|
+
const content = scoreContent(parsed, doc.content);
|
|
132
|
+
if (!pathScore && !content.score) continue;
|
|
133
|
+
|
|
134
|
+
const at = content.index;
|
|
135
|
+
results.push({
|
|
136
|
+
path: docPath,
|
|
137
|
+
score: pathScore + content.score,
|
|
138
|
+
line: at >= 0 ? lineOfIndex(doc.content, at) : 1,
|
|
139
|
+
snippet: snippetAt(doc.content, at >= 0 ? at : 0),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
results.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
|
|
144
|
+
return results;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// What the index could not read, in the shape the palette can say out loud —
|
|
148
|
+
// "searched 127 files, 3 skipped" beats a mystery file that never turns up.
|
|
149
|
+
function indexStats(indexData) {
|
|
150
|
+
const skipped = indexData && indexData.skipped;
|
|
151
|
+
if (!skipped) return null;
|
|
152
|
+
return { bytes: indexData.totalBytes ?? 0, skipped: { ...skipped }, cap: indexData.cap ?? null };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// The serializable half of a parsed query. `parsed.regex` is a RegExp and would
|
|
156
|
+
// go over the wire as `{}`, so the pattern travels as the text it was typed as.
|
|
157
|
+
function querySummary(parsed) {
|
|
158
|
+
return {
|
|
159
|
+
terms: parsed.terms,
|
|
160
|
+
phrases: parsed.phrases,
|
|
161
|
+
negatives: parsed.negatives,
|
|
162
|
+
exts: parsed.exts,
|
|
163
|
+
paths: parsed.paths,
|
|
164
|
+
kinds: parsed.kinds,
|
|
165
|
+
isTest: parsed.isTest,
|
|
166
|
+
isSource: parsed.isSource,
|
|
167
|
+
regex: parsed.regexSource ? `/${parsed.regexSource}/${parsed.regexFlags}` : null,
|
|
168
|
+
caseSensitive: parsed.caseSensitive,
|
|
169
|
+
advanced: isAdvanced(parsed),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// The one entry point the route uses, kept separate from `searchIndex` so the
|
|
174
|
+
// forgiving ranking that shipped first survives intact: a query of bare words
|
|
175
|
+
// is still answered by that function, partial matches and all. Only a query the
|
|
176
|
+
// person made decisions in — a filter, a phrase, a regex, the case toggle —
|
|
177
|
+
// goes down the strict path, where those decisions are enforced.
|
|
178
|
+
export function searchDocuments(indexData, rawQuery, options = {}) {
|
|
179
|
+
const limit = Number.isFinite(options.limit) ? Math.max(1, Math.floor(options.limit)) : 15;
|
|
180
|
+
const parsed = parseQuery(rawQuery, { caseSensitive: !!options.caseSensitive });
|
|
181
|
+
const advanced = isAdvanced(parsed);
|
|
182
|
+
const base = {
|
|
183
|
+
results: [],
|
|
184
|
+
total: 0,
|
|
185
|
+
indexed: indexData?.totalDocs ?? 0,
|
|
186
|
+
capped: false,
|
|
187
|
+
advanced,
|
|
188
|
+
query: querySummary(parsed),
|
|
189
|
+
error: parsed.error,
|
|
190
|
+
stats: indexStats(indexData),
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
if (!indexData || !indexData.totalDocs) return base;
|
|
194
|
+
if (queryIsEmpty(parsed)) return base;
|
|
195
|
+
// A pattern that would not compile is the whole answer. Reporting "no
|
|
196
|
+
// results" instead would send someone hunting for a file sitting right there.
|
|
197
|
+
if (parsed.error) return base;
|
|
198
|
+
|
|
199
|
+
if (!advanced) {
|
|
200
|
+
const ranked = searchIndex(indexData, parsed.terms.join(' '), limit);
|
|
201
|
+
return { ...base, results: ranked, total: ranked.length, capped: ranked.length >= limit };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const all = strictSearch(indexData, parsed);
|
|
205
|
+
return {
|
|
206
|
+
...base,
|
|
207
|
+
results: all.slice(0, limit),
|
|
208
|
+
total: all.length,
|
|
209
|
+
capped: all.length > limit,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function handleSearch(res, body) {
|
|
214
|
+
const { scanId, query, limit = 15, caseSensitive = false } = body || {};
|
|
215
|
+
if (!scanId) return sendError(res, 400, 'Missing scanId.');
|
|
216
|
+
const session = getSession(scanId);
|
|
217
|
+
if (!session) return sendError(res, 404, 'Scan not found.');
|
|
218
|
+
|
|
219
|
+
const q = (query || '').trim();
|
|
220
|
+
if (!q) {
|
|
221
|
+
return sendJSON(res, 200, { results: [], total: 0, indexed: session.searchIndex?.totalDocs ?? 0 });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// No index means the scan was opened before this change shipped, or the
|
|
225
|
+
// caller is using a session that was never given a scan-time index. The
|
|
226
|
+
// honest answer is "no results" rather than rebuilding the whole thing on
|
|
227
|
+
// the request thread.
|
|
228
|
+
if (!session.searchIndex || session.searchIndex.totalDocs === 0) {
|
|
229
|
+
return sendJSON(res, 200, {
|
|
230
|
+
results: [],
|
|
231
|
+
total: 0,
|
|
232
|
+
indexed: 0,
|
|
233
|
+
note: 'Nothing was indexed for this scan.',
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
const out = searchDocuments(session.searchIndex, q, { limit, caseSensitive });
|
|
239
|
+
return sendJSON(res, 200, {
|
|
240
|
+
results: out.results,
|
|
241
|
+
total: out.total,
|
|
242
|
+
indexed: out.indexed,
|
|
243
|
+
capped: out.capped,
|
|
244
|
+
advanced: out.advanced,
|
|
245
|
+
query: out.query,
|
|
246
|
+
error: out.error || null,
|
|
247
|
+
stats: out.stats || null,
|
|
248
|
+
});
|
|
249
|
+
} catch (err) {
|
|
250
|
+
return sendError(res, 500, 'Search failed: ' + err.message);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// The settings endpoints: read the public shape, apply a patch, rotate the key.
|
|
2
|
+
//
|
|
3
|
+
// All three go through `server/config.js` for validation and the atomic 0600
|
|
4
|
+
// write, so the CLI wizard, `onboarder config set`, and this API can never save
|
|
5
|
+
// three dialects of the same file. What lives here is only what HTTP adds:
|
|
6
|
+
// status codes, the secret-handling rules (never read the key back out, show a
|
|
7
|
+
// new one exactly once), and the restart hint — the running server bound its
|
|
8
|
+
// socket at boot, so a host/port change is real but not yet in effect.
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_SETTINGS, generateAccessKey, normalizeSettings, publicSettings,
|
|
12
|
+
readSettings, updateSettings, writeSettings, configPath,
|
|
13
|
+
} from './config.js';
|
|
14
|
+
import { sendError, sendJSON } from './http.js';
|
|
15
|
+
import { tunnelStatus } from './tunnel.js';
|
|
16
|
+
|
|
17
|
+
// Where this request's settings live. The router's config object carries the
|
|
18
|
+
// path when the server was booted with one; tests and a bare `createServer()`
|
|
19
|
+
// get the defaults-in-memory view instead of somebody's home directory.
|
|
20
|
+
function settingsFile(config) {
|
|
21
|
+
return config?.configPath || null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function currentSettings(config) {
|
|
25
|
+
if (typeof config?.getSettings === 'function') return config.getSettings();
|
|
26
|
+
const file = settingsFile(config);
|
|
27
|
+
if (file) return readSettings(file);
|
|
28
|
+
return { ...DEFAULT_SETTINGS, account: { ...DEFAULT_SETTINGS.account }, tunnel: { ...DEFAULT_SETTINGS.tunnel } };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The public body every read returns: settings with the secret masked, plus
|
|
32
|
+
// the connection picture (URLs, tunnel status) the drawer and CLI both render.
|
|
33
|
+
async function publicBody(config) {
|
|
34
|
+
const settings = await currentSettings(config);
|
|
35
|
+
return {
|
|
36
|
+
settings: publicSettings(settings),
|
|
37
|
+
configFile: settingsFile(config) || configPath(),
|
|
38
|
+
node: process.version,
|
|
39
|
+
tunnels: tunnelStatus(settings),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function handleGetSettings(req, res, config) {
|
|
44
|
+
try {
|
|
45
|
+
sendJSON(res, 200, await publicBody(config));
|
|
46
|
+
} catch (err) {
|
|
47
|
+
sendError(res, 500, err.message || 'The settings could not be read.');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Which changes the running server cannot absorb. Auth, domain, and tunnel
|
|
52
|
+
// flags are read live from disk on each request; the bind is a socket that
|
|
53
|
+
// already exists.
|
|
54
|
+
function restartRequired(config, next) {
|
|
55
|
+
const boot = config?.boot;
|
|
56
|
+
if (!boot) return [];
|
|
57
|
+
const changed = [];
|
|
58
|
+
if (next.host !== boot.host) changed.push('host');
|
|
59
|
+
if (next.port !== boot.port) changed.push('port');
|
|
60
|
+
return changed;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function handleUpdateSettings(res, body, config) {
|
|
64
|
+
const file = settingsFile(config);
|
|
65
|
+
if (!file) {
|
|
66
|
+
return sendError(res, 400, 'This server was started without a config file, so there is nothing to save to.');
|
|
67
|
+
}
|
|
68
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
69
|
+
return sendError(res, 400, 'Send a JSON object of settings to change.');
|
|
70
|
+
}
|
|
71
|
+
// Only known keys may be patched — a typo like "ports" must be a loud 400,
|
|
72
|
+
// not a silently ignored write that the UI then claims it saved.
|
|
73
|
+
const allowed = new Set(['mode', 'host', 'port', 'domain', 'autoOpen', 'account', 'tunnel']);
|
|
74
|
+
const unknown = Object.keys(body).filter((k) => k !== 'accessKey' && !allowed.has(k));
|
|
75
|
+
if (unknown.length) {
|
|
76
|
+
return sendError(res, 400, `Unknown setting${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}.`);
|
|
77
|
+
}
|
|
78
|
+
if ('accessKey' in body) {
|
|
79
|
+
return sendError(res, 400, 'The access key is rotated, not patched — POST /api/settings/access-key.');
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const next = await updateSettings(body, file);
|
|
83
|
+
sendJSON(res, 200, {
|
|
84
|
+
settings: publicSettings(next),
|
|
85
|
+
restartRequired: restartRequired(config, next),
|
|
86
|
+
});
|
|
87
|
+
} catch (err) {
|
|
88
|
+
sendError(res, 400, err.message || 'Those settings did not validate.');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Rotation returns the new key in the clear, once. It is never in a GET after
|
|
93
|
+
// that — `hasAccessKey`/`accessKeyMasked` are all the readback there is. The
|
|
94
|
+
// person copies it from this response into the devices that need it.
|
|
95
|
+
export async function handleRotateAccessKey(res, config) {
|
|
96
|
+
const file = settingsFile(config);
|
|
97
|
+
if (!file) {
|
|
98
|
+
return sendError(res, 400, 'This server was started without a config file, so there is nothing to save to.');
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const accessKey = generateAccessKey();
|
|
102
|
+
const current = await currentSettings(config);
|
|
103
|
+
const next = await writeSettings({ ...current, accessKey }, file);
|
|
104
|
+
sendJSON(res, 200, {
|
|
105
|
+
accessKey,
|
|
106
|
+
settings: publicSettings(next),
|
|
107
|
+
note: 'Shown once. Update every device that connects remotely; the old key is dead.',
|
|
108
|
+
});
|
|
109
|
+
} catch (err) {
|
|
110
|
+
sendError(res, 400, err.message || 'The key could not be rotated.');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Kept exported for the tests: a patch that survives normalizeSettings is the
|
|
115
|
+
// same patch the wizard and the API would both accept.
|
|
116
|
+
export function validatePatch(patch) {
|
|
117
|
+
return normalizeSettings({ ...DEFAULT_SETTINGS, ...patch });
|
|
118
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// GET /api/tools — which analyzers this machine can run, for the engines panel.
|
|
2
|
+
// POST /api/tools/run — run the available analyzers against a live scan and
|
|
3
|
+
// return the normalized findings.
|
|
4
|
+
//
|
|
5
|
+
// The scan id is a capability the same way it is for `/api/file`: it names a
|
|
6
|
+
// root the person chose, and the analyzers are run inside that root and nowhere
|
|
7
|
+
// else. `tools.js` spawns with argument arrays and hard timeouts, so a crafted
|
|
8
|
+
// path becomes an argument and a hung tool becomes a failed pass, not a stuck
|
|
9
|
+
// server.
|
|
10
|
+
|
|
11
|
+
import { sendError, sendJSON } from './http.js';
|
|
12
|
+
import { getSession } from './sessions.js';
|
|
13
|
+
import { runExternalAnalysis, toolsStatus } from './tools/scan.js';
|
|
14
|
+
import { installTool, isInstalling, plansFor } from './tools/install.js';
|
|
15
|
+
import { TOOL_DEFS } from './tools/registry.js';
|
|
16
|
+
|
|
17
|
+
const KNOWN_TOOLS = new Set(TOOL_DEFS.map((d) => d.id));
|
|
18
|
+
|
|
19
|
+
export function handleToolsStatus(res) {
|
|
20
|
+
sendJSON(res, 200, { tools: toolsStatus() });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function handleToolsRun(res, body) {
|
|
24
|
+
const { scanId, kinds, tools, options } = body || {};
|
|
25
|
+
if (!scanId) return sendError(res, 400, 'Missing scanId.');
|
|
26
|
+
const session = getSession(scanId);
|
|
27
|
+
if (!session) return sendError(res, 404, 'That scan is gone. Rescan the repo.');
|
|
28
|
+
|
|
29
|
+
// `kinds` narrows by purpose, `tools` to named engines. An unknown value is a
|
|
30
|
+
// typo the caller would rather hear about than have silently ignored.
|
|
31
|
+
let kindFilter = null;
|
|
32
|
+
if (Array.isArray(kinds) && kinds.length) {
|
|
33
|
+
const ok = kinds.filter((k) => k === 'security' || k === 'dead-code');
|
|
34
|
+
if (!ok.length) return sendError(res, 400, 'kinds must be "security" and/or "dead-code".');
|
|
35
|
+
kindFilter = ok;
|
|
36
|
+
}
|
|
37
|
+
let toolFilter = null;
|
|
38
|
+
if (Array.isArray(tools) && tools.length) {
|
|
39
|
+
toolFilter = tools.map((t) => String(t));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// `options` is the per-engine GUI form. Validation happens against the
|
|
43
|
+
// registry schema inside the run — the handler just passes it along.
|
|
44
|
+
const report = await runExternalAnalysis(session.root, {
|
|
45
|
+
kinds: kindFilter, tools: toolFilter, options,
|
|
46
|
+
});
|
|
47
|
+
return sendJSON(res, 200, report);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// POST /api/tools/install — install a missing engine, streaming progress.
|
|
51
|
+
//
|
|
52
|
+
// This is the one endpoint that changes the machine, so it is worth spelling
|
|
53
|
+
// out why that is safe enough for a local, self-hosted app:
|
|
54
|
+
//
|
|
55
|
+
// * The route is a POST, which the router's same-origin gate already
|
|
56
|
+
// refuses for any page that is not Onboarder's own — a foreign tab cannot
|
|
57
|
+
// drive it.
|
|
58
|
+
// * `tool` must name a registry entry; the install plans are fixed data in
|
|
59
|
+
// `tools/install.js`, spawned as argument arrays. Nothing from the
|
|
60
|
+
// request body ever becomes part of a command line.
|
|
61
|
+
// * The stream is SSE in the same `data:`-framed shape the explain proxy
|
|
62
|
+
// uses, so the front end reads install progress exactly like AI output.
|
|
63
|
+
export async function handleToolsInstall(res, body) {
|
|
64
|
+
const tool = String(body?.tool || '');
|
|
65
|
+
if (!KNOWN_TOOLS.has(tool)) {
|
|
66
|
+
return sendError(res, 400, `Unknown tool "${tool}". Known: ${[...KNOWN_TOOLS].join(', ')}.`);
|
|
67
|
+
}
|
|
68
|
+
if (!plansFor(tool).length) {
|
|
69
|
+
return sendError(res, 400, `Onboarder has no install plan for ${tool} on ${process.platform}.`);
|
|
70
|
+
}
|
|
71
|
+
if (isInstalling(tool)) {
|
|
72
|
+
return sendError(res, 409, `${tool} is already being installed.`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
res.writeHead(200, {
|
|
76
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
77
|
+
'cache-control': 'no-cache',
|
|
78
|
+
connection: 'keep-alive',
|
|
79
|
+
});
|
|
80
|
+
const send = (event) => {
|
|
81
|
+
try { res.write('data: ' + JSON.stringify(event) + '\n\n'); } catch { /* client went away */ }
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
await installTool(tool, send);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
send({ type: 'done', ok: false, error: err.message || 'The install failed.' });
|
|
88
|
+
}
|
|
89
|
+
res.end();
|
|
90
|
+
}
|
package/server/config.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// Durable, user-scoped settings for the CLI/server. This file deliberately does
|
|
2
|
+
// not know about HTTP: the CLI and the settings API use the same validation and
|
|
3
|
+
// write path so a browser can change a setting without inventing a second schema.
|
|
4
|
+
|
|
5
|
+
import { promises as fs } from 'node:fs';
|
|
6
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
export const CONFIG_VERSION = 1;
|
|
11
|
+
export const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
|
|
12
|
+
export const DEFAULT_SETTINGS = Object.freeze({
|
|
13
|
+
version: CONFIG_VERSION,
|
|
14
|
+
mode: 'local',
|
|
15
|
+
host: '127.0.0.1',
|
|
16
|
+
port: 4310,
|
|
17
|
+
domain: '',
|
|
18
|
+
accessKey: '',
|
|
19
|
+
// Whether `onboarder start` opens the app in a browser once it is listening.
|
|
20
|
+
// Off in the schema so `npm start` from a checkout stays quiet; the setup
|
|
21
|
+
// wizard offers to turn it on, which is where the people who want it are.
|
|
22
|
+
autoOpen: false,
|
|
23
|
+
account: Object.freeze({ name: '', email: '', provider: 'openai-compatible', baseUrl: '', model: '' }),
|
|
24
|
+
tunnel: Object.freeze({ cloudflare: false, tailscale: false }),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export function configHome(env = process.env) {
|
|
28
|
+
if (env.XDG_CONFIG_HOME) return path.resolve(env.XDG_CONFIG_HOME, 'onboarder');
|
|
29
|
+
return path.join(os.homedir(), '.config', 'onboarder');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function configPath(env = process.env) {
|
|
33
|
+
// An explicit override first: the CLI's --config flag and the test suite both
|
|
34
|
+
// need a config file that is not the one in the person's home directory.
|
|
35
|
+
if (env.ONBOARDER_CONFIG) return path.resolve(env.ONBOARDER_CONFIG);
|
|
36
|
+
return path.join(configHome(env), 'config.json');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isLoopbackHost(host) {
|
|
40
|
+
return LOOPBACK_HOSTS.has(String(host || '').trim().toLowerCase());
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function cleanString(value, max = 240) {
|
|
44
|
+
return String(value ?? '').trim().slice(0, max);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizePort(value) {
|
|
48
|
+
const port = Number(value);
|
|
49
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Port must be a whole number from 1 to 65535.');
|
|
50
|
+
return port;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeDomain(value) {
|
|
54
|
+
const domain = cleanString(value).toLowerCase();
|
|
55
|
+
if (!domain) return '';
|
|
56
|
+
if (domain.length > 253 || domain.includes('/') || domain.includes('\\') || domain.includes(' ') || domain.includes('@') || domain.startsWith('.')) {
|
|
57
|
+
throw new Error('Domain must be a hostname such as map.example.com.');
|
|
58
|
+
}
|
|
59
|
+
return domain;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeAccount(value = {}) {
|
|
63
|
+
return {
|
|
64
|
+
name: cleanString(value.name, 80),
|
|
65
|
+
email: cleanString(value.email, 160),
|
|
66
|
+
provider: cleanString(value.provider || 'openai-compatible', 80),
|
|
67
|
+
baseUrl: cleanString(value.baseUrl, 500),
|
|
68
|
+
model: cleanString(value.model, 160),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizeTunnel(value = {}) {
|
|
73
|
+
return { cloudflare: Boolean(value.cloudflare), tailscale: Boolean(value.tailscale) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function normalizeSettings(value = {}) {
|
|
77
|
+
const mode = value.mode === 'self-hosted' ? 'self-hosted' : 'local';
|
|
78
|
+
const host = cleanString(value.host || DEFAULT_SETTINGS.host, 120);
|
|
79
|
+
const settings = {
|
|
80
|
+
version: CONFIG_VERSION,
|
|
81
|
+
mode,
|
|
82
|
+
host,
|
|
83
|
+
port: normalizePort(value.port ?? DEFAULT_SETTINGS.port),
|
|
84
|
+
domain: normalizeDomain(value.domain),
|
|
85
|
+
accessKey: cleanString(value.accessKey, 256),
|
|
86
|
+
autoOpen: value.autoOpen === undefined ? DEFAULT_SETTINGS.autoOpen : Boolean(value.autoOpen),
|
|
87
|
+
account: normalizeAccount(value.account),
|
|
88
|
+
tunnel: normalizeTunnel(value.tunnel),
|
|
89
|
+
};
|
|
90
|
+
if (!host) throw new Error('Host cannot be empty.');
|
|
91
|
+
if (settings.mode === 'local' && !isLoopbackHost(settings.host)) {
|
|
92
|
+
throw new Error('Local mode only binds to 127.0.0.1, localhost, or ::1. Choose self-hosted mode for a network bind.');
|
|
93
|
+
}
|
|
94
|
+
if (settings.mode === 'self-hosted' && !isLoopbackHost(settings.host) && !settings.domain) {
|
|
95
|
+
throw new Error('A self-hosted network bind needs a domain (or keep the server on loopback behind a tunnel).');
|
|
96
|
+
}
|
|
97
|
+
return settings;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function generateAccessKey() {
|
|
101
|
+
return `ob_${randomBytes(32).toString('base64url')}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Every URL a person might reach this server through, derived from the settings
|
|
105
|
+
// so the CLI banner, the settings drawer, and `onboarder config` never disagree
|
|
106
|
+
// about what to print.
|
|
107
|
+
export function serverUrls(value = DEFAULT_SETTINGS) {
|
|
108
|
+
const settings = normalizeSettings(value);
|
|
109
|
+
const urls = { local: `http://localhost:${settings.port}` };
|
|
110
|
+
if (!isLoopbackHost(settings.host)) {
|
|
111
|
+
urls.network = settings.host === '0.0.0.0' || settings.host === '::'
|
|
112
|
+
? `http://<this-machine>:${settings.port} (every interface)`
|
|
113
|
+
: `http://${settings.host}:${settings.port}`;
|
|
114
|
+
}
|
|
115
|
+
if (settings.domain) urls.domain = `https://${settings.domain}`;
|
|
116
|
+
return urls;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The key, safe to print in a list: enough to recognize it, not enough to use it.
|
|
120
|
+
export function maskAccessKey(key) {
|
|
121
|
+
const value = String(key || '');
|
|
122
|
+
if (value.length <= 8) return value ? '…' : '';
|
|
123
|
+
return `${value.slice(0, 6)}…${value.slice(-4)}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function publicSettings(value = DEFAULT_SETTINGS) {
|
|
127
|
+
const settings = normalizeSettings(value);
|
|
128
|
+
return {
|
|
129
|
+
version: settings.version,
|
|
130
|
+
mode: settings.mode,
|
|
131
|
+
host: settings.host,
|
|
132
|
+
port: settings.port,
|
|
133
|
+
domain: settings.domain,
|
|
134
|
+
autoOpen: settings.autoOpen,
|
|
135
|
+
hasAccessKey: Boolean(settings.accessKey),
|
|
136
|
+
accessKeyMasked: maskAccessKey(settings.accessKey),
|
|
137
|
+
account: settings.account,
|
|
138
|
+
tunnel: settings.tunnel,
|
|
139
|
+
urls: serverUrls(settings),
|
|
140
|
+
security: {
|
|
141
|
+
remoteAuth: settings.mode === 'self-hosted',
|
|
142
|
+
loopback: isLoopbackHost(settings.host),
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Hostnames the rebinding guard should accept beyond loopback: the configured
|
|
148
|
+
// domain, and a non-loopback bind address itself. A loopback-only local server
|
|
149
|
+
// gets an empty list, which is exactly the old behavior.
|
|
150
|
+
//
|
|
151
|
+
// Enabled tunnels add wildcard suffixes: a Cloudflare quick tunnel's name is
|
|
152
|
+
// random (https://<something>.trycloudflare.com) and a Tailscale machine name
|
|
153
|
+
// belongs to the tailnet, so neither can be configured ahead of time. This is
|
|
154
|
+
// safe because it can only widen Host acceptance in self-hosted mode, where
|
|
155
|
+
// every API call already needs the access key — the guard's job there is
|
|
156
|
+
// keeping drive-by traffic off the static files, not authentication.
|
|
157
|
+
export function allowedHosts(value = DEFAULT_SETTINGS) {
|
|
158
|
+
const settings = normalizeSettings(value);
|
|
159
|
+
const hosts = new Set();
|
|
160
|
+
if (settings.domain) hosts.add(settings.domain);
|
|
161
|
+
if (!isLoopbackHost(settings.host)) hosts.add(settings.host.toLowerCase());
|
|
162
|
+
if (settings.tunnel.cloudflare) hosts.add('*.trycloudflare.com');
|
|
163
|
+
if (settings.tunnel.tailscale) hosts.add('*.ts.net');
|
|
164
|
+
return [...hosts];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function accessKeysMatch(expected, actual) {
|
|
168
|
+
const a = Buffer.from(String(expected || ''));
|
|
169
|
+
const b = Buffer.from(String(actual || ''));
|
|
170
|
+
return a.length > 0 && a.length === b.length && timingSafeEqual(a, b);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function bearerToken(req) {
|
|
174
|
+
const value = String(req.headers?.authorization || '');
|
|
175
|
+
const match = value.match(/^Bearer\s+(.+)$/i);
|
|
176
|
+
return match ? match[1].trim() : '';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function authReason(req, settings = DEFAULT_SETTINGS) {
|
|
180
|
+
const normalized = normalizeSettings(settings);
|
|
181
|
+
if (normalized.mode !== 'self-hosted') return null;
|
|
182
|
+
if (!normalized.accessKey) return 'Self-hosted mode has no access key configured.';
|
|
183
|
+
const token = bearerToken(req);
|
|
184
|
+
if (!accessKeysMatch(normalized.accessKey, token)) return 'A valid access key is required.';
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Has a config ever been written? The wizard asks this to decide between
|
|
189
|
+
// "start from the defaults" and "start from what you already have".
|
|
190
|
+
export async function configExists(file = configPath()) {
|
|
191
|
+
try {
|
|
192
|
+
await fs.access(file);
|
|
193
|
+
return true;
|
|
194
|
+
} catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
export async function readSettings(file = configPath()) {
|
|
201
|
+
try {
|
|
202
|
+
const raw = await fs.readFile(file, 'utf8');
|
|
203
|
+
return normalizeSettings(JSON.parse(raw));
|
|
204
|
+
} catch (err) {
|
|
205
|
+
if (err.code === 'ENOENT') return { ...DEFAULT_SETTINGS, account: { ...DEFAULT_SETTINGS.account }, tunnel: { ...DEFAULT_SETTINGS.tunnel } };
|
|
206
|
+
if (err instanceof SyntaxError) throw new Error(`Config is not valid JSON: ${file}`);
|
|
207
|
+
throw err;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function writeSettings(value, file = configPath()) {
|
|
212
|
+
const settings = normalizeSettings(value);
|
|
213
|
+
const dir = path.dirname(file);
|
|
214
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
215
|
+
const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
216
|
+
const body = JSON.stringify(settings, null, 2) + '\n';
|
|
217
|
+
await fs.writeFile(temp, body, { encoding: 'utf8', mode: 0o600 });
|
|
218
|
+
await fs.rename(temp, file);
|
|
219
|
+
await fs.chmod(file, 0o600);
|
|
220
|
+
return settings;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function updateSettings(patch = {}, file = configPath()) {
|
|
224
|
+
const current = await readSettings(file);
|
|
225
|
+
const next = normalizeSettings({ ...current, ...patch, account: { ...current.account, ...(patch.account || {}) }, tunnel: { ...current.tunnel, ...(patch.tunnel || {}) } });
|
|
226
|
+
return writeSettings(next, file);
|
|
227
|
+
}
|