thumbgate 1.34.3 → 1.37.1

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 (200) hide show
  1. package/.agents/skills/cyberstrike-compare-not-clone/SKILL.md +36 -0
  2. package/.agents/skills/gitlab-sandbox-allowlist-not-trust/SKILL.md +77 -0
  3. package/.agents/skills/jit-harness-compare-not-clone/SKILL.md +34 -0
  4. package/.agents/skills/openui-catalog-compose-honesty/SKILL.md +64 -0
  5. package/.agents/skills/zvec-grep-compare-not-clone/SKILL.md +34 -0
  6. package/.claude-plugin/plugin.json +1 -1
  7. package/.well-known/llms.txt +1 -0
  8. package/.well-known/mcp/server-card.json +1 -1
  9. package/CONTRIBUTING.md +95 -0
  10. package/README.md +195 -632
  11. package/THIRD_PARTY_NOTICES.md +89 -0
  12. package/adapters/claude/.mcp.json +2 -2
  13. package/adapters/forge/forge.yaml +3 -3
  14. package/adapters/future-agi/.mcp.json +8 -0
  15. package/adapters/future-agi/FUTURE_AGI.md +23 -0
  16. package/adapters/future-agi/config.toml +3 -0
  17. package/adapters/future-agi/future-agi-bridge.js +9 -0
  18. package/adapters/future-agi/opencode.json +8 -0
  19. package/adapters/herdr/herdr-plugin.toml +18 -0
  20. package/adapters/mcp/server-stdio.js +238 -25
  21. package/adapters/opencode/opencode.json +1 -1
  22. package/adapters/workos/WORKOS.md +52 -0
  23. package/bin/cli.js +373 -5
  24. package/bin/futureagi-bridge +9 -0
  25. package/config/gate-templates.json +653 -4
  26. package/config/gates/actor-critic-audit.json +34 -0
  27. package/config/gates/default.json +21 -2
  28. package/config/gates/five-walls-governance.json +34 -0
  29. package/config/gates/future-agi-guardrails.json +34 -0
  30. package/config/gates/radware-threat-defense-2026.json +61 -0
  31. package/config/gates/simatree-data-governance.json +33 -0
  32. package/config/mcp-allowlists.json +4 -0
  33. package/config/merge-quality-checks.json +10 -1
  34. package/config/model-candidates.json +382 -24
  35. package/config/model-tiers.json +18 -0
  36. package/config/post-deploy-marketing-pages.json +10 -0
  37. package/config/progressive/01-wire-only.json +11 -0
  38. package/config/progressive/02-dashboard-empty-ok.json +10 -0
  39. package/config/progressive/03-one-lesson.json +10 -0
  40. package/config/progressive/04-warn-fires.json +11 -0
  41. package/config/progressive/05-strict-optional.json +11 -0
  42. package/config/progressive/README.md +15 -0
  43. package/config/schemas/broker-execution-receipt.schema.json +139 -0
  44. package/config/schemas/provider-execution-attestation-v1.schema.json +58 -0
  45. package/conformance/provider-attestation/vectors.json +320 -0
  46. package/docs/specs/provider-execution-attestation-v1.md +69 -0
  47. package/openapi/openapi.yaml +15 -0
  48. package/package.json +401 -147
  49. package/public/about.html +2 -2
  50. package/public/ai-malpractice-prevention.html +7 -7
  51. package/public/blog/a-10-dollar-vps-is-not-a-computer.html +143 -0
  52. package/public/blog/a-receipt-is-not-world-state.html +388 -0
  53. package/public/blog/git-at-agent-scale.html +374 -0
  54. package/public/blog/no-llm-in-the-gate.html +133 -0
  55. package/public/blog.html +80 -0
  56. package/public/case-studies.html +16 -1
  57. package/public/compare.html +28 -0
  58. package/public/diagnostic.html +216 -7
  59. package/public/docs/connectors.html +39 -0
  60. package/public/federal.html +2 -2
  61. package/public/founders.html +639 -0
  62. package/public/index.html +87 -9
  63. package/public/install.html +8 -8
  64. package/public/learn.html +39 -0
  65. package/public/numbers.html +2 -2
  66. package/public/peter.html +310 -0
  67. package/public/platform-partners.html +119 -0
  68. package/public/pricing.html +24 -3
  69. package/public/privacy.html +117 -0
  70. package/public/pro.html +17 -0
  71. package/public/support.html +62 -0
  72. package/public/terms.html +130 -0
  73. package/public/third-party-notices.html +95 -0
  74. package/public/yt.html +351 -0
  75. package/scripts/action-receipts.js +133 -3
  76. package/scripts/adaptive-governance-arena.js +349 -0
  77. package/scripts/admin-override.js +205 -0
  78. package/scripts/agent-action-inventory.js +869 -0
  79. package/scripts/agent-audit-trace.js +42 -2
  80. package/scripts/agent-egress-policy.js +1117 -0
  81. package/scripts/agent-memory-lifecycle.js +141 -2
  82. package/scripts/agent-operations-planner.js +441 -1
  83. package/scripts/agent-readiness.js +68 -0
  84. package/scripts/agent-security-central.js +647 -0
  85. package/scripts/allowlist-bridge-honesty.js +417 -0
  86. package/scripts/async-job-runner.js +102 -11
  87. package/scripts/audit-trail.js +212 -0
  88. package/scripts/auto-promote-gates.js +178 -27
  89. package/scripts/billing.js +1 -1
  90. package/scripts/broker-execution-receipts.js +719 -0
  91. package/scripts/budget-aware-gates-proof.js +423 -0
  92. package/scripts/claude-feedback-sync.js +29 -3
  93. package/scripts/claw-harness-production.js +237 -0
  94. package/scripts/cli-progress.js +111 -0
  95. package/scripts/cli-schema.js +163 -1
  96. package/scripts/codex-runbook-flywheel.js +318 -0
  97. package/scripts/context-footprint.js +186 -0
  98. package/scripts/contextfs.js +143 -61
  99. package/scripts/dashboard-limits.js +27 -0
  100. package/scripts/dashboard.js +279 -9
  101. package/scripts/deepseek-v4-runtime-guardrails.js +72 -6
  102. package/scripts/docker-sandbox-planner.js +18 -0
  103. package/scripts/double-blind-eval-protocol.js +252 -0
  104. package/scripts/edotenv-rl-gateway.js +259 -0
  105. package/scripts/ensure-production-search-corpus.js +162 -0
  106. package/scripts/eval-holdout.js +311 -0
  107. package/scripts/feedback-aggregate.js +21 -2
  108. package/scripts/feedback-loop.js +87 -5
  109. package/scripts/feedback-quality.js +9 -0
  110. package/scripts/file-ledger-lock.js +4 -1
  111. package/scripts/financial-control-plane.js +41 -1
  112. package/scripts/find-dormant-requires.js +118 -0
  113. package/scripts/fs-utils.js +84 -8
  114. package/scripts/gate-stats.js +2 -2
  115. package/scripts/gates-engine.js +859 -58
  116. package/scripts/generate-case-study-outreach.js +24 -15
  117. package/scripts/git-at-scale.js +628 -0
  118. package/scripts/governance-conflict-audit.js +1650 -0
  119. package/scripts/governance-difficulty-curriculum.js +328 -0
  120. package/scripts/graphrag-retrieval.js +275 -0
  121. package/scripts/gurobi-optimizer.js +324 -0
  122. package/scripts/gurobi_optimizer.py +485 -0
  123. package/scripts/harness-selector.js +82 -1
  124. package/scripts/hidden-entry-points.js +284 -0
  125. package/scripts/human-escalation.js +199 -1
  126. package/scripts/hybrid-feedback-context.js +152 -19
  127. package/scripts/intent-governed-execution.js +602 -0
  128. package/scripts/intervention-policy.js +123 -20
  129. package/scripts/jit-harness-compose.js +628 -0
  130. package/scripts/jsonl-watcher.js +10 -0
  131. package/scripts/lesson-embedding-index.js +95 -12
  132. package/scripts/lesson-retrieval.js +105 -19
  133. package/scripts/local-model-profile.js +19 -2
  134. package/scripts/mailer/resend-mailer.js +1 -1
  135. package/scripts/matryoshka-embedding.js +235 -0
  136. package/scripts/mcp-oauth.js +42 -4
  137. package/scripts/mcp-session-handles.js +1016 -0
  138. package/scripts/mcp-wiring-doctor.js +314 -0
  139. package/scripts/memory-firewall.js +115 -2
  140. package/scripts/memory-scope-readiness.js +299 -0
  141. package/scripts/memory-vs-rag-route.js +161 -0
  142. package/scripts/model-tier-router.js +148 -21
  143. package/scripts/nvidia-specdecode-al-doctor.js +536 -0
  144. package/scripts/openui-catalog-compose-honesty.js +593 -0
  145. package/scripts/operational-integrity.js +19 -1
  146. package/scripts/override-audit.js +213 -0
  147. package/scripts/package-manager-honesty-doctor.js +458 -0
  148. package/scripts/pr-manager.js +63 -1
  149. package/scripts/prove-herdr-adapter.js +52 -0
  150. package/scripts/prove-memory-pyramid-and-symbolic-canvas.js +95 -0
  151. package/scripts/prove-workos.js +73 -0
  152. package/scripts/provider-attestation-conformance.js +192 -0
  153. package/scripts/provider-receipt-contract.js +136 -0
  154. package/scripts/qwen38-max-cost-optimizer.js +401 -0
  155. package/scripts/radware-threat-defense.js +280 -0
  156. package/scripts/rag-embedding-identity.js +221 -0
  157. package/scripts/rag-precision-guardrails.js +112 -2
  158. package/scripts/remote-feedback-capture.js +159 -0
  159. package/scripts/research-agent-harness.js +256 -0
  160. package/scripts/rsi-safety-hillclimb.js +200 -0
  161. package/scripts/rule-sprawl.js +188 -0
  162. package/scripts/schedule-manager.js +147 -0
  163. package/scripts/self-heal.js +8 -0
  164. package/scripts/session-lease.js +415 -0
  165. package/scripts/simatree-data-governance.js +347 -0
  166. package/scripts/slo-alert-engine.js +172 -7
  167. package/scripts/solver-parity.js +539 -0
  168. package/scripts/stealth-memory-injection-gate.js +333 -0
  169. package/scripts/switchyard-router.js +366 -0
  170. package/scripts/telemetry-analytics.js +84 -27
  171. package/scripts/temporal-decay-weighting.js +138 -0
  172. package/scripts/test-all.js +165 -0
  173. package/scripts/token-savings.js +42 -0
  174. package/scripts/tool-kpi-tracker.js +108 -5
  175. package/scripts/tool-registry.js +193 -5
  176. package/scripts/universal-claim-evaluator.js +14 -2
  177. package/scripts/vector-store.js +279 -9
  178. package/scripts/workflow-notebook.js +391 -0
  179. package/scripts/workflow-sentinel.js +111 -12
  180. package/scripts/workos-production-guard.js +260 -0
  181. package/scripts/workspace-search-route.js +515 -0
  182. package/server.json +2 -2
  183. package/src/agent-identity-boundary.js +76 -0
  184. package/src/agent-retrieval-cache.js +155 -0
  185. package/src/alert-noise-ledger.js +502 -0
  186. package/src/api/server.js +802 -185
  187. package/src/git-fast-cache.js +220 -0
  188. package/src/git-wal-sync.js +156 -0
  189. package/src/hash-anchored-edit.js +82 -0
  190. package/src/hermes-platform-protocol.js +475 -0
  191. package/src/hermes-sync-plane.js +241 -0
  192. package/src/index.js +30 -1
  193. package/src/iso42001-compliance-guard.js +97 -0
  194. package/src/latency-budget.js +244 -0
  195. package/src/mcp-writeguard.js +316 -0
  196. package/src/miminions-adapter.js +106 -0
  197. package/src/pipeline-compass.js +104 -0
  198. package/src/ppl-alert-pipeline.js +284 -0
  199. package/src/rendezvous-router.js +90 -0
  200. package/src/security-questionnaire.js +195 -0
package/src/api/server.js CHANGED
@@ -16,6 +16,10 @@ const {
16
16
  buildIntakeDiscoveryPacket,
17
17
  buildIntakeQualificationCard,
18
18
  } = require('../../scripts/revenue-offer-system');
19
+ const {
20
+ getSecurityQuestionnaire,
21
+ renderSecurityOverviewHtml,
22
+ } = require('../security-questionnaire');
19
23
 
20
24
  const POSTHOG_API_PATHS = new Set(['/capture', '/batch', '/decide', '/e', '/engage']);
21
25
  const POSTHOG_INGEST_HOST = 'us.i.posthog.com';
@@ -139,6 +143,10 @@ const {
139
143
  getBillingSummaryLive,
140
144
  getBillingSummariesLive,
141
145
  } = require('../../scripts/billing');
146
+ const {
147
+ ensureProductionSearchCorpus,
148
+ shouldEnsureProductionSearchCorpus,
149
+ } = require('../../scripts/ensure-production-search-corpus');
142
150
  const {
143
151
  DEFAULT_PUBLIC_APP_ORIGIN,
144
152
  resolveHostedBillingConfig,
@@ -188,6 +196,49 @@ const {
188
196
  recordDecisionOutcome,
189
197
  computeDecisionMetrics,
190
198
  } = require('../../scripts/decision-journal');
199
+ const {
200
+ HermesPlatformProtocol,
201
+ } = require('../hermes-platform-protocol');
202
+ const {
203
+ HermesSyncPlane,
204
+ } = require('../hermes-sync-plane');
205
+
206
+ // Partition Hermes protocol/sync state by hosted tenant so one customer's
207
+ // connection/thread/approval maps cannot be read or mutated by another.
208
+ const hostedHermesByTenant = new Map();
209
+
210
+ function resolveHermesTenantKey(requestDataIdentity) {
211
+ return requestDataIdentity?.partition || '__default__';
212
+ }
213
+
214
+ function getHostedHermes(requestDataIdentity) {
215
+ const key = resolveHermesTenantKey(requestDataIdentity);
216
+ let entry = hostedHermesByTenant.get(key);
217
+ if (!entry) {
218
+ entry = {
219
+ protocol: new HermesPlatformProtocol(),
220
+ syncPlane: new HermesSyncPlane(),
221
+ };
222
+ hostedHermesByTenant.set(key, entry);
223
+ }
224
+ return entry;
225
+ }
226
+
227
+ function resolveHermesApproverId(req, humanReviewerConfig, requestDataIdentity) {
228
+ if (humanReviewerConfig?.key && humanReviewerConfig?.id) {
229
+ const header = req.headers['x-thumbgate-human-reviewer-key'];
230
+ const presented = Array.isArray(header) ? header[0] : header;
231
+ if (safeKeyEqual(presented, humanReviewerConfig.key)) {
232
+ return humanReviewerConfig.id;
233
+ }
234
+ }
235
+ if (requestDataIdentity?.accessContext?.principalId) {
236
+ return requestDataIdentity.accessContext.principalId;
237
+ }
238
+ const role = resolveKeyRole(extractApiKey(req));
239
+ if (role) return `thumbgate:${role}`;
240
+ return null;
241
+ }
191
242
  const {
192
243
  generateDashboard,
193
244
  buildReviewSnapshot,
@@ -195,6 +246,10 @@ const {
195
246
  writeDashboardReviewState,
196
247
  collectAllFeedbackEntries,
197
248
  } = require('../../scripts/dashboard');
249
+ const {
250
+ isDashboardDataLimitError,
251
+ formatDashboardLimitDetail,
252
+ } = require('../../scripts/dashboard-limits');
198
253
  const {
199
254
  collectAggregateLogEntries,
200
255
  computeAggregateFeedbackStats,
@@ -214,6 +269,7 @@ const {
214
269
  } = require('../../scripts/thumbgate-search');
215
270
  const {
216
271
  appendTelemetryPing,
272
+ headerHasPrivacyOptOut,
217
273
  } = require('../../scripts/telemetry-analytics');
218
274
  const {
219
275
  buildProductIssueTitle,
@@ -274,10 +330,13 @@ const EVAL_SCORECARD_PAGE_PATH = path.resolve(__dirname, '../../public/eval-scor
274
330
  const EVALUATIONS_PAGE_PATH = path.resolve(__dirname, '../../public/evaluations.html');
275
331
  const CASE_STUDIES_PAGE_PATH = path.resolve(__dirname, '../../public/case-studies.html');
276
332
  const FEDERAL_PAGE_PATH = path.resolve(__dirname, '../../public/federal.html');
333
+ const YT_PAGE_PATH = path.resolve(__dirname, '../../public/yt.html');
277
334
  const PRICING_PAGE_PATH = path.resolve(__dirname, '../../public/pricing.html');
278
335
  const ABOUT_PAGE_PATH = path.resolve(__dirname, '../../public/about.html');
279
336
  const DIAGNOSTIC_PAGE_PATH = path.resolve(__dirname, '../../public/diagnostic.html');
337
+ const FOUNDERS_PAGE_PATH = path.resolve(__dirname, '../../public/founders.html');
280
338
  const PARTNER_INTAKE_PAGE_PATH = path.resolve(__dirname, '../../public/partner-intake.html');
339
+ const PETER_PAGE_PATH = path.resolve(__dirname, '../../public/peter.html');
281
340
  const INSTALL_PAGE_PATH = path.resolve(__dirname, '../../public/install.html');
282
341
  const LEARN_DIR = path.resolve(__dirname, '../../public/learn');
283
342
  const GUIDES_DIR = path.resolve(__dirname, '../../public/guides');
@@ -292,6 +351,7 @@ const COMPARE_PAGE_PATHS_BY_SLUG = buildPublicHtmlFileMap(COMPARE_DIR);
292
351
  const USE_CASE_PAGE_PATHS_BY_SLUG = buildPublicHtmlFileMap(USE_CASES_DIR);
293
352
  const BLOG_PAGE_PATHS_BY_SLUG = buildPublicHtmlFileMap(BLOG_DIR);
294
353
  const BUYER_INTENT_SCRIPT_PATH = path.resolve(__dirname, '../../public/js/buyer-intent.js');
354
+ const WEBMCP_SCRIPT_PATH = path.resolve(__dirname, '../../public/js/webmcp.js');
295
355
  const STATIC_MIME_BY_EXT = Object.freeze({
296
356
  '.png': 'image/png',
297
357
  '.jpg': 'image/jpeg',
@@ -640,6 +700,30 @@ const TRACKED_LINK_TARGETS = Object.freeze({
640
700
  utm_campaign: 'github_repo',
641
701
  },
642
702
  },
703
+ npm: {
704
+ href: 'https://www.npmjs.com/package/thumbgate',
705
+ external: true,
706
+ ctaId: 'go_npm',
707
+ ctaPlacement: 'link_router',
708
+ eventType: 'cta_click',
709
+ defaults: {
710
+ utm_source: 'website',
711
+ utm_medium: 'link_router',
712
+ utm_campaign: 'npm_package',
713
+ },
714
+ },
715
+ marketplace: {
716
+ href: 'https://github.com/marketplace/actions/thumbgate-agent-governance',
717
+ external: true,
718
+ ctaId: 'go_marketplace',
719
+ ctaPlacement: 'link_router',
720
+ eventType: 'cta_click',
721
+ defaults: {
722
+ utm_source: 'website',
723
+ utm_medium: 'link_router',
724
+ utm_campaign: 'github_marketplace_action',
725
+ },
726
+ },
643
727
  });
644
728
 
645
729
  // ---------------------------------------------------------------------------
@@ -883,7 +967,7 @@ function hashDataIdentity(prefix, value) {
883
967
  }
884
968
 
885
969
  function buildHostedTenantIdentity(validation = {}) {
886
- if (!validation || validation.valid !== true || !validation.customerId) return null;
970
+ if (validation?.valid !== true || !validation.customerId) return null;
887
971
  const tenantSeed = validation.customerId;
888
972
  const principalSeed = validation.installId || tenantSeed;
889
973
  const tenantId = hashDataIdentity('tenant', tenantSeed);
@@ -1820,11 +1904,20 @@ function buildWorkflowIntakeQueue(parsed, workflowSprintIntake, feedbackDir, opt
1820
1904
  }
1821
1905
 
1822
1906
  function sendInvalidAnalyticsWindowProblem(res, title, err) {
1907
+ if (isDashboardDataLimitError(err)) {
1908
+ sendProblem(res, {
1909
+ type: PROBLEM_TYPES.INTERNAL,
1910
+ title: 'Dashboard data too large',
1911
+ status: 503,
1912
+ detail: formatDashboardLimitDetail(err, { phase: 'assembly' }),
1913
+ });
1914
+ return;
1915
+ }
1823
1916
  sendProblem(res, {
1824
1917
  type: PROBLEM_TYPES.INVALID_REQUEST,
1825
1918
  title,
1826
1919
  status: 400,
1827
- detail: err?.message ? err.message : 'Invalid analytics window request.',
1920
+ detail: err?.message || 'Invalid analytics window request.',
1828
1921
  });
1829
1922
  }
1830
1923
 
@@ -1851,19 +1944,103 @@ function resolveBillingSummaryOptionsOrRespondProblem(res, parsed, invalidTitle)
1851
1944
 
1852
1945
  async function buildLiveDashboardData(parsed, feedbackDir) {
1853
1946
  const summaryOptions = resolveBillingSummaryOptions(parsed);
1854
- const billingSummary = await getBillingSummaryLive(summaryOptions);
1947
+ // Stripe/live billing can hang longer than the authenticated deploy proof
1948
+ // timeout (12s). Prefer live, but fail closed to local summary so /v1/dashboard
1949
+ // remains usable for operational proof.
1950
+ const liveTimeoutMs = Number(process.env.THUMBGATE_DASHBOARD_BILLING_TIMEOUT_MS || 4000);
1951
+ let billingSummary;
1952
+ let billingSource = 'live';
1953
+ try {
1954
+ billingSummary = await Promise.race([
1955
+ getBillingSummaryLive(summaryOptions),
1956
+ new Promise((_, reject) => {
1957
+ const timer = setTimeout(() => reject(new Error('billing_summary_timeout')), liveTimeoutMs);
1958
+ if (typeof timer.unref === 'function') timer.unref();
1959
+ }),
1960
+ ]);
1961
+ } catch {
1962
+ billingSummary = getBillingSummary(summaryOptions);
1963
+ billingSource = 'local';
1964
+ }
1855
1965
  const data = generateDashboard(feedbackDir, {
1856
1966
  analyticsWindow: summaryOptions,
1857
1967
  billingSummary,
1858
- billingSource: 'live',
1968
+ billingSource,
1859
1969
  authContext: { tier: 'pro' },
1860
1970
  });
1861
1971
  return { summaryOptions, data };
1862
1972
  }
1863
1973
 
1974
+ const DEFAULT_DASHBOARD_CACHE_TTL_MS = 30_000;
1975
+ const DEFAULT_DASHBOARD_CACHE_MAX_ENTRIES = 32;
1976
+ const liveDashboardCache = new Map();
1977
+
1978
+ function parseNonNegativeInteger(value, fallback) {
1979
+ const parsed = Number(value);
1980
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
1981
+ }
1982
+
1983
+ function buildLiveDashboardCacheKey(parsed, feedbackDir) {
1984
+ const query = new URLSearchParams(parsed.searchParams);
1985
+ query.sort();
1986
+ return `${path.resolve(feedbackDir)}?${query.toString()}`;
1987
+ }
1988
+
1989
+ async function loadCachedLiveDashboardData(parsed, feedbackDir, options = {}) {
1990
+ const now = options.now || Date.now;
1991
+ const build = options.build || buildLiveDashboardData;
1992
+ const cache = options.cache || liveDashboardCache;
1993
+ const ttlMs = parseNonNegativeInteger(
1994
+ options.ttlMs ?? process.env.THUMBGATE_DASHBOARD_CACHE_TTL_MS,
1995
+ DEFAULT_DASHBOARD_CACHE_TTL_MS,
1996
+ );
1997
+ const maxEntries = parseNonNegativeInteger(
1998
+ options.maxEntries ?? process.env.THUMBGATE_DASHBOARD_CACHE_MAX_ENTRIES,
1999
+ DEFAULT_DASHBOARD_CACHE_MAX_ENTRIES,
2000
+ );
2001
+ const key = buildLiveDashboardCacheKey(parsed, feedbackDir);
2002
+ const currentTime = now();
2003
+ const cached = cache.get(key);
2004
+ if (cached && (cached.promise || cached.expiresAt > currentTime)) {
2005
+ return cached.promise || cached.value;
2006
+ }
2007
+
2008
+ if (maxEntries === 0) {
2009
+ return build(parsed, feedbackDir);
2010
+ }
2011
+
2012
+ if (cached) cache.delete(key);
2013
+ for (const [cacheKey, entry] of cache) {
2014
+ if (!entry.promise && entry.expiresAt <= currentTime) cache.delete(cacheKey);
2015
+ }
2016
+ while (cache.size >= maxEntries) {
2017
+ const evictableKey = [...cache].find(([, entry]) => !entry.promise)?.[0];
2018
+ if (!evictableKey) {
2019
+ const error = new Error('Live dashboard cache is at concurrent build capacity.');
2020
+ error.code = 'DASHBOARD_BUILD_CAPACITY';
2021
+ throw error;
2022
+ }
2023
+ cache.delete(evictableKey);
2024
+ }
2025
+
2026
+ const promise = Promise.resolve().then(() => build(parsed, feedbackDir));
2027
+ cache.set(key, { promise, expiresAt: 0, value: null });
2028
+ try {
2029
+ const value = await promise;
2030
+ cache.set(key, { promise: null, expiresAt: now() + ttlMs, value });
2031
+ while (cache.size > maxEntries && cache.size > 0) {
2032
+ cache.delete(cache.keys().next().value);
2033
+ }
2034
+ return value;
2035
+ } catch (error) {
2036
+ cache.delete(key);
2037
+ throw error;
2038
+ }
2039
+ }
2040
+
1864
2041
  async function loadLiveDashboardDataOrRespondProblem(res, parsed, feedbackDir, invalidTitle) {
1865
2042
  try {
1866
- return await buildLiveDashboardData(parsed, feedbackDir);
2043
+ return await loadCachedLiveDashboardData(parsed, feedbackDir);
1867
2044
  } catch (err) {
1868
2045
  sendInvalidAnalyticsWindowProblem(res, invalidTitle, err);
1869
2046
  return null;
@@ -1980,14 +2157,22 @@ function readRecentFeedbackEntries(feedbackDir, signal, windowMs, limit = 5, opt
1980
2157
  try {
1981
2158
  if (!feedbackDir) return [];
1982
2159
  const rows = shouldAggregateFeedback()
1983
- ? collectAggregateLogEntries('feedback-log.jsonl', { feedbackDir }).entries
2160
+ ? collectAggregateLogEntries('feedback-log.jsonl', {
2161
+ feedbackDir,
2162
+ maxLines: 20_000,
2163
+ maxBytes: 4 * 1024 * 1024,
2164
+ }).entries
1984
2165
  : (() => {
1985
2166
  const fsLocal = require('node:fs');
1986
2167
  const pathLocal = require('node:path');
1987
2168
  const logPath = pathLocal.join(feedbackDir, 'feedback-log.jsonl');
1988
2169
  if (!fsLocal.existsSync(logPath)) return [];
1989
2170
  const { readJsonl } = require('../../scripts/fs-utils');
1990
- return readJsonl(logPath) || [];
2171
+ return readJsonl(logPath, {
2172
+ maxLines: 20_000,
2173
+ maxBytes: 4 * 1024 * 1024,
2174
+ tail: true,
2175
+ }) || [];
1991
2176
  })();
1992
2177
  const cutoff = windowMs ? Date.now() - windowMs : 0;
1993
2178
  const filtered = rows
@@ -2775,32 +2960,37 @@ function getTrackedLinkTarget(slug) {
2775
2960
  : null;
2776
2961
  }
2777
2962
 
2778
- function appendTrackedLinkQueryParams(destinationUrl, parsed, target) {
2779
- const params = parsed.searchParams;
2963
+ function applyTrackedLinkDefaults(destinationUrl, target) {
2780
2964
  for (const [key, value] of Object.entries(target.defaults || {})) {
2781
2965
  if (!destinationUrl.searchParams.has(key)) {
2782
2966
  appendQueryParam(destinationUrl, key, value);
2783
2967
  }
2784
2968
  }
2969
+ }
2970
+
2971
+ function applyTrackedLinkQueryKeys(destinationUrl, params) {
2785
2972
  for (const key of TRACKED_LINK_QUERY_KEYS) {
2786
2973
  const value = params.get(key);
2787
- if (value && value.trim()) {
2788
- const normalizedValue = key === 'utm_campaign'
2789
- ? normalizeCampaignId(value)
2790
- : value.trim();
2791
- destinationUrl.searchParams.set(key, normalizedValue);
2792
- }
2793
- }
2794
- if (target.allowCustomerEmail) {
2795
- const customerEmail = normalizeCheckoutCustomerEmail(params.get('customer_email'));
2796
- if (customerEmail) {
2797
- appendQueryParam(
2798
- destinationUrl,
2799
- target.prefillStripeEmail ? 'prefilled_email' : 'customer_email',
2800
- customerEmail
2801
- );
2802
- }
2974
+ if (!value?.trim()) continue;
2975
+ const normalizedValue = key === 'utm_campaign'
2976
+ ? normalizeCampaignId(value)
2977
+ : value.trim();
2978
+ destinationUrl.searchParams.set(key, normalizedValue);
2803
2979
  }
2980
+ }
2981
+
2982
+ function applyTrackedLinkCustomerEmail(destinationUrl, params, target) {
2983
+ if (!target.allowCustomerEmail) return;
2984
+ const customerEmail = normalizeCheckoutCustomerEmail(params.get('customer_email'));
2985
+ if (!customerEmail) return;
2986
+ appendQueryParam(
2987
+ destinationUrl,
2988
+ target.prefillStripeEmail ? 'prefilled_email' : 'customer_email',
2989
+ customerEmail,
2990
+ );
2991
+ }
2992
+
2993
+ function applyTrackedLinkCtaDefaults(destinationUrl, target) {
2804
2994
  if (!destinationUrl.searchParams.has('cta_id')) {
2805
2995
  appendQueryParam(destinationUrl, 'cta_id', target.ctaId);
2806
2996
  }
@@ -2812,6 +3002,14 @@ function appendTrackedLinkQueryParams(destinationUrl, parsed, target) {
2812
3002
  }
2813
3003
  }
2814
3004
 
3005
+ function appendTrackedLinkQueryParams(destinationUrl, parsed, target) {
3006
+ const params = parsed.searchParams;
3007
+ applyTrackedLinkDefaults(destinationUrl, target);
3008
+ applyTrackedLinkQueryKeys(destinationUrl, params);
3009
+ applyTrackedLinkCustomerEmail(destinationUrl, params, target);
3010
+ applyTrackedLinkCtaDefaults(destinationUrl, target);
3011
+ }
3012
+
2815
3013
  function buildTrackedLinkDestination(target, hostedConfig, parsed) {
2816
3014
  const configuredHref = target.configUrlKey ? hostedConfig[target.configUrlKey] : null;
2817
3015
  const href = target.href || configuredHref || target.fallbackHref;
@@ -3006,7 +3204,21 @@ function resolveCheckoutOfferSummary(metadata = {}) {
3006
3204
 
3007
3205
  function sendJson(res, statusCode, payload, extraHeaders = {}, options = {}) {
3008
3206
  const { headOnly = false } = options;
3009
- const body = JSON.stringify(payload);
3207
+ let body;
3208
+ try {
3209
+ body = JSON.stringify(payload);
3210
+ } catch (err) {
3211
+ if (isDashboardDataLimitError(err)) {
3212
+ sendProblem(res, {
3213
+ type: PROBLEM_TYPES.INTERNAL,
3214
+ title: 'Dashboard response too large',
3215
+ status: 503,
3216
+ detail: formatDashboardLimitDetail(err, { phase: 'stringify' }),
3217
+ });
3218
+ return;
3219
+ }
3220
+ throw err;
3221
+ }
3010
3222
  res.writeHead(statusCode, {
3011
3223
  'Content-Type': 'application/json; charset=utf-8',
3012
3224
  'X-Content-Type-Options': 'nosniff',
@@ -3060,8 +3272,8 @@ function sendPublicBillingPreflight(res) {
3060
3272
 
3061
3273
  function appendBestEffortTelemetry(feedbackDir, payload, headers, context) {
3062
3274
  try {
3063
- appendTelemetryPing(feedbackDir, payload, headers);
3064
- return true;
3275
+ const written = appendTelemetryPing(feedbackDir, payload, headers);
3276
+ return Boolean(written);
3065
3277
  } catch (err) {
3066
3278
  try {
3067
3279
  appendDiagnosticRecord({
@@ -3248,7 +3460,8 @@ function loadPublicMarketingTemplateHtml(templatePath, runtimeConfig, pageContex
3248
3460
  const googleSiteVerificationMeta = runtimeConfig.googleSiteVerification
3249
3461
  ? ` <meta name="google-site-verification" content="${escapeHtmlAttribute(runtimeConfig.googleSiteVerification)}" />`
3250
3462
  : '';
3251
- const gaBootstrap = runtimeConfig.gaMeasurementId
3463
+ const privacyOptOut = Boolean(pageContext.privacyOptOut);
3464
+ const gaBootstrap = (!privacyOptOut && runtimeConfig.gaMeasurementId)
3252
3465
  ? [
3253
3466
  ` <script async src="https://www.googletagmanager.com/gtag/js?id=${runtimeConfig.gaMeasurementId}"></script>`,
3254
3467
  ' <script>',
@@ -3259,7 +3472,7 @@ function loadPublicMarketingTemplateHtml(templatePath, runtimeConfig, pageContex
3259
3472
  ' </script>',
3260
3473
  ].join('\n')
3261
3474
  : '';
3262
- return normalizePublicMarketingHtml(fillTemplate(template, {
3475
+ let rendered = fillTemplate(template, {
3263
3476
  '__PACKAGE_VERSION__': pkg.version,
3264
3477
  '__APP_ORIGIN__': runtimeConfig.appOrigin,
3265
3478
  '__CHECKOUT_ENDPOINT__': runtimeConfig.checkoutEndpoint,
@@ -3288,7 +3501,34 @@ function loadPublicMarketingTemplateHtml(templatePath, runtimeConfig, pageContex
3288
3501
  '__GTM_PLAN_URL__': 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/GO_TO_MARKET_REVENUE_WEDGE_2026-03.md',
3289
3502
  '__GITHUB_URL__': 'https://github.com/IgorGanapolsky/ThumbGate',
3290
3503
  '__POSTHOG_API_KEY__': runtimeConfig.posthogApiKey || '',
3291
- }), runtimeConfig, pageContext.requestHost);
3504
+ });
3505
+ const normalized = normalizePublicMarketingHtml(rendered, runtimeConfig, pageContext.requestHost);
3506
+ if (!privacyOptOut) return normalized;
3507
+ let rewrittenDomain = '';
3508
+ try {
3509
+ rewrittenDomain = normalizePublicRequestHost(pageContext.requestHost)
3510
+ || new URL(runtimeConfig.appOrigin).host;
3511
+ } catch {
3512
+ rewrittenDomain = '';
3513
+ }
3514
+ return removeKnownPlausibleScriptTags(normalized, rewrittenDomain);
3515
+ }
3516
+
3517
+ function removeKnownPlausibleScriptTags(html, extraDomain) {
3518
+ const domains = ['thumbgate.ai', 'thumbgate-production.up.railway.app'];
3519
+ if (extraDomain) domains.push(extraDomain);
3520
+ const sources = [
3521
+ 'https://plausible.io/js/script.tagged-events.js',
3522
+ 'https://plausible.io/js/script.js',
3523
+ ];
3524
+ let out = html;
3525
+ for (const domain of domains) {
3526
+ for (const src of sources) {
3527
+ const tag = `<script defer data-domain="${domain}" src="${src}"></script>`;
3528
+ if (out.includes(tag)) out = out.split(tag).join('');
3529
+ }
3530
+ }
3531
+ return out;
3292
3532
  }
3293
3533
 
3294
3534
  function loadLandingPageHtml(runtimeConfig, pageContext = {}) {
@@ -3303,10 +3543,18 @@ function loadDiagnosticPageHtml(runtimeConfig, pageContext = {}) {
3303
3543
  return loadPublicMarketingTemplateHtml(DIAGNOSTIC_PAGE_PATH, runtimeConfig, pageContext);
3304
3544
  }
3305
3545
 
3546
+ function loadFoundersPageHtml(runtimeConfig, pageContext = {}) {
3547
+ return loadPublicMarketingTemplateHtml(FOUNDERS_PAGE_PATH, runtimeConfig, pageContext);
3548
+ }
3549
+
3306
3550
  function loadPartnerIntakePageHtml(runtimeConfig, pageContext = {}) {
3307
3551
  return loadPublicMarketingTemplateHtml(PARTNER_INTAKE_PAGE_PATH, runtimeConfig, pageContext);
3308
3552
  }
3309
3553
 
3554
+ function loadPeterPageHtml(runtimeConfig, pageContext = {}) {
3555
+ return loadPublicMarketingTemplateHtml(PETER_PAGE_PATH, runtimeConfig, pageContext);
3556
+ }
3557
+
3310
3558
  function loadInstallPageHtml(runtimeConfig, pageContext = {}) {
3311
3559
  return loadPublicMarketingTemplateHtml(INSTALL_PAGE_PATH, runtimeConfig, pageContext);
3312
3560
  }
@@ -3945,8 +4193,20 @@ function renderSitemapXml(runtimeConfig) {
3945
4193
  { path: '/', changefreq: 'weekly', priority: '1.0' },
3946
4194
  { path: '/pro', changefreq: 'weekly', priority: '0.9' },
3947
4195
  { path: '/diagnostic', changefreq: 'weekly', priority: '0.9' },
4196
+ { path: '/founders', changefreq: 'weekly', priority: '0.95' },
4197
+ { path: '/peter', changefreq: 'weekly', priority: '0.9' },
3948
4198
  { path: '/workflow-hardening-sprint', changefreq: 'weekly', priority: '0.9' },
3949
4199
  { path: '/install', changefreq: 'weekly', priority: '0.9' },
4200
+ { path: '/terms', changefreq: 'monthly', priority: '0.7' },
4201
+ { path: '/privacy', changefreq: 'monthly', priority: '0.7' },
4202
+ { path: '/support', changefreq: 'monthly', priority: '0.7' },
4203
+ { path: '/legal', changefreq: 'monthly', priority: '0.7' },
4204
+ { path: '/security', changefreq: 'monthly', priority: '0.65' },
4205
+ { path: '/trust', changefreq: 'monthly', priority: '0.65' },
4206
+ { path: '/security.json', changefreq: 'monthly', priority: '0.55' },
4207
+ { path: '/legal/data-flow', changefreq: 'monthly', priority: '0.7' },
4208
+ { path: '/legal/licensing', changefreq: 'monthly', priority: '0.65' },
4209
+ { path: '/legal/msa-sow', changefreq: 'monthly', priority: '0.6' },
3950
4210
  { path: '/agent-manager', changefreq: 'weekly', priority: '0.9' },
3951
4211
  { path: '/llm-context.md', changefreq: 'weekly', priority: '0.8' },
3952
4212
  { path: '/chatgpt-app', changefreq: 'weekly', priority: '0.85' },
@@ -3960,6 +4220,7 @@ function renderSitemapXml(runtimeConfig) {
3960
4220
  { path: '/evaluations', changefreq: 'weekly', priority: '0.85' },
3961
4221
  { path: '/case-studies', changefreq: 'weekly', priority: '0.9' },
3962
4222
  { path: '/numbers', changefreq: 'weekly', priority: '0.8' },
4223
+ { path: '/yt', changefreq: 'weekly', priority: '0.8' },
3963
4224
  { path: '/learn/background-agent-control-layer', changefreq: 'weekly', priority: '0.85' },
3964
4225
  { path: '/learn/ac-dc-runtime-enforcement', changefreq: 'weekly', priority: '0.85' },
3965
4226
  { path: '/learn/feedback-loop-vs-decision-layer', changefreq: 'weekly', priority: '0.9' },
@@ -3969,10 +4230,14 @@ function renderSitemapXml(runtimeConfig) {
3969
4230
  { path: '/learn/agentic-os-team-governance', changefreq: 'weekly', priority: '0.85' },
3970
4231
  { path: '/learn/cost-aware-agent-gate-routing', changefreq: 'weekly', priority: '0.85' },
3971
4232
  { path: '/learn/pretix-stripe-connect-marketplaces', changefreq: 'weekly', priority: '0.9' },
4233
+ { path: '/learn/netflix-oci-actor-critic-process-audits', changefreq: 'weekly', priority: '0.85' },
4234
+ { path: '/learn/future-agi-self-healing-agent-firewall', changefreq: 'weekly', priority: '0.85' },
4235
+ { path: '/learn/five-walls-enterprise-agent-deployment', changefreq: 'weekly', priority: '0.85' },
3972
4236
  { path: '/compare/claude-code-hooks', changefreq: 'weekly', priority: '0.85' },
3973
4237
  { path: '/compare/bumblebee', changefreq: 'weekly', priority: '0.85' },
3974
4238
  { path: '/compare/anthropic-containment', changefreq: 'weekly', priority: '0.85' },
3975
4239
  { path: '/compare/oak-and-sparrow-gatekeeper', changefreq: 'weekly', priority: '0.85' },
4240
+ { path: '/compare/cloudflare-writeguard', changefreq: 'weekly', priority: '0.85' },
3976
4241
  { path: '/compare/arcjet', changefreq: 'weekly', priority: '0.85' },
3977
4242
  { path: '/compare/anthropic-claude-for-legal', changefreq: 'weekly', priority: '0.9' },
3978
4243
  ...THUMBGATE_SEO_SITEMAP_ENTRIES,
@@ -4141,6 +4406,7 @@ function servePublicMarketingPage({
4141
4406
  serverUtmCampaign: landingAttribution.utmCampaign,
4142
4407
  serverUtmContent: landingAttribution.utmContent,
4143
4408
  requestHost,
4409
+ privacyOptOut: headerHasPrivacyOptOut(req.headers),
4144
4410
  });
4145
4411
 
4146
4412
  sendHtml(
@@ -5131,6 +5397,7 @@ function authenticateHumanReviewer(req, reviewerConfig) {
5131
5397
  return {
5132
5398
  id: reviewerConfig.id,
5133
5399
  kind: 'human',
5400
+ role: 'admin',
5134
5401
  };
5135
5402
  }
5136
5403
 
@@ -5169,6 +5436,23 @@ function isBillingSummaryAuthorized(req, expectedAdminKey, expectedOperatorKey)
5169
5436
  return false;
5170
5437
  }
5171
5438
 
5439
+ /** Read-only paths the operator key may access (CLI dashboard / CFO / north-star). */
5440
+ const OPERATOR_READONLY_GET_PATHS = new Set([
5441
+ '/v1/billing/summary',
5442
+ '/v1/dashboard',
5443
+ '/v1/dashboard/render-spec',
5444
+ '/v1/dashboard/ai-inventory',
5445
+ '/v1/dashboard/review-state',
5446
+ '/v1/intake/workflow-sprint/queue',
5447
+ '/v1/task-outcomes/monitor',
5448
+ ]);
5449
+
5450
+ function isOperatorReadonlyGet(req, expectedOperatorKey, pathname) {
5451
+ if (!expectedOperatorKey || req.method !== 'GET') return false;
5452
+ if (!OPERATOR_READONLY_GET_PATHS.has(pathname)) return false;
5453
+ return safeKeyEqual(extractApiKey(req), expectedOperatorKey);
5454
+ }
5455
+
5172
5456
  function extractTags(input) {
5173
5457
  if (Array.isArray(input)) return input;
5174
5458
  if (typeof input === 'string') {
@@ -5412,13 +5696,22 @@ function createApiServer() {
5412
5696
  ? mcpOauth.tokenAudienceValid(oauthSession, resourceUrl)
5413
5697
  : rawKeyValid;
5414
5698
  if (!authed) {
5699
+ const deniedName = msg.params?.name || 'unknown';
5700
+ const deniedTool = MCP_TOOLS.find((t) => t.name === deniedName);
5415
5701
  recordToolCall({
5416
- toolName: msg.params?.name || 'unknown',
5702
+ toolName: deniedName,
5417
5703
  serverName: 'mcp-http',
5418
5704
  latencyMs: 0,
5419
5705
  success: false,
5706
+ outcome: 'blocked',
5707
+ blocked: true,
5420
5708
  agentId: 'unauthenticated',
5421
- metadata: { denied: true, deniedReason: 'authentication_required' },
5709
+ writeRiskTier: deniedTool?.annotations?.writeRiskTier || null,
5710
+ metadata: {
5711
+ denied: true,
5712
+ deniedReason: 'authentication_required',
5713
+ writeRiskTier: deniedTool?.annotations?.writeRiskTier || null,
5714
+ },
5422
5715
  });
5423
5716
  res.writeHead(401, {
5424
5717
  'Content-Type': 'application/json',
@@ -5447,8 +5740,15 @@ function createApiServer() {
5447
5740
  serverName: 'mcp-http',
5448
5741
  latencyMs: 0,
5449
5742
  success: false,
5743
+ outcome: 'blocked',
5744
+ blocked: true,
5450
5745
  agentId: 'reviewer',
5451
- metadata: { denied: true, deniedReason: 'reviewer_read_only' },
5746
+ writeRiskTier: tool?.annotations?.writeRiskTier || null,
5747
+ metadata: {
5748
+ denied: true,
5749
+ deniedReason: 'reviewer_read_only',
5750
+ writeRiskTier: tool?.annotations?.writeRiskTier || null,
5751
+ },
5452
5752
  });
5453
5753
  sendJson(res, 200, {
5454
5754
  jsonrpc: '2.0', id: msg.id,
@@ -5460,18 +5760,25 @@ function createApiServer() {
5460
5760
  if (oauthSession) {
5461
5761
  const name = msg.params && msg.params.name;
5462
5762
  const tool = MCP_TOOLS.find((candidate) => candidate.name === name);
5463
- const requiredScope = tool?.annotations?.readOnlyHint === true ? 'mcp:read' : 'mcp:write';
5763
+ // Hierarchy: mcp:write implies read/gates/feedback (WorkOS MCP Auth style).
5764
+ const requiredScope = mcpOauth.requiredScopeForTool(tool || {});
5464
5765
  if (!mcpOauth.scopeAllows(oauthSession, requiredScope)) {
5465
5766
  recordToolCall({
5466
5767
  toolName: name || 'unknown',
5467
5768
  serverName: 'mcp-http',
5468
5769
  latencyMs: 0,
5469
5770
  success: false,
5771
+ outcome: 'blocked',
5772
+ blocked: true,
5470
5773
  agentId: oauthSession.clientId || 'oauth-client',
5774
+ clientId: oauthSession.clientId || null,
5775
+ sessionId: oauthSession.sessionId || oauthSession.id || null,
5776
+ writeRiskTier: tool?.annotations?.writeRiskTier || null,
5471
5777
  metadata: {
5472
5778
  denied: true,
5473
5779
  deniedReason: 'insufficient_scope',
5474
5780
  requiredScope,
5781
+ writeRiskTier: tool?.annotations?.writeRiskTier || null,
5475
5782
  },
5476
5783
  });
5477
5784
  sendJson(res, 200, {
@@ -5486,7 +5793,14 @@ function createApiServer() {
5486
5793
  const { callTool } = require('../../adapters/mcp/server-stdio');
5487
5794
  const name = msg.params?.name;
5488
5795
  const args = (msg.params && msg.params.arguments) || {};
5489
- const result = await callTool(name, args);
5796
+ // Attribution rides as callTool options (not tool args) so KPI
5797
+ // can bind human OAuth client/session without polluting schemas.
5798
+ const result = await callTool(name, args, {
5799
+ clientId: oauthSession?.clientId || null,
5800
+ sessionId: oauthSession?.sessionId || oauthSession?.id || null,
5801
+ agentId: oauthSession?.clientId || (isReviewer ? 'reviewer' : 'mcp-http'),
5802
+ serverName: 'mcp-http',
5803
+ });
5490
5804
  sendJson(res, 200, {
5491
5805
  jsonrpc: '2.0', id: msg.id,
5492
5806
  result,
@@ -5569,6 +5883,27 @@ function createApiServer() {
5569
5883
  return;
5570
5884
  }
5571
5885
 
5886
+ // WebMCP read-only agent tools for the product pages. Absent from the
5887
+ // packaged npm runtime by design (tarball exclusion) — the route then
5888
+ // 404s and the deferred script tag degrades to a no-op.
5889
+ if (isGetLikeRequest && pathname === '/js/webmcp.js') {
5890
+ try {
5891
+ const script = fs.readFileSync(WEBMCP_SCRIPT_PATH, 'utf-8');
5892
+ res.writeHead(200, {
5893
+ 'Content-Type': 'application/javascript; charset=utf-8',
5894
+ 'Cache-Control': 'public, max-age=86400',
5895
+ });
5896
+ if (!isHeadRequest) {
5897
+ res.end(script);
5898
+ } else {
5899
+ res.end();
5900
+ }
5901
+ } catch {
5902
+ sendJson(res, 404, { error: 'WebMCP script not found' });
5903
+ }
5904
+ return;
5905
+ }
5906
+
5572
5907
 
5573
5908
  // User feedback → GitHub Issues
5574
5909
  if (req.method === 'POST' && pathname === '/api/feedback/submit') {
@@ -6336,6 +6671,57 @@ async function addContext(){
6336
6671
  return;
6337
6672
  }
6338
6673
 
6674
+
6675
+ // Partner co-branded case study (Peter Yang / high-ROI acquisition landing).
6676
+ if (isGetLikeRequest && (pathname === '/peter' || pathname === '/peter.html')) {
6677
+ try {
6678
+ servePublicMarketingPage({
6679
+ req,
6680
+ res,
6681
+ parsed,
6682
+ hostedConfig,
6683
+ isHeadRequest,
6684
+ renderHtml: loadPeterPageHtml,
6685
+ extraTelemetry: {
6686
+ pageType: 'partner_case_study',
6687
+ partner: 'peter-yang',
6688
+ },
6689
+ });
6690
+ } catch (err) {
6691
+ sendText(res, 500, err.message || 'Partner case study page unavailable');
6692
+ }
6693
+ return;
6694
+ }
6695
+
6696
+ // Oceans-style founder conversion landing (high-ROI cash path).
6697
+ // Mirrors personal operator pages (/peter): pain triad, timed process,
6698
+ // comparison table, refund fence, sticky diagnostic checkout.
6699
+ if (isGetLikeRequest && (
6700
+ pathname === '/founders'
6701
+ || pathname === '/founders.html'
6702
+ || pathname === '/operators'
6703
+ || pathname === '/operators.html'
6704
+ || pathname === '/agent-operators'
6705
+ || pathname === '/agent-operators.html'
6706
+ )) {
6707
+ try {
6708
+ servePublicMarketingPage({
6709
+ req,
6710
+ res,
6711
+ parsed,
6712
+ hostedConfig,
6713
+ isHeadRequest,
6714
+ renderHtml: loadFoundersPageHtml,
6715
+ extraTelemetry: {
6716
+ pageType: 'founders',
6717
+ },
6718
+ });
6719
+ } catch (err) {
6720
+ sendText(res, 500, err.message || 'Founders page unavailable');
6721
+ }
6722
+ return;
6723
+ }
6724
+
6339
6725
  // Natural marketing URLs for the Workflow Hardening Diagnostic/Sprint.
6340
6726
  // High-intent outreach refers to "the diagnostic", "the sprint", or
6341
6727
  // "workflow hardening"; serve a focused buyer page instead of sending
@@ -6482,6 +6868,35 @@ async function addContext(){
6482
6868
  return;
6483
6869
  }
6484
6870
 
6871
+ if (isGetLikeRequest && (
6872
+ pathname === '/yt'
6873
+ || pathname === '/yt.html'
6874
+ || pathname === '/aias-registration-yt'
6875
+ )) {
6876
+ // Dedicated YouTube/CPC destination (FORMAT steal from AIAS registration
6877
+ // landings). /aias-registration-yt is an alias so ad paths that copy the
6878
+ // summit slug still hit ThumbGate. Canonical is /yt. Free GitHub/npm/
6879
+ // Marketplace CTAs only — not a $499 hero, not a 6-agent OS SKU.
6880
+ try {
6881
+ servePublicMarketingPage({
6882
+ req,
6883
+ res,
6884
+ parsed,
6885
+ hostedConfig,
6886
+ isHeadRequest,
6887
+ renderHtml: (runtimeConfig, pageContext) => loadPublicMarketingTemplateHtml(
6888
+ YT_PAGE_PATH,
6889
+ runtimeConfig,
6890
+ pageContext
6891
+ ),
6892
+ extraTelemetry: { pageType: 'yt' },
6893
+ });
6894
+ } catch {
6895
+ sendJson(res, 404, { error: 'YouTube landing page not found' });
6896
+ }
6897
+ return;
6898
+ }
6899
+
6485
6900
  if (isGetLikeRequest && (pathname === '/agent-manager' || pathname === '/agent-manager.html')) {
6486
6901
  // ICP landing page for the role Anthropic named (Agent Manager —
6487
6902
  // hybrid PM/engineer DRI who owns CLAUDE.md hierarchy, plugin
@@ -8306,35 +8721,168 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
8306
8721
  // Public terms of service — required by Stripe / Stripe Checkout for the
8307
8722
  // "Terms of service URL" field in Business → Public details. Mirrors the
8308
8723
  // /privacy + /support pages: thin HTML, no external deps, no DB hit.
8309
- if (isGetLikeRequest && pathname === '/terms') {
8310
- sendHtml(res, 200, `<!DOCTYPE html><html><head><title>Terms of Service — ThumbGate</title></head><body>
8311
- <h1>Terms of Service</h1>
8312
- <p><strong>ThumbGate</strong> (npm: thumbgate)</p>
8313
- <p>Last updated: 2026-05-12</p>
8314
- <h2>The Service</h2>
8315
- <p>ThumbGate provides pre-action gates for AI coding agents: a local CLI (MIT-licensed) and an optional hosted tier at thumbgate-production.up.railway.app. By installing the CLI or paying for a subscription, you agree to these terms.</p>
8316
- <h2>Payment</h2>
8317
- <p>Paid tiers are billed through Stripe. Subscriptions auto-renew until cancelled. One-off purchases (Sprint Diagnostic, Workflow Sprint, Quick Read, Workflow Teardown, First Failure Rule) are charged once.</p>
8318
- <h2>Refunds</h2>
8319
- <p>Pro and Team subscriptions: cancel anytime; we issue a full refund within 7 days of the first charge, prorated thereafter. One-off purchases: refund on request if we cannot deliver the scoped artifact.</p>
8320
- <h2>Acceptable Use</h2>
8321
- <p>You may not use ThumbGate to (a) circumvent the safety controls of other AI providers, (b) generate malware or content that violates third-party terms, or (c) resell the hosted tier without written permission.</p>
8322
- <h2>Disclaimer of Warranty</h2>
8323
- <p>The service is provided "as is", without warranty of any kind. ThumbGate is a guard rail, not a guarantee. We do not warrant that every AI-agent mistake will be prevented.</p>
8324
- <h2>Limitation of Liability</h2>
8325
- <p>Total liability for any claim is limited to the amount you paid in the 12 months preceding the claim.</p>
8326
- <h2>Governing Law</h2>
8327
- <p>These terms are governed by the laws of the State of New York, United States.</p>
8328
- <h2>Changes</h2>
8329
- <p>We may update these terms; material changes will be announced via the email on file at least 14 days before they take effect.</p>
8330
- <h2>Contact</h2><p>igor.ganapolsky@gmail.com</p>
8331
- <p><a href="https://github.com/IgorGanapolsky/ThumbGate">GitHub</a> · <a href="/privacy">Privacy</a> · <a href="/support">Support</a></p>
8332
- </body></html>`, {}, {
8333
- headOnly: isHeadRequest,
8334
- });
8724
+ if (isGetLikeRequest && (pathname === '/terms' || pathname === '/terms.html' || pathname === '/eula' || pathname === '/eula.html')) {
8725
+ try {
8726
+ const termsHtml = fs.readFileSync(path.join(PUBLIC_DIR, 'terms.html'), 'utf-8');
8727
+ sendHtml(res, 200, termsHtml, {}, { headOnly: isHeadRequest });
8728
+ } catch {
8729
+ sendHtml(res, 200, `<!DOCTYPE html><html><head><title>Terms of Service — ThumbGate</title></head><body><h1>Terms of Service</h1><p>Contact: <a href="mailto:legal@thumbgate.ai">legal@thumbgate.ai</a></p></body></html>`, {}, { headOnly: isHeadRequest });
8730
+ }
8731
+ return;
8732
+ }
8733
+
8734
+ if (isGetLikeRequest && (pathname === '/privacy' || pathname === '/privacy.html')) {
8735
+ try {
8736
+ const privacyHtml = fs.readFileSync(path.join(PUBLIC_DIR, 'privacy.html'), 'utf-8');
8737
+ sendHtml(res, 200, privacyHtml, {}, { headOnly: isHeadRequest });
8738
+ } catch {
8739
+ sendHtml(res, 200, `<!DOCTYPE html><html><head><title>Privacy Policy — ThumbGate</title></head><body><h1>Privacy Policy</h1><p>Contact: <a href="mailto:privacy@thumbgate.ai">privacy@thumbgate.ai</a></p></body></html>`, {}, { headOnly: isHeadRequest });
8740
+ }
8741
+ return;
8742
+ }
8743
+
8744
+ if (isGetLikeRequest && (pathname === '/support' || pathname === '/support.html')) {
8745
+ try {
8746
+ const supportHtml = fs.readFileSync(path.join(PUBLIC_DIR, 'support.html'), 'utf-8');
8747
+ sendHtml(res, 200, supportHtml, {}, { headOnly: isHeadRequest });
8748
+ } catch {
8749
+ sendHtml(res, 200, `<!DOCTYPE html><html><head><title>Support — ThumbGate</title></head><body><h1>Support</h1><p>Contact: <a href="mailto:support@thumbgate.ai">support@thumbgate.ai</a></p></body></html>`, {}, { headOnly: isHeadRequest });
8750
+ }
8751
+ return;
8752
+ }
8753
+
8754
+ if (isGetLikeRequest && (pathname === '/third-party-notices' || pathname === '/third-party-notices.html' || pathname === '/third-party-notices.md')) {
8755
+ try {
8756
+ const noticesHtml = fs.readFileSync(path.join(PUBLIC_DIR, 'third-party-notices.html'), 'utf-8');
8757
+ sendHtml(res, 200, noticesHtml, {}, { headOnly: isHeadRequest });
8758
+ } catch {
8759
+ sendHtml(res, 200, `<!DOCTYPE html><html><head><title>Third-Party Notices — ThumbGate</title></head><body><h1>Third-Party Notices</h1><p>See <a href="https://github.com/IgorGanapolsky/ThumbGate/blob/main/THIRD_PARTY_NOTICES.md">THIRD_PARTY_NOTICES.md</a>.</p></body></html>`, {}, { headOnly: isHeadRequest });
8760
+ }
8761
+ return;
8762
+ }
8763
+
8764
+ // Public security overview + copy-paste questionnaire (not a certification).
8765
+ if (isGetLikeRequest && (pathname === '/security.json' || pathname === '/v1/security-questionnaire')) {
8766
+ sendJson(res, 200, getSecurityQuestionnaire(), {
8767
+ 'Cache-Control': 'public, max-age=300',
8768
+ }, { headOnly: isHeadRequest });
8769
+ return;
8770
+ }
8771
+ if (isGetLikeRequest && (
8772
+ pathname === '/security'
8773
+ || pathname === '/security.html'
8774
+ || pathname === '/trust'
8775
+ || pathname === '/trust.html'
8776
+ )) {
8777
+ sendHtml(res, 200, renderSecurityOverviewHtml(), {}, { headOnly: isHeadRequest });
8778
+ return;
8779
+ }
8780
+
8781
+ // Legal index — entry point for counsel package + public legal URLs.
8782
+ if (isGetLikeRequest && (pathname === '/legal' || pathname === '/legal/')) {
8783
+ sendHtml(res, 200, `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
8784
+ <title>Legal — ThumbGate</title>
8785
+ <meta name="description" content="ThumbGate legal index: Terms, Privacy, Security, licensing, data-flow, MSA/SOW.">
8786
+ <style>body{font-family:system-ui,-apple-system,sans-serif;max-width:820px;margin:0 auto;padding:32px 20px;line-height:1.55;color:#1f2937}
8787
+ h1{font-size:28px;margin:0 0 8px}h2{font-size:18px;margin:28px 0 8px}p,li{font-size:15px}.meta{color:#6b7280;font-size:14px;margin:0 0 24px}
8788
+ .draft{border-left:3px solid #f59e0b;background:#fffbeb;padding:10px 14px;border-radius:0 8px 8px 0;margin:16px 0}
8789
+ ul{padding-left:22px}li{margin:6px 0}a{color:#0066cc}
8790
+ footer{margin-top:40px;padding-top:20px;border-top:1px solid #e5e7eb;color:#6b7280;font-size:14px}</style></head><body>
8791
+ <h1>Legal</h1>
8792
+ <p class="meta">Public legal surfaces for ThumbGate paid and hosted paths.</p>
8793
+ <div class="draft"><strong>Highest-value first pass:</strong> short data-flow map + subscription terms + privacy policy + open-source boundary. Markdown counsel package lives in <code>docs/legal/</code>.</div>
8794
+ <ul>
8795
+ <li><a href="/terms">Terms of Service</a></li>
8796
+ <li><a href="/privacy">Privacy Policy</a></li>
8797
+ <li><a href="/support">Support</a> — <a href="mailto:support@thumbgate.ai">support@thumbgate.ai</a></li>
8798
+ <li><a href="/security">Security questionnaire</a> — <a href="/security.json">JSON</a></li>
8799
+ <li><a href="/legal/licensing">MIT vs paid licensing boundary</a></li>
8800
+ <li><a href="/legal/data-flow">Data-flow map</a></li>
8801
+ <li><a href="/legal/msa-sow">MSA / SOW template</a></li>
8802
+ <li><a href="https://github.com/IgorGanapolsky/ThumbGate/tree/main/docs/legal">docs/legal on GitHub</a></li>
8803
+ <li><a href="/third-party-notices">Third-party notices</a></li>
8804
+ </ul>
8805
+ <footer><a href="/">Home</a> · <a href="/pricing">Pricing</a> · <a href="mailto:legal@thumbgate.ai">legal@thumbgate.ai</a></footer>
8806
+ </body></html>`, {}, { headOnly: isHeadRequest });
8335
8807
  return;
8336
8808
  }
8337
8809
 
8810
+ if (isGetLikeRequest && (pathname === '/legal/licensing' || pathname === '/legal/licensing/')) {
8811
+ sendHtml(res, 200, `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
8812
+ <title>Licensing — ThumbGate</title>
8813
+ <style>body{font-family:system-ui,-apple-system,sans-serif;max-width:820px;margin:0 auto;padding:32px 20px;line-height:1.55;color:#1f2937}
8814
+ h1{font-size:28px;margin:0 0 8px}h2{font-size:18px;margin:28px 0 8px}p,li{font-size:15px}ul{padding-left:22px}a{color:#0066cc}
8815
+ footer{margin-top:40px;padding-top:20px;border-top:1px solid #e5e7eb;color:#6b7280;font-size:14px}</style></head><body>
8816
+ <h1>Commercial licensing boundary</h1>
8817
+ <p class="meta">MIT vs paid · Last updated 2026-08-12</p>
8818
+ <h2>What is MIT</h2>
8819
+ <p>Unless a file states otherwise, the public repository and published npm package <code>thumbgate</code> are offered under the <strong>MIT</strong> License: local CLI, hooks, public MCP tooling, docs, and tests shipped in that package.</p>
8820
+ <h2>What is not MIT</h2>
8821
+ <ul>
8822
+ <li>Hosted API / production cloud service — proprietary hosted service</li>
8823
+ <li>thumbgate.app pairing, mobile, and cloud runners as offered — proprietary product</li>
8824
+ <li><strong>Pro</strong> subscription entitlement, license keys, hosted dashboard state — subscription license, not transfer of ThumbGate IP</li>
8825
+ <li>Brand and marks — trademark, not MIT code rights to imply endorsement</li>
8826
+ <li>Managed services under an SOW — governed by MSA/SOW</li>
8827
+ </ul>
8828
+ <h2>Customer-specific rules</h2>
8829
+ <p><strong>Customer-specific rules</strong> and configs produced under a paid engagement are owned by the customer as deliverables under the SOW, except that ThumbGate retains pre-existing IP and may reuse generalized non-confidential know-how. <strong>Production approvals</strong> remain the customer’s responsibility; ThumbGate does not assume production change-control ownership by default.</p>
8830
+ <p>Questions: <a href="mailto:legal@thumbgate.ai">legal@thumbgate.ai</a>. Full text: <code>docs/legal/COMMERCIAL_LICENSING_BOUNDARY.md</code>.</p>
8831
+ <footer><a href="/legal">Legal index</a> · <a href="/terms">Terms</a></footer>
8832
+ </body></html>`, {}, { headOnly: isHeadRequest });
8833
+ return;
8834
+ }
8835
+
8836
+ if (isGetLikeRequest && (pathname === '/legal/data-flow' || pathname === '/legal/data-flow/')) {
8837
+ sendHtml(res, 200, `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
8838
+ <title>Data-flow map — ThumbGate</title>
8839
+ <style>body{font-family:system-ui,-apple-system,sans-serif;max-width:820px;margin:0 auto;padding:32px 20px;line-height:1.55;color:#1f2937}
8840
+ h1{font-size:28px;margin:0 0 8px}h2{font-size:18px;margin:28px 0 8px}p,li{font-size:15px}ul{padding-left:22px}a{color:#0066cc}
8841
+ footer{margin-top:40px;padding-top:20px;border-top:1px solid #e5e7eb;color:#6b7280;font-size:14px}</style></head><body>
8842
+ <h1>Data-flow map</h1>
8843
+ <p>First-pass counsel input: what stays local vs what may leave the machine.</p>
8844
+ <h2>Local MIT CLI</h2>
8845
+ <p><strong>Local MIT CLI</strong> and engine: workspace source, local prevention rules, context packs, and lesson DB stay on disk under paths such as <code>~/.thumbgate/</code> and project <code>.claude/</code>. Workspace source does not leave the machine by default.</p>
8846
+ <h2>What may leave the machine</h2>
8847
+ <ul>
8848
+ <li>Account registration and license checks for Pro / hosted features</li>
8849
+ <li><strong>Stripe</strong> payment tokens and billing metadata when you check out</li>
8850
+ <li>Device pairing identifiers for thumbgate.app</li>
8851
+ <li>Cloud-runner operational logs (timestamps, success/fail, error summaries)</li>
8852
+ <li>Optional CLI telemetry (disable with THUMBGATE_NO_TELEMETRY=1)</li>
8853
+ <li>Support email content you voluntarily send</li>
8854
+ </ul>
8855
+ <p>Full map: <code>docs/legal/DATA_FLOW_MAP.md</code> · <a href="/privacy">Privacy Policy</a></p>
8856
+ <footer><a href="/legal">Legal index</a> · <a href="/privacy">Privacy</a></footer>
8857
+ </body></html>`, {}, { headOnly: isHeadRequest });
8858
+ return;
8859
+ }
8860
+
8861
+ if (isGetLikeRequest && (pathname === '/legal/msa-sow' || pathname === '/legal/msa-sow/')) {
8862
+ sendHtml(res, 200, `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
8863
+ <title>MSA / SOW — ThumbGate</title>
8864
+ <style>body{font-family:system-ui,-apple-system,sans-serif;max-width:820px;margin:0 auto;padding:32px 20px;line-height:1.55;color:#1f2937}
8865
+ h1{font-size:28px;margin:0 0 8px}h2{font-size:18px;margin:28px 0 8px}p,li{font-size:15px}ul{padding-left:22px}a{color:#0066cc}
8866
+ footer{margin-top:40px;padding-top:20px;border-top:1px solid #e5e7eb;color:#6b7280;font-size:14px}</style></head><body>
8867
+ <h1>Master Services Agreement + Statement of Work</h1>
8868
+ <p>Template for counsel/product use. Not a signed contract. Self-serve Pro is covered by <a href="/terms">Terms</a> + checkout, not this SOW.</p>
8869
+ <h2>Master Services Agreement (summary)</h2>
8870
+ <ul>
8871
+ <li>Services only as described in an executed Statement of Work</li>
8872
+ <li>Fees and taxes as in the SOW; payment via Stripe or invoice</li>
8873
+ <li>Pre-existing ThumbGate IP remains ThumbGate’s; customer owns customer-specific configs under the SOW</li>
8874
+ <li>No guarantee all unsafe agent actions will be blocked</li>
8875
+ <li>Liability capped at fees paid under the applicable SOW in the prior 12 months</li>
8876
+ </ul>
8877
+ <h2>Statement of Work fields</h2>
8878
+ <p>Typical SOW fields: title, supported workflow, access/materials, acceptance criteria, deliverables, exclusions, change requests, approvals, fees, timeline, termination.</p>
8879
+ <p>Contact: <a href="mailto:legal@thumbgate.ai">legal@thumbgate.ai</a>. Full template: <code>docs/legal/MSA_SOW_TEMPLATE.md</code>.</p>
8880
+ <footer><a href="/legal">Legal index</a> · <a href="/terms">Terms</a></footer>
8881
+ </body></html>`, {}, { headOnly: isHeadRequest });
8882
+ return;
8883
+ }
8884
+
8885
+
8338
8886
  // Public case studies — dogfood proof narratives (static HTML; no fabricated logos).
8339
8887
  if (isGetLikeRequest && (pathname === '/case-studies' || pathname === '/case-studies.html')) {
8340
8888
  try {
@@ -8353,14 +8901,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
8353
8901
  return;
8354
8902
  }
8355
8903
 
8356
-
8357
- // Public canonical pricing page. The audit flagged "pricing schizophrenia":
8358
- // sales/pricing.json said $49 / $299, COMMERCIAL_TRUTH.md said $19 / $149,
8359
- // and there was no buyer-facing surface to reconcile the two. This is now
8360
- // the single source of truth for what ThumbGate sells, in priority order:
8361
- // Sprint (proof-pack, sales-led) → Pro (self-serve recurring) → Team
8362
- // (after qualification). Every paid CTA across the site should funnel
8363
- // here OR directly into Stripe checkout — never a different price.
8904
+ // Public canonical pricing page.
8364
8905
  if (isGetLikeRequest && (pathname === '/pricing' || pathname === '/pricing.html')) {
8365
8906
  try {
8366
8907
  servePublicMarketingPage({
@@ -8380,100 +8921,14 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
8380
8921
  return;
8381
8922
  }
8382
8923
 
8383
-
8384
- // Remote MCP connector documentation the `resource_documentation` target
8385
- // advertised by /.well-known/oauth-protected-resource. The Claude Connectors
8386
- // Directory requires this URL to resolve (200) for submission review.
8387
- if (isGetLikeRequest && (pathname === '/docs/connectors' || pathname === '/docs/connectors/')) {
8388
- const mcpUrl = buildPublicUrl(hostedConfig, '/mcp');
8389
- const cardUrl = buildPublicUrl(hostedConfig, '/.well-known/mcp/server-card.json');
8390
- const prmUrl = buildPublicUrl(hostedConfig, '/.well-known/oauth-protected-resource');
8391
- sendHtml(res, 200, `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Remote MCP Connector — ThumbGate</title><meta name="description" content="Connect ThumbGate to Claude as a remote MCP server: OAuth 2.1 (PKCE) authorization, available tools, and the read-only reviewer credential."><style>body{font-family:system-ui,-apple-system,sans-serif;max-width:780px;margin:0 auto;padding:32px 20px;line-height:1.55;color:#1f2937}h1{font-size:30px;margin:0 0 8px}.lede{color:#6b7280;font-size:18px;margin:0 0 28px}h2{font-size:20px;margin:28px 0 8px}code{background:#f3f4f6;padding:1px 6px;border-radius:4px;font-size:13.5px}pre{background:#0f172a;color:#e2e8f0;padding:14px 16px;border-radius:8px;overflow:auto;font-size:13px}a{color:#0066cc}ol,ul{padding-left:22px}li{margin:6px 0}.note{border-left:3px solid #22d3ee;background:#ecfeff;padding:10px 14px;border-radius:0 8px 8px 0;margin:16px 0}footer{margin-top:40px;padding-top:20px;border-top:1px solid #e5e7eb;color:#6b7280;font-size:14px}</style></head><body>
8392
- <h1>ThumbGate — Remote MCP Connector</h1>
8393
- <p class="lede">ThumbGate is a remote Model Context Protocol (MCP) server. Add it as a connector in Claude to give an agent governed access to ThumbGate's feedback capture, lesson retrieval, context assembly, and pre-action gate tools — over HTTP, authenticated with OAuth 2.1.</p>
8394
-
8395
- <h2>Connect URL</h2>
8396
- <pre>${esc(mcpUrl)}</pre>
8397
- <p>In Claude, add a custom connector and paste the URL above. Claude discovers the authorization server automatically via the protected-resource metadata at <a href="${esc(prmUrl)}">${esc(prmUrl)}</a>.</p>
8398
-
8399
- <h2>Authorization (OAuth 2.1 + PKCE)</h2>
8400
- <ol>
8401
- <li>Claude reads the <code>resource</code> and <code>authorization_servers</code> from the protected-resource metadata.</li>
8402
- <li>It runs the standard OAuth 2.1 authorization-code flow with PKCE (<code>S256</code> only — <code>plain</code> is rejected).</li>
8403
- <li>The issued access token is audience-bound to the <code>${esc(mcpUrl)}</code> resource (RFC 8707); a token for any other audience is rejected.</li>
8404
- <li>The bearer token is then sent on the <code>Authorization</code> header for every <code>tools/call</code>.</li>
8405
- </ol>
8406
-
8407
- <h2>Available tools</h2>
8408
- <p>The full, machine-readable tool registry — names, input schemas, and the <code>readOnlyHint</code> / <code>destructiveHint</code> annotations — is published at <a href="${esc(cardUrl)}">${esc(cardUrl)}</a>. Tools fall into these groups:</p>
8409
- <ul>
8410
- <li><strong>Feedback &amp; lessons</strong> — <code>capture_feedback</code>, <code>feedback_summary</code>, <code>search_lessons</code>, <code>retrieve_lessons</code>, <code>prevention_rules</code>.</li>
8411
- <li><strong>Context engineering</strong> — <code>construct_context_pack</code>, <code>evaluate_context_pack</code>, <code>unified_context</code>, <code>recall</code>.</li>
8412
- <li><strong>Pre-action gates &amp; governance</strong> — <code>satisfy_gate</code>, <code>track_action</code>, <code>approve_protected_action</code>, <code>verify_claim</code>, <code>enforcement_matrix</code>.</li>
8413
- <li><strong>Diagnostics &amp; planning</strong> — <code>diagnose_failure</code>, <code>suggest_fix</code>, <code>security_scan</code>, and the <code>plan_*</code> advisory tools.</li>
8414
- </ul>
8415
-
8416
- <h2>Reviewer credential (read-only)</h2>
8417
- <div class="note">For directory reviewers: ThumbGate issues a dedicated <strong>read-only</strong> reviewer credential. A token bound to that credential may invoke only tools annotated <code>readOnlyHint: true</code>; any write or mutating tool call is rejected. This makes the credential safe to share for review without granting the ability to mutate shared server state. Request it from the contact below.</div>
8418
-
8419
- <h2>Source &amp; contact</h2>
8420
- <p>Open-source CLI and server: <a href="https://github.com/IgorGanapolsky/ThumbGate">github.com/IgorGanapolsky/ThumbGate</a>. Questions or reviewer-credential requests: <a href="mailto:igor.ganapolsky@gmail.com">igor.ganapolsky@gmail.com</a>.</p>
8421
-
8422
- <footer><a href="/">ThumbGate</a> · <a href="/support">Support</a> · <a href="/privacy">Privacy</a> · <a href="/terms">Terms</a></footer>
8423
- </body></html>`, {}, { headOnly: isHeadRequest });
8424
- return;
8425
- }
8426
-
8427
- // Public support / contact page — required for Stripe Business → Public
8428
- // details "Customer support URL" field. Single source of truth for how
8429
- // customers reach us (email, GitHub issues, status page).
8430
- if (isGetLikeRequest && pathname === '/support') {
8431
- sendHtml(res, 200, `<!DOCTYPE html><html><head><title>Support — ThumbGate</title></head><body>
8432
- <h1>Support</h1>
8433
- <p><strong>ThumbGate</strong> support, billing, and contact paths.</p>
8434
- <h2>Email</h2>
8435
- <p>For billing questions, refunds, subscription changes, or technical issues with the hosted tier: <a href="mailto:igor.ganapolsky@gmail.com">igor.ganapolsky@gmail.com</a>. We reply within one business day.</p>
8436
- <h2>GitHub Issues</h2>
8437
- <p>For bugs, CLI questions, and feature requests in the open-source CLI: <a href="https://github.com/IgorGanapolsky/ThumbGate/issues">github.com/IgorGanapolsky/ThumbGate/issues</a>.</p>
8438
- <h2>Status</h2>
8439
- <p>Hosted-tier status: check <a href="https://thumbgate-production.up.railway.app/health">/health</a> for current health. Railway-hosted; rebuilds take 2-5 minutes.</p>
8440
- <h2>Refunds</h2>
8441
- <p>Pro / Team subscriptions: 7-day full refund window from first charge. One-off purchases: refund on request if we cannot deliver. Email the address above.</p>
8442
- <h2>Security</h2>
8443
- <p>Disclose vulnerabilities by email; please do not post to public GitHub issues. We acknowledge within 48 hours.</p>
8444
- <p><a href="https://github.com/IgorGanapolsky/ThumbGate">GitHub</a> · <a href="/privacy">Privacy</a> · <a href="/terms">Terms</a></p>
8445
- </body></html>`, {}, {
8446
- headOnly: isHeadRequest,
8447
- });
8448
- return;
8449
- }
8450
-
8451
- // Public privacy policy — required for GPT Store and marketplace listings
8452
- if (isGetLikeRequest && pathname === '/privacy') {
8453
- sendHtml(res, 200, `<!DOCTYPE html><html><head><title>Privacy Policy — ThumbGate</title></head><body>
8454
- <h1>Privacy Policy</h1>
8455
- <p><strong>ThumbGate</strong> (npm: thumbgate)</p>
8456
- <p>Last updated: 2026-03-11</p>
8457
- <h2>Data Collection</h2>
8458
- <p>The self-hosted version stores workflow data locally on your machine. Local feedback, memory entries, proof artifacts, and context packs stay in your project files unless you explicitly point the system at a hosted endpoint.</p>
8459
- <p>The hosted tier (thumbgate-production.up.railway.app) stores feedback signals, memory entries, and related workflow metadata associated with your API key.</p>
8460
- <p>Optional CLI telemetry is best-effort and covers install or usage metadata needed to understand adoption and failures. You can disable it with <code>THUMBGATE_NO_TELEMETRY=1</code>.</p>
8461
- <h2>Data Stored</h2><ul>
8462
- <li>Feedback signals (thumbs up/down) with context you provide</li>
8463
- <li>Promoted memory entries</li>
8464
- <li>Prevention rules generated from your feedback</li>
8465
- </ul>
8466
- <h2>Data Sharing</h2>
8467
- <p>We do not sell customer data. Hosted data is used to operate the service and is not shared with third parties except for infrastructure providers needed to run the product.</p>
8468
- <h2>Data Retention</h2>
8469
- <p>Local data is retained until you delete the files. Hosted data is retained while your account or API key remains active, or until you request deletion, subject to operational or legal retention requirements.</p>
8470
- <h2>Data Deletion</h2>
8471
- <p>Contact igor.ganapolsky@gmail.com to request deletion of hosted data.</p>
8472
- <h2>Contact</h2><p>igor.ganapolsky@gmail.com</p>
8473
- <p><a href="https://github.com/IgorGanapolsky/ThumbGate">GitHub</a></p>
8474
- </body></html>`, {}, {
8475
- headOnly: isHeadRequest,
8476
- });
8924
+ // Public Remote MCP Connector Documentation.
8925
+ if (isGetLikeRequest && (pathname === '/docs/connectors' || pathname === '/docs/connectors/' || pathname === '/docs/connectors.html')) {
8926
+ try {
8927
+ const connectorsHtml = fs.readFileSync(path.join(PUBLIC_DIR, 'docs', 'connectors.html'), 'utf-8');
8928
+ sendHtml(res, 200, connectorsHtml, {}, { headOnly: isHeadRequest });
8929
+ } catch {
8930
+ sendJson(res, 404, { error: 'Connectors documentation not found' });
8931
+ }
8477
8932
  return;
8478
8933
  }
8479
8934
 
@@ -8736,19 +9191,8 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
8736
9191
  return;
8737
9192
  }
8738
9193
 
8739
- // Operator key is allowed to bypass the general admin gate for dedicated
8740
- // read-only operational endpoints.
8741
- const _reqToken = extractApiKey(req);
8742
- const isOperatorReadRequest = Boolean(expectedOperatorKey)
8743
- && safeKeyEqual(_reqToken, expectedOperatorKey)
8744
- && req.method === 'GET'
8745
- && [
8746
- '/v1/billing/summary',
8747
- '/v1/intake/workflow-sprint/queue',
8748
- '/v1/task-outcomes/monitor',
8749
- ].includes(pathname);
8750
-
8751
- if (!isOperatorReadRequest && !isAuthorized(req, expectedApiKey)) {
9194
+ // Operator key: read-only operational endpoints only (see OPERATOR_READONLY_GET_PATHS).
9195
+ if (!isOperatorReadonlyGet(req, expectedOperatorKey, pathname) && !isAuthorized(req, expectedApiKey)) {
8752
9196
  sendProblem(res, {
8753
9197
  type: PROBLEM_TYPES.UNAUTHORIZED,
8754
9198
  title: 'Unauthorized',
@@ -9642,6 +10086,13 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
9642
10086
  guardrails: body.guardrails,
9643
10087
  tags: extractTags(body.tags),
9644
10088
  skill: body.skill,
10089
+ memorySource: body.memorySource && typeof body.memorySource === 'object'
10090
+ ? {
10091
+ type: body.memorySource.type,
10092
+ identifier: body.memorySource.identifier,
10093
+ trust: body.memorySource.trust,
10094
+ }
10095
+ : undefined,
9645
10096
  reviewOrigin: 'human',
9646
10097
  });
9647
10098
  const actionIntegration = inferActionIntegration(body, req.headers);
@@ -9980,12 +10431,23 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
9980
10431
  } catch (err) {
9981
10432
  throw createHttpError(400, err.message || 'Invalid namespaces');
9982
10433
  }
9983
- const pack = constructContextPack({
9984
- query: body.query || '',
9985
- maxItems: Number(body.maxItems || 8),
9986
- maxChars: Number(body.maxChars || 6000),
9987
- namespaces,
9988
- });
10434
+ let pack;
10435
+ try {
10436
+ pack = constructContextPack({
10437
+ query: body.query || '',
10438
+ maxItems: Number(body.maxItems || 8),
10439
+ maxChars: Number(body.maxChars || 6000),
10440
+ namespaces,
10441
+ strategy: body.strategy || null,
10442
+ contextEnvelope: body.contextEnvelope || null,
10443
+ });
10444
+ } catch (err) {
10445
+ if (err.code === 'INVALID_CONTEXT_ENVELOPE'
10446
+ || err.code === 'CONTEXT_BUDGET_EXCEEDED') {
10447
+ throw createHttpError(400, err.message);
10448
+ }
10449
+ throw err;
10450
+ }
9989
10451
  sendJson(res, 200, pack);
9990
10452
  return;
9991
10453
  }
@@ -10698,11 +11160,151 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
10698
11160
  });
10699
11161
  return;
10700
11162
  }
10701
- const entry = satisfyCondition(body.gateId, body.evidence);
11163
+ // Attribute the override to the API surface. Without an explicit
11164
+ // source the record defaults to 'cli' and the audit summary cannot
11165
+ // tell an HTTP unlock from a local one.
11166
+ const entry = satisfyCondition(body.gateId, body.evidence, null, {
11167
+ source: 'api',
11168
+ actor: body.actor || 'api-client',
11169
+ });
10702
11170
  sendJson(res, 200, { satisfied: true, gateId: body.gateId, ...entry });
10703
11171
  return;
10704
11172
  }
10705
11173
 
11174
+ // POST /v1/hermes/initialize — Bind a connectionId before turn start/end
11175
+ if (req.method === 'POST' && pathname === '/v1/hermes/initialize') {
11176
+ const body = await parseJsonBody(req);
11177
+ const { protocol } = getHostedHermes(requestDataIdentity);
11178
+ const result = protocol.initialize({
11179
+ connectionId: body.connectionId,
11180
+ });
11181
+ if (!result.ok) {
11182
+ sendProblem(res, {
11183
+ type: PROBLEM_TYPES.BAD_REQUEST,
11184
+ title: 'Hermes Initialize Rejected',
11185
+ status: 400,
11186
+ detail: result.reason,
11187
+ ...result,
11188
+ });
11189
+ return;
11190
+ }
11191
+ sendJson(res, 200, result);
11192
+ return;
11193
+ }
11194
+
11195
+ // POST /v1/hermes/turn/start — Start turn under Hermes Platform Protocol
11196
+ // Fail closed until POST /v1/hermes/initialize registers the connectionId.
11197
+ if (req.method === 'POST' && pathname === '/v1/hermes/turn/start') {
11198
+ const body = await parseJsonBody(req);
11199
+ const { protocol } = getHostedHermes(requestDataIdentity);
11200
+ const result = protocol.startTurn(body);
11201
+ if (!result.ok) {
11202
+ sendProblem(res, {
11203
+ type: PROBLEM_TYPES.BAD_REQUEST,
11204
+ title: 'Hermes Turn Rejected',
11205
+ status: 400,
11206
+ detail: result.reason,
11207
+ ...result,
11208
+ });
11209
+ return;
11210
+ }
11211
+ sendJson(res, 200, result);
11212
+ return;
11213
+ }
11214
+
11215
+ // POST /v1/hermes/turn/end — End active turn under Hermes Platform Protocol
11216
+ if (req.method === 'POST' && pathname === '/v1/hermes/turn/end') {
11217
+ const body = await parseJsonBody(req);
11218
+ const { protocol } = getHostedHermes(requestDataIdentity);
11219
+ const result = protocol.endTurn(body);
11220
+ if (!result.ok) {
11221
+ sendProblem(res, {
11222
+ type: PROBLEM_TYPES.BAD_REQUEST,
11223
+ title: 'Hermes End Turn Rejected',
11224
+ status: 400,
11225
+ detail: result.reason,
11226
+ ...result,
11227
+ });
11228
+ return;
11229
+ }
11230
+ sendJson(res, 200, result);
11231
+ return;
11232
+ }
11233
+
11234
+ // POST /v1/hermes/action/approve — Record human/operator approval for consequential action
11235
+ if (req.method === 'POST' && pathname === '/v1/hermes/action/approve') {
11236
+ const body = await parseJsonBody(req);
11237
+ const boundApproverId = resolveHermesApproverId(
11238
+ req,
11239
+ humanReviewerConfig,
11240
+ requestDataIdentity,
11241
+ );
11242
+ if (!boundApproverId) {
11243
+ sendProblem(res, {
11244
+ type: PROBLEM_TYPES.UNAUTHORIZED,
11245
+ title: 'Hermes Approval Rejected',
11246
+ status: 401,
11247
+ detail: 'Authenticated reviewer identity is required for Hermes approvals.',
11248
+ });
11249
+ return;
11250
+ }
11251
+ const { protocol } = getHostedHermes(requestDataIdentity);
11252
+ const result = protocol.approve({
11253
+ ...body,
11254
+ approverId: boundApproverId,
11255
+ });
11256
+ if (!result.ok) {
11257
+ sendProblem(res, {
11258
+ type: PROBLEM_TYPES.BAD_REQUEST,
11259
+ title: 'Hermes Approval Rejected',
11260
+ status: 400,
11261
+ detail: result.reason,
11262
+ ...result,
11263
+ });
11264
+ return;
11265
+ }
11266
+ sendJson(res, 200, result);
11267
+ return;
11268
+ }
11269
+
11270
+ // GET /v1/hermes/sync/read — Reactive read path with cursor + offset
11271
+ if (req.method === 'GET' && pathname === '/v1/hermes/sync/read') {
11272
+ const rawOffset = parsed.searchParams.get('offset');
11273
+ const offset = rawOffset !== null ? Number(rawOffset) : undefined;
11274
+ const cursor = parsed.searchParams.get('cursor') || undefined;
11275
+ const threadId = parsed.searchParams.get('threadId') || undefined;
11276
+ const recordId = parsed.searchParams.get('recordId') || undefined;
11277
+ const mode = parsed.searchParams.get('mode') || undefined;
11278
+ // Prefer the authenticated principal over a client-supplied userId so
11279
+ // hosted tenants cannot probe another identity's sync shapes.
11280
+ const authUserId = requestDataIdentity?.accessContext?.principalId
11281
+ || parsed.searchParams.get('userId')
11282
+ || undefined;
11283
+
11284
+ const { syncPlane } = getHostedHermes(requestDataIdentity);
11285
+ const result = syncPlane.readStatus({
11286
+ offset,
11287
+ cursor,
11288
+ auth: authUserId ? { userId: authUserId } : null,
11289
+ threadId,
11290
+ recordId,
11291
+ mode,
11292
+ });
11293
+
11294
+ if (!result.ok) {
11295
+ sendProblem(res, {
11296
+ type: PROBLEM_TYPES.BAD_REQUEST,
11297
+ title: 'Hermes Sync Read Rejected',
11298
+ status: 400,
11299
+ detail: result.reason,
11300
+ ...result,
11301
+ });
11302
+ return;
11303
+ }
11304
+ sendJson(res, 200, result);
11305
+ return;
11306
+ }
11307
+
10706
11308
  // GET /api/conversions — Conversion stats derived from the Stripe event log
10707
11309
  if (req.method === 'GET' && pathname === '/api/conversions') {
10708
11310
  try {
@@ -10740,7 +11342,20 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
10740
11342
  function startServer({ port, host } = {}) {
10741
11343
  const listenPort = Number(port ?? process.env.PORT ?? 8787);
10742
11344
  const listenHost = String(host ?? process.env.HOST ?? '0.0.0.0').trim() || '0.0.0.0';
10743
- fs.mkdirSync(getFeedbackPaths().FEEDBACK_DIR, { recursive: true });
11345
+ const feedbackPaths = getFeedbackPaths();
11346
+ fs.mkdirSync(feedbackPaths.FEEDBACK_DIR, { recursive: true });
11347
+ // Hosted volumes often start empty (Docker does not ship .claude/memory).
11348
+ // Seed a durable "thumbgate" corpus so authenticated deploy proof can verify retrieval.
11349
+ if (shouldEnsureProductionSearchCorpus(process.env)) {
11350
+ try {
11351
+ const seedResult = ensureProductionSearchCorpus({ feedbackDir: feedbackPaths.FEEDBACK_DIR });
11352
+ if (seedResult.wrote.memory || seedResult.wrote.feedback || seedResult.wrote.rules) {
11353
+ console.log('[startup] ensured production search corpus seed', JSON.stringify(seedResult.wrote));
11354
+ }
11355
+ } catch (err) {
11356
+ console.warn('[startup] ensure production search corpus failed:', err?.message || err);
11357
+ }
11358
+ }
10744
11359
  const server = createApiServer();
10745
11360
  registerGracefulShutdown(server);
10746
11361
  return new Promise((resolve) => {
@@ -10823,6 +11438,8 @@ module.exports = {
10823
11438
  sanitizeHtmlUnsafeJsonValue,
10824
11439
  buildHostedTenantIdentity,
10825
11440
  partitionFeedbackPaths,
11441
+ buildLiveDashboardCacheKey,
11442
+ loadCachedLiveDashboardData,
10826
11443
  },
10827
11444
  };
10828
11445