thumbgate 0.9.13 → 0.9.14

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/src/api/server.js CHANGED
@@ -13,6 +13,10 @@ const {
13
13
  getFeedbackPaths,
14
14
  appendDiagnosticRecord,
15
15
  } = require('../../scripts/feedback-loop');
16
+ const {
17
+ readActiveProjectState,
18
+ resolveProjectDir,
19
+ } = require('../../scripts/feedback-paths');
16
20
  const {
17
21
  readRecentConversationWindow,
18
22
  } = require('../../scripts/feedback-history-distiller');
@@ -203,8 +207,55 @@ function computeConversionStats(events) {
203
207
  }
204
208
  // ---------------------------------------------------------------------------
205
209
 
206
- function getSafeDataDir() {
207
- const { FEEDBACK_LOG_PATH } = getFeedbackPaths();
210
+ function getRequestedProjectSelection(req, parsed) {
211
+ const projectFromQuery = parsed && parsed.searchParams
212
+ ? parsed.searchParams.get('project')
213
+ : null;
214
+ const projectFromHeaders = req && req.headers
215
+ ? req.headers['x-thumbgate-project-dir'] || req.headers['x-thumbgate-project']
216
+ : null;
217
+ return projectFromQuery || projectFromHeaders || null;
218
+ }
219
+
220
+ function getEffectiveRequestedProjectSelection(req, parsed) {
221
+ return isProjectSelectionAllowed(req, parsed)
222
+ ? getRequestedProjectSelection(req, parsed)
223
+ : null;
224
+ }
225
+
226
+ function isProjectSelectionAllowed(req, parsed) {
227
+ const explicitProject = getRequestedProjectSelection(req, parsed);
228
+ if (!explicitProject) return true;
229
+ return isLoopbackHost(getRequestHostHeader(req));
230
+ }
231
+
232
+ function resolveRequestProjectDir(req, parsed) {
233
+ const explicitProject = getEffectiveRequestedProjectSelection(req, parsed);
234
+ return resolveProjectDir({
235
+ projectDir: explicitProject,
236
+ env: process.env,
237
+ });
238
+ }
239
+
240
+ function shouldPreferProjectScopedFeedback(req, parsed) {
241
+ const explicitProject = getEffectiveRequestedProjectSelection(req, parsed);
242
+ if (explicitProject) return true;
243
+ if (process.env.THUMBGATE_PROJECT_DIR || process.env.CLAUDE_PROJECT_DIR) return true;
244
+ if (process.env.THUMBGATE_FEEDBACK_DIR) return false;
245
+ return Boolean(readActiveProjectState({ env: process.env }));
246
+ }
247
+
248
+ function getRequestFeedbackPaths(req, parsed) {
249
+ const explicitProject = getEffectiveRequestedProjectSelection(req, parsed);
250
+ return getFeedbackPaths({
251
+ projectDir: resolveRequestProjectDir(req, parsed),
252
+ explicitProjectDir: explicitProject,
253
+ skipExplicitFeedbackDir: shouldPreferProjectScopedFeedback(req, parsed),
254
+ });
255
+ }
256
+
257
+ function getSafeDataDir(req, parsed) {
258
+ const { FEEDBACK_LOG_PATH } = getRequestFeedbackPaths(req, parsed);
208
259
  return path.resolve(path.dirname(FEEDBACK_LOG_PATH));
209
260
  }
210
261
 
@@ -857,6 +908,14 @@ function getPublicOrigin(req) {
857
908
  return `${proto}://${host}`;
858
909
  }
859
910
 
911
+ function getRequestHostHeader(req) {
912
+ const forwardedHost = req.headers['x-forwarded-host'];
913
+ if (Array.isArray(forwardedHost)) {
914
+ return forwardedHost[0] || req.headers.host || '';
915
+ }
916
+ return forwardedHost || req.headers.host || '';
917
+ }
918
+
860
919
  function isLoopbackHost(hostValue) {
861
920
  const rawHost = String(hostValue || '').split(',')[0].trim();
862
921
  if (!rawHost) {
@@ -2094,10 +2153,10 @@ function extractTags(input) {
2094
2153
  return [];
2095
2154
  }
2096
2155
 
2097
- function resolveSafePath(inputPath, { mustExist = false } = {}) {
2156
+ function resolveSafePath(inputPath, { mustExist = false, safeDataDir } = {}) {
2098
2157
  const allowExternal = process.env.THUMBGATE_ALLOW_EXTERNAL_PATHS === 'true';
2099
2158
  const resolved = path.resolve(String(inputPath || ''));
2100
- const SAFE_DATA_DIR = getSafeDataDir();
2159
+ const SAFE_DATA_DIR = safeDataDir || getSafeDataDir();
2101
2160
  const inSafeRoot = resolved === SAFE_DATA_DIR || resolved.startsWith(`${SAFE_DATA_DIR}${path.sep}`);
2102
2161
 
2103
2162
  if (!allowExternal && !inSafeRoot) {
@@ -2121,6 +2180,13 @@ function createApiServer() {
2121
2180
  const isGetLikeRequest = req.method === 'GET' || isHeadRequest;
2122
2181
  const publicOrigin = getPublicOrigin(req);
2123
2182
  const hostedConfig = resolveHostedBillingConfig({ requestOrigin: publicOrigin });
2183
+ if (!isProjectSelectionAllowed(req, parsed)) {
2184
+ sendJson(res, 403, { error: 'project selection is only available on localhost requests' });
2185
+ return;
2186
+ }
2187
+ const requestFeedbackPaths = getRequestFeedbackPaths(req, parsed);
2188
+ const requestFeedbackDir = requestFeedbackPaths.FEEDBACK_DIR;
2189
+ const requestSafeDataDir = getSafeDataDir(req, parsed);
2124
2190
 
2125
2191
  // Public MCP endpoint — responds to Smithery registry scanning and MCP initialize
2126
2192
  // The initialize handshake is unauthenticated; subsequent tool calls require Bearer auth
@@ -2394,7 +2460,7 @@ function createApiServer() {
2394
2460
  const signal = parsed.searchParams.get('signal');
2395
2461
  if (signal === 'up' || signal === 'down') {
2396
2462
  const chatHistory = readRecentConversationWindow({
2397
- feedbackDir: getSafeDataDir(),
2463
+ feedbackDir: requestSafeDataDir,
2398
2464
  limit: 10,
2399
2465
  });
2400
2466
  const result = captureFeedback({
@@ -2484,7 +2550,7 @@ async function addContext(){
2484
2550
  sendJson(res, 400, { error: 'relatedFeedbackId is required' });
2485
2551
  return;
2486
2552
  }
2487
- const feedbackDir = getSafeDataDir();
2553
+ const feedbackDir = requestSafeDataDir;
2488
2554
  const detailField = signal === 'down' ? 'whatWentWrong' : 'whatWorked';
2489
2555
  const updated = updateLessonRecord(feedbackDir, relatedFeedbackId, (existing) => {
2490
2556
  const nextTags = Array.from(new Set([
@@ -2536,7 +2602,7 @@ async function addContext(){
2536
2602
  const lessonUpdateMatch = pathname.match(/^\/lessons\/([^/]+)\/update$/);
2537
2603
  if (req.method === 'POST' && lessonUpdateMatch) {
2538
2604
  const lessonId = decodeURIComponent(lessonUpdateMatch[1]);
2539
- const feedbackDir = getSafeDataDir();
2605
+ const feedbackDir = requestSafeDataDir;
2540
2606
  const body = await parseJsonBody(req);
2541
2607
  const record = findRecordById(lessonId, feedbackDir);
2542
2608
  if (!record) {
@@ -2568,7 +2634,7 @@ async function addContext(){
2568
2634
  const lessonDeleteMatch = pathname.match(/^\/lessons\/([^/]+)\/delete$/);
2569
2635
  if (req.method === 'POST' && lessonDeleteMatch) {
2570
2636
  const lessonId = decodeURIComponent(lessonDeleteMatch[1]);
2571
- const feedbackDir = getSafeDataDir();
2637
+ const feedbackDir = requestSafeDataDir;
2572
2638
  const memoryLogPath = path.join(feedbackDir, 'memory-log.jsonl');
2573
2639
  const feedbackLogPath = path.join(feedbackDir, 'feedback-log.jsonl');
2574
2640
  const deletedMemory = deleteRecordFromJsonl(memoryLogPath, lessonId);
@@ -2585,7 +2651,7 @@ async function addContext(){
2585
2651
  const lessonDetailMatch = pathname.match(/^\/lessons\/([^/]+)$/);
2586
2652
  if (isGetLikeRequest && lessonDetailMatch && lessonDetailMatch[1] !== '') {
2587
2653
  const lessonId = decodeURIComponent(lessonDetailMatch[1]);
2588
- const feedbackDir = getSafeDataDir();
2654
+ const feedbackDir = requestSafeDataDir;
2589
2655
  const record = findRecordById(lessonId, feedbackDir);
2590
2656
  if (!record) {
2591
2657
  sendHtml(res, 404, renderLessonDetailHtml(null, lessonId));
@@ -2959,7 +3025,7 @@ async function addContext(){
2959
3025
  }
2960
3026
 
2961
3027
  if (isGetLikeRequest && pathname === '/healthz') {
2962
- const { FEEDBACK_LOG_PATH, MEMORY_LOG_PATH } = getFeedbackPaths();
3028
+ const { FEEDBACK_LOG_PATH, MEMORY_LOG_PATH } = requestFeedbackPaths;
2963
3029
  sendJson(res, 200, {
2964
3030
  status: 'ok',
2965
3031
  feedbackLogPath: FEEDBACK_LOG_PATH,
@@ -3497,7 +3563,7 @@ async function addContext(){
3497
3563
 
3498
3564
  try {
3499
3565
  if (req.method === 'GET' && pathname === '/v1/feedback/stats') {
3500
- sendJson(res, 200, analyzeFeedback());
3566
+ sendJson(res, 200, analyzeFeedback(requestFeedbackPaths.FEEDBACK_LOG_PATH));
3501
3567
  return;
3502
3568
  }
3503
3569
 
@@ -3740,7 +3806,9 @@ async function addContext(){
3740
3806
 
3741
3807
  if (req.method === 'GET' && pathname === '/v1/feedback/summary') {
3742
3808
  const recent = Number(parsed.searchParams.get('recent') || 20);
3743
- const summary = feedbackSummary(Number.isFinite(recent) ? recent : 20);
3809
+ const summary = feedbackSummary(Number.isFinite(recent) ? recent : 20, {
3810
+ feedbackDir: requestFeedbackDir,
3811
+ });
3744
3812
  sendJson(res, 200, { summary });
3745
3813
  return;
3746
3814
  }
@@ -3757,6 +3825,7 @@ async function addContext(){
3757
3825
  limit: Number.isFinite(limit) ? limit : 10,
3758
3826
  category,
3759
3827
  tags,
3828
+ feedbackDir: requestFeedbackDir,
3760
3829
  });
3761
3830
  sendJson(res, 200, results);
3762
3831
  return;
@@ -3846,7 +3915,9 @@ async function addContext(){
3846
3915
  if (req.method === 'POST' && pathname === '/v1/feedback/rules') {
3847
3916
  const body = await parseJsonBody(req);
3848
3917
  const minOccurrences = Number(body.minOccurrences || 2);
3849
- const outputPath = body.outputPath ? resolveSafePath(body.outputPath) : undefined;
3918
+ const outputPath = body.outputPath
3919
+ ? resolveSafePath(body.outputPath, { safeDataDir: requestSafeDataDir })
3920
+ : undefined;
3850
3921
  const result = writePreventionRules(outputPath, Number.isFinite(minOccurrences) ? minOccurrences : 2);
3851
3922
  sendJson(res, 200, {
3852
3923
  path: result.path,
@@ -3875,20 +3946,28 @@ async function addContext(){
3875
3946
  let memories = [];
3876
3947
 
3877
3948
  if (body.inputPath) {
3878
- const safeInputPath = resolveSafePath(body.inputPath, { mustExist: true });
3949
+ const safeInputPath = resolveSafePath(body.inputPath, {
3950
+ mustExist: true,
3951
+ safeDataDir: requestSafeDataDir,
3952
+ });
3879
3953
  const raw = fs.readFileSync(safeInputPath, 'utf-8');
3880
3954
  const parsedMemories = JSON.parse(raw);
3881
3955
  memories = Array.isArray(parsedMemories) ? parsedMemories : parsedMemories.memories || [];
3882
3956
  } else {
3883
3957
  const localPath = body.memoryLogPath
3884
- ? resolveSafePath(body.memoryLogPath, { mustExist: true })
3885
- : DEFAULT_LOCAL_MEMORY_LOG;
3958
+ ? resolveSafePath(body.memoryLogPath, {
3959
+ mustExist: true,
3960
+ safeDataDir: requestSafeDataDir,
3961
+ })
3962
+ : requestFeedbackPaths.MEMORY_LOG_PATH;
3886
3963
  memories = readJSONL(localPath);
3887
3964
  }
3888
3965
 
3889
3966
  const result = exportDpoFromMemories(memories);
3890
3967
  if (body.outputPath) {
3891
- const safeOutputPath = resolveSafePath(body.outputPath);
3968
+ const safeOutputPath = resolveSafePath(body.outputPath, {
3969
+ safeDataDir: requestSafeDataDir,
3970
+ });
3892
3971
  fs.mkdirSync(path.dirname(safeOutputPath), { recursive: true });
3893
3972
  fs.writeFileSync(safeOutputPath, result.jsonl);
3894
3973
  }
@@ -3899,14 +3978,18 @@ async function addContext(){
3899
3978
  learnings: result.learnings.length,
3900
3979
  unpairedErrors: result.unpairedErrors.length,
3901
3980
  unpairedLearnings: result.unpairedLearnings.length,
3902
- outputPath: body.outputPath ? resolveSafePath(body.outputPath) : null,
3981
+ outputPath: body.outputPath
3982
+ ? resolveSafePath(body.outputPath, { safeDataDir: requestSafeDataDir })
3983
+ : null,
3903
3984
  });
3904
3985
  return;
3905
3986
  }
3906
3987
 
3907
3988
  if (req.method === 'POST' && pathname === '/v1/analytics/databricks/export') {
3908
3989
  const body = await parseJsonBody(req);
3909
- const outputPath = body.outputPath ? resolveSafePath(body.outputPath) : undefined;
3990
+ const outputPath = body.outputPath
3991
+ ? resolveSafePath(body.outputPath, { safeDataDir: requestSafeDataDir })
3992
+ : undefined;
3910
3993
  const result = exportDatabricksBundle(undefined, outputPath);
3911
3994
  sendJson(res, 200, result);
3912
3995
  return;
@@ -3971,7 +4054,7 @@ async function addContext(){
3971
4054
  // ----------------------------------------------------------------
3972
4055
 
3973
4056
  if (req.method === 'GET' && pathname === '/v1/quality/scores') {
3974
- const modelPath = path.join(getSafeDataDir(), 'feedback_model.json');
4057
+ const modelPath = path.join(requestSafeDataDir, 'feedback_model.json');
3975
4058
  const model = loadModel(modelPath);
3976
4059
  const reliability = getReliability(model);
3977
4060
  const category = parsed.searchParams.get('category');
@@ -3991,7 +4074,7 @@ async function addContext(){
3991
4074
  }
3992
4075
 
3993
4076
  if (req.method === 'GET' && pathname === '/v1/quality/rules') {
3994
- const rulesPath = path.join(getSafeDataDir(), 'prevention-rules.md');
4077
+ const rulesPath = path.join(requestSafeDataDir, 'prevention-rules.md');
3995
4078
  let markdown = '';
3996
4079
  if (fs.existsSync(rulesPath)) {
3997
4080
  markdown = fs.readFileSync(rulesPath, 'utf8').trim();
@@ -4008,7 +4091,7 @@ async function addContext(){
4008
4091
  }
4009
4092
 
4010
4093
  if (req.method === 'GET' && pathname === '/v1/quality/posteriors') {
4011
- const modelPath = path.join(getSafeDataDir(), 'feedback_model.json');
4094
+ const modelPath = path.join(requestSafeDataDir, 'feedback_model.json');
4012
4095
  const model = loadModel(modelPath);
4013
4096
  const posteriors = samplePosteriors(model);
4014
4097
  sendJson(res, 200, { posteriors });
@@ -4247,9 +4330,8 @@ async function addContext(){
4247
4330
  return;
4248
4331
  }
4249
4332
 
4250
- const { FEEDBACK_DIR } = getFeedbackPaths();
4251
4333
  const billingSummary = await getBillingSummaryLive(summaryOptions);
4252
- const data = generateDashboard(FEEDBACK_DIR, {
4334
+ const data = generateDashboard(requestFeedbackDir, {
4253
4335
  analyticsWindow: summaryOptions,
4254
4336
  billingSummary,
4255
4337
  billingSource: 'live',
@@ -4275,9 +4357,8 @@ async function addContext(){
4275
4357
  }
4276
4358
 
4277
4359
  try {
4278
- const { FEEDBACK_DIR } = getFeedbackPaths();
4279
4360
  const billingSummary = await getBillingSummaryLive(summaryOptions);
4280
- const data = generateDashboard(FEEDBACK_DIR, {
4361
+ const data = generateDashboard(requestFeedbackDir, {
4281
4362
  analyticsWindow: summaryOptions,
4282
4363
  billingSummary,
4283
4364
  billingSource: 'live',