thumbgate 1.28.4 → 1.29.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.
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/llms.txt +18 -10
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +5 -2
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/forge/forge.yaml +3 -3
- package/adapters/mcp/server-stdio.js +1 -1
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +8 -8
- package/bin/postinstall.js +4 -13
- package/config/github-about.json +5 -4
- package/config/post-deploy-marketing-pages.json +6 -6
- package/docs/integrations/grafana/README.md +109 -0
- package/docs/integrations/grafana/thumbgate-revenue-evidence-dashboard.json +1930 -0
- package/openapi/openapi.yaml +240 -5
- package/package.json +59 -19
- package/public/agent-manager.html +10 -11
- package/public/agents-cost-savings.html +2 -2
- package/public/assets/brand/thumbgate-logo-transparent.svg +6 -11
- package/public/assets/brand/thumbgate-mark-inline-v3.svg +11 -10
- package/public/assets/brand/thumbgate-mark.svg +10 -11
- package/public/blog/inside-your-boundary.html +114 -0
- package/public/blog/process-over-outcome-gates.html +119 -0
- package/public/blog.html +296 -402
- package/public/brand/thumbgate-mark.svg +5 -9
- package/public/codex-enterprise.html +2 -2
- package/public/compare.html +12 -3
- package/public/diagnostic.html +79 -29
- package/public/guide.html +4 -4
- package/public/index.html +1088 -2098
- package/public/install.html +3 -3
- package/public/js/buyer-intent.js +33 -18
- package/public/numbers.html +2 -2
- package/public/pricing.html +268 -408
- package/public/pro.html +4 -4
- package/scripts/billing.js +456 -126
- package/scripts/buyer-paths.js +102 -0
- package/scripts/cli-feedback.js +2 -2
- package/scripts/commercial-offer.js +18 -10
- package/scripts/external-customer-audit.js +881 -0
- package/scripts/feedback-loop.js +26 -0
- package/scripts/gates-engine.js +123 -1
- package/scripts/grafana-revenue-evidence.js +856 -0
- package/scripts/jsonl-window.js +89 -0
- package/scripts/lesson-embedding-index.js +3 -7
- package/scripts/meta-agent-loop.js +20 -2
- package/scripts/observability-env.js +139 -0
- package/scripts/observability-setup.js +55 -0
- package/scripts/plausible-domain-config.js +4 -0
- package/scripts/provider-live-evidence.js +1290 -0
- package/scripts/provider-payment-reconciler.js +442 -0
- package/scripts/provider-revenue-evidence.js +249 -0
- package/scripts/rate-limiter.js +1 -5
- package/scripts/revenue-action-eligibility.js +414 -0
- package/scripts/revenue-evidence-remediation.js +694 -0
- package/scripts/revenue-offer-system.js +709 -0
- package/scripts/sales-pipeline.js +1117 -0
- package/scripts/seo-gsd.js +8 -4
- package/scripts/stripe-credentials.js +37 -0
- package/scripts/stripe-revenue-catalog-audit.js +363 -0
- package/scripts/stripe-revenue-catalog.js +164 -0
- package/scripts/telemetry-analytics.js +23 -3
- package/scripts/vector-store.js +83 -7
- package/scripts/workflow-intake-queue.js +483 -0
- package/src/api/server.js +521 -114
|
@@ -19,6 +19,7 @@ const MARKETING_CLICK_EVENT_TYPES = new Set([
|
|
|
19
19
|
'cta_click',
|
|
20
20
|
'checkout_start',
|
|
21
21
|
'checkout_bootstrap',
|
|
22
|
+
'diagnostic_checkout_confirmed',
|
|
22
23
|
'checkout_interstitial_cta_clicked',
|
|
23
24
|
'chatgpt_gpt_open',
|
|
24
25
|
'chatgpt_gpt_click',
|
|
@@ -31,6 +32,14 @@ const MARKETING_CLICK_EVENT_TYPES = new Set([
|
|
|
31
32
|
'github_repo_click',
|
|
32
33
|
'community_landing_redirect',
|
|
33
34
|
]);
|
|
35
|
+
const CHECKOUT_START_EVENT_TYPES = new Set([
|
|
36
|
+
'checkout_start',
|
|
37
|
+
'checkout_bootstrap',
|
|
38
|
+
// A diagnostic buyer has submitted a valid receipt email and the server is
|
|
39
|
+
// issuing the external Payment Link redirect. This is stronger than a CTA
|
|
40
|
+
// click and is the service equivalent of the Pro checkout bootstrap.
|
|
41
|
+
'diagnostic_checkout_confirmed',
|
|
42
|
+
]);
|
|
34
43
|
|
|
35
44
|
function shouldIncludeLegacyTelemetry() {
|
|
36
45
|
if (
|
|
@@ -91,6 +100,10 @@ function isMarketingClickEvent(eventType) {
|
|
|
91
100
|
return MARKETING_CLICK_EVENT_TYPES.has(String(eventType || '').toLowerCase());
|
|
92
101
|
}
|
|
93
102
|
|
|
103
|
+
function isCheckoutStartEvent(eventType) {
|
|
104
|
+
return CHECKOUT_START_EVENT_TYPES.has(String(eventType || '').toLowerCase());
|
|
105
|
+
}
|
|
106
|
+
|
|
94
107
|
function getTelemetryPath(feedbackDir) {
|
|
95
108
|
return path.join(feedbackDir, TELEMETRY_FILE_NAME);
|
|
96
109
|
}
|
|
@@ -666,7 +679,7 @@ function summarizeExternalTrafficQuality(events) {
|
|
|
666
679
|
if ((entry.page || entry.landingPath) && !pathRow.pages.includes(entry.page || entry.landingPath)) {
|
|
667
680
|
pathRow.pages.push(entry.page || entry.landingPath);
|
|
668
681
|
}
|
|
669
|
-
if (eventType
|
|
682
|
+
if (isCheckoutStartEvent(eventType)) {
|
|
670
683
|
pathRow.checkoutStarts += 1;
|
|
671
684
|
}
|
|
672
685
|
visitorPaths.set(visitorKey, pathRow);
|
|
@@ -683,7 +696,7 @@ function summarizeExternalTrafficQuality(events) {
|
|
|
683
696
|
externalCtaClicks += 1;
|
|
684
697
|
}
|
|
685
698
|
|
|
686
|
-
if (eventType
|
|
699
|
+
if (isCheckoutStartEvent(eventType)) {
|
|
687
700
|
externalCheckoutStarts += 1;
|
|
688
701
|
incrementCounter(checkoutStartsBySource, entry.source);
|
|
689
702
|
incrementCounter(checkoutStartsByTrafficChannel, entry.trafficChannel);
|
|
@@ -814,6 +827,7 @@ function getTelemetrySummary(feedbackDir, options = {}) {
|
|
|
814
827
|
let ctaClicks = 0;
|
|
815
828
|
let ctaImpressions = 0;
|
|
816
829
|
let checkoutStarts = 0;
|
|
830
|
+
let diagnosticCheckoutStarts = 0;
|
|
817
831
|
let checkoutInterstitialViews = 0;
|
|
818
832
|
let checkoutBotDeflections = 0;
|
|
819
833
|
let checkoutInterstitialClicks = 0;
|
|
@@ -922,8 +936,11 @@ function getTelemetrySummary(feedbackDir, options = {}) {
|
|
|
922
936
|
}
|
|
923
937
|
}
|
|
924
938
|
|
|
925
|
-
if ((entry.eventType || entry.event)
|
|
939
|
+
if (isCheckoutStartEvent(entry.eventType || entry.event)) {
|
|
926
940
|
checkoutStarts += 1;
|
|
941
|
+
if ((entry.eventType || entry.event) === 'diagnostic_checkout_confirmed') {
|
|
942
|
+
diagnosticCheckoutStarts += 1;
|
|
943
|
+
}
|
|
927
944
|
incrementCounter(checkoutStartsBySource, entry.source);
|
|
928
945
|
incrementCounter(checkoutStartsByCampaign, entry.utmCampaign);
|
|
929
946
|
incrementCounter(checkoutStartsByTrafficChannel, entry.trafficChannel);
|
|
@@ -1112,6 +1129,7 @@ function getTelemetrySummary(feedbackDir, options = {}) {
|
|
|
1112
1129
|
gptOpens,
|
|
1113
1130
|
gptActionCalls: chatgptActionCalls,
|
|
1114
1131
|
checkoutStarts,
|
|
1132
|
+
diagnosticCheckoutStarts,
|
|
1115
1133
|
checkoutInterstitialViews,
|
|
1116
1134
|
checkoutInterstitialClicks,
|
|
1117
1135
|
trialEmails,
|
|
@@ -1133,6 +1151,7 @@ function getTelemetrySummary(feedbackDir, options = {}) {
|
|
|
1133
1151
|
pageViews,
|
|
1134
1152
|
ctaClicks,
|
|
1135
1153
|
checkoutStarts,
|
|
1154
|
+
diagnosticCheckoutStarts,
|
|
1136
1155
|
checkoutInterstitialViews,
|
|
1137
1156
|
checkoutBotDeflections,
|
|
1138
1157
|
checkoutInterstitialClicks,
|
|
@@ -1317,6 +1336,7 @@ function getTelemetryAnalytics(feedbackDir, options = {}) {
|
|
|
1317
1336
|
ctas: {
|
|
1318
1337
|
totalClicks: summary.web.ctaClicks,
|
|
1319
1338
|
checkoutStarts: summary.web.checkoutStarts,
|
|
1339
|
+
diagnosticCheckoutStarts: summary.web.diagnosticCheckoutStarts,
|
|
1320
1340
|
checkoutInterstitialViews: summary.web.checkoutInterstitialViews,
|
|
1321
1341
|
checkoutBotDeflections: summary.web.checkoutBotDeflections,
|
|
1322
1342
|
checkoutInterstitialClicks: summary.web.checkoutInterstitialClicks,
|
package/scripts/vector-store.js
CHANGED
|
@@ -28,6 +28,7 @@ let _lastEmbeddingProfile = null;
|
|
|
28
28
|
let _pipelineLoader = null;
|
|
29
29
|
let _geminiEmbedderForTests = null;
|
|
30
30
|
const TABLE_NAME = 'thumbgate_memories';
|
|
31
|
+
const FEATURE_HASH_DIMENSIONS = 384;
|
|
31
32
|
|
|
32
33
|
async function getLanceDB() {
|
|
33
34
|
if (!_lancedb) {
|
|
@@ -69,6 +70,58 @@ async function loadPipelineForProfile(profile) {
|
|
|
69
70
|
return pipe;
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
function hasLocalTransformerProvider() {
|
|
74
|
+
if (_pipelineLoader) return true;
|
|
75
|
+
try {
|
|
76
|
+
require.resolve('@huggingface/transformers');
|
|
77
|
+
return true;
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function fnv1a32(value) {
|
|
84
|
+
let hash = 0x811c9dc5;
|
|
85
|
+
const bytes = Buffer.from(String(value), 'utf8');
|
|
86
|
+
for (const byte of bytes) {
|
|
87
|
+
hash ^= byte;
|
|
88
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
89
|
+
}
|
|
90
|
+
return hash;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function addHashedFeature(vector, feature, weight) {
|
|
94
|
+
const hash = fnv1a32(feature);
|
|
95
|
+
const index = hash % vector.length;
|
|
96
|
+
const sign = (hash & 0x80000000) === 0 ? 1 : -1;
|
|
97
|
+
vector[index] += sign * weight;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function embedWithFeatureHash(text) {
|
|
101
|
+
const vector = Array(FEATURE_HASH_DIMENSIONS).fill(0);
|
|
102
|
+
const tokens = String(text || '').toLowerCase().match(/[\p{L}\p{N}_-]+/gu) || [];
|
|
103
|
+
|
|
104
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
105
|
+
const token = tokens[index];
|
|
106
|
+
addHashedFeature(vector, `token:${token}`, 1);
|
|
107
|
+
if (index > 0) {
|
|
108
|
+
addHashedFeature(vector, `bigram:${tokens[index - 1]}:${token}`, 0.6);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const bounded = `^${token.slice(0, 64)}$`;
|
|
112
|
+
for (let offset = 0; offset <= bounded.length - 3; offset += 1) {
|
|
113
|
+
addHashedFeature(vector, `trigram:${bounded.slice(offset, offset + 3)}`, 0.3);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const norm = Math.sqrt(vector.reduce((sum, value) => sum + (value * value), 0));
|
|
118
|
+
if (norm === 0) {
|
|
119
|
+
vector[0] = 1;
|
|
120
|
+
return vector;
|
|
121
|
+
}
|
|
122
|
+
return vector.map((value) => value / norm);
|
|
123
|
+
}
|
|
124
|
+
|
|
72
125
|
async function getEmbeddingPipeline() {
|
|
73
126
|
const resolved = resolveEmbeddingProfile();
|
|
74
127
|
const report = writeModelFitReport(getFeedbackDir(), { resolved }).report;
|
|
@@ -247,12 +300,33 @@ async function embed(text, options = {}) {
|
|
|
247
300
|
console.warn(`Gemini embedding fallback: ${geminiError.message}`);
|
|
248
301
|
}
|
|
249
302
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
303
|
+
if (hasLocalTransformerProvider()) {
|
|
304
|
+
try {
|
|
305
|
+
const { pipe, profile } = await getEmbeddingPipeline();
|
|
306
|
+
const output = await pipe(truncateForEmbedding(text, profile.activeProfile.maxChars), {
|
|
307
|
+
pooling: 'mean',
|
|
308
|
+
normalize: true,
|
|
309
|
+
});
|
|
310
|
+
return Array.from(output.data); // Float32Array -> plain number[] for LanceDB Arrow serialization
|
|
311
|
+
} catch (transformerError) {
|
|
312
|
+
console.warn(`Transformers.js embedding fallback: ${transformerError.message}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const vector = embedWithFeatureHash(text);
|
|
317
|
+
_lastEmbeddingProfile = {
|
|
318
|
+
generatedAt: new Date().toISOString(),
|
|
319
|
+
source: 'built-in',
|
|
320
|
+
activeProfile: {
|
|
321
|
+
id: 'feature-hash-v1',
|
|
322
|
+
model: 'ThumbGate feature hashing',
|
|
323
|
+
outputDimensionality: FEATURE_HASH_DIMENSIONS,
|
|
324
|
+
task: options.task || 'code retrieval',
|
|
325
|
+
rationale: 'Deterministic zero-dependency local text embedding.',
|
|
326
|
+
},
|
|
327
|
+
fallbackUsed: false,
|
|
328
|
+
};
|
|
329
|
+
return vector;
|
|
256
330
|
}
|
|
257
331
|
|
|
258
332
|
async function upsertFeedback(feedbackEvent) {
|
|
@@ -269,7 +343,8 @@ async function upsertFeedback(feedbackEvent) {
|
|
|
269
343
|
feedbackEvent.whatWorked || '',
|
|
270
344
|
].filter(Boolean).join('. ');
|
|
271
345
|
|
|
272
|
-
// Embed is pure CPU/model work (
|
|
346
|
+
// Embed is pure CPU/model work (managed, optional Transformers.js, built-in,
|
|
347
|
+
// or stub) and deterministic for local providers.
|
|
273
348
|
// for a given input, so no retry is needed here. Retry wraps the table
|
|
274
349
|
// write below, which is the actual I/O failure surface.
|
|
275
350
|
const vector = await embed(textForEmbedding, {
|
|
@@ -364,4 +439,5 @@ module.exports = {
|
|
|
364
439
|
setLanceLoaderForTests,
|
|
365
440
|
setGeminiEmbedderForTests,
|
|
366
441
|
truncateForEmbedding,
|
|
442
|
+
embedWithFeatureHash,
|
|
367
443
|
};
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
|
|
9
|
+
const { resolveHostedBillingConfig } = require('./hosted-config');
|
|
10
|
+
|
|
11
|
+
const DEFAULT_TIMEOUT_MS = 15000;
|
|
12
|
+
const DEFAULT_LIMIT = 50;
|
|
13
|
+
const MAX_LIMIT = 100;
|
|
14
|
+
const OPERATOR_CONFIG_PATH = path.join(os.homedir(), '.config', 'thumbgate', 'operator.json');
|
|
15
|
+
const ALLOWED_STATUSES = new Set([
|
|
16
|
+
'all',
|
|
17
|
+
'new',
|
|
18
|
+
'qualified',
|
|
19
|
+
'named_pilot',
|
|
20
|
+
'proof_backed_run',
|
|
21
|
+
'paid_team',
|
|
22
|
+
]);
|
|
23
|
+
const ALLOWED_PRIORITY_BANDS = new Set(['review_now', 'review_next', 'hold_or_nurture']);
|
|
24
|
+
const ALLOWED_FIT_BANDS = new Set(['strong_evidence_for_review', 'partial_evidence', 'incomplete_evidence']);
|
|
25
|
+
const ALLOWED_ROUTES = new Set(['diagnostic', 'close', 'nurture', 'disqualify']);
|
|
26
|
+
const ALLOWED_OPERATOR_STEPS = new Set([
|
|
27
|
+
'request_action_time_approval',
|
|
28
|
+
'request_action_time_approval_for_discovery',
|
|
29
|
+
'review_and_qualify',
|
|
30
|
+
'prepare_scope_or_hold',
|
|
31
|
+
]);
|
|
32
|
+
const ALLOWED_CLOSE_STATUSES = new Set(['approval_ready_not_authorized', 'hold_not_approval_ready']);
|
|
33
|
+
const ALLOWED_DISCOVERY_STATUSES = new Set(['approval_ready_not_authorized', 'hold_not_approval_ready']);
|
|
34
|
+
const ALLOWED_UNKNOWNS = new Set([
|
|
35
|
+
'contactEmail',
|
|
36
|
+
'workflow',
|
|
37
|
+
'owner',
|
|
38
|
+
'repeatedFailure',
|
|
39
|
+
'runtime',
|
|
40
|
+
'urgencyAndTrigger',
|
|
41
|
+
'measurableCurrentImpact',
|
|
42
|
+
'decisionAuthority',
|
|
43
|
+
'budgetMechanism',
|
|
44
|
+
'priceUnderstanding',
|
|
45
|
+
'proofRequiredToDecide',
|
|
46
|
+
]);
|
|
47
|
+
const ALLOWED_CLOSE_BLOCKERS = new Set([
|
|
48
|
+
'qualification_review_unverified',
|
|
49
|
+
'lifecycle_not_evidence_qualified',
|
|
50
|
+
'intake_not_current',
|
|
51
|
+
'contact_path_missing',
|
|
52
|
+
'contact_path_invalid',
|
|
53
|
+
'material_unknowns_remaining',
|
|
54
|
+
'price_understanding_unconfirmed',
|
|
55
|
+
'offer_unavailable',
|
|
56
|
+
'route_not_closeable',
|
|
57
|
+
'checkout_url_unavailable',
|
|
58
|
+
]);
|
|
59
|
+
const ALLOWED_DISCOVERY_BLOCKERS = new Set([
|
|
60
|
+
'lifecycle_not_new',
|
|
61
|
+
'intake_not_current',
|
|
62
|
+
'contact_path_missing',
|
|
63
|
+
'contact_path_invalid',
|
|
64
|
+
'no_material_questions',
|
|
65
|
+
'disqualified',
|
|
66
|
+
'zero_spend_unverified',
|
|
67
|
+
]);
|
|
68
|
+
const ALLOWED_OFFER_IDS = new Set([
|
|
69
|
+
'workflow_hardening_diagnostic',
|
|
70
|
+
'workflow_hardening_sprint',
|
|
71
|
+
'workflow_reliability_operations',
|
|
72
|
+
'enterprise_governance_pilot',
|
|
73
|
+
'enterprise_reliability_operations',
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
function queueError(code, message, status = null) {
|
|
77
|
+
const error = new Error(message);
|
|
78
|
+
error.code = code;
|
|
79
|
+
if (status !== null) error.status = status;
|
|
80
|
+
return error;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parsePositiveInteger(value, label, maximum = Number.MAX_SAFE_INTEGER) {
|
|
84
|
+
const parsed = Number(value);
|
|
85
|
+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > maximum) {
|
|
86
|
+
throw queueError('invalid_argument', `${label} must be an integer from 1 to ${maximum}.`);
|
|
87
|
+
}
|
|
88
|
+
return parsed;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parseArgs(argv = []) {
|
|
92
|
+
const options = {
|
|
93
|
+
json: false,
|
|
94
|
+
statuses: ['new', 'qualified'],
|
|
95
|
+
limit: DEFAULT_LIMIT,
|
|
96
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
97
|
+
exportPrivatePath: null,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
for (const arg of argv) {
|
|
101
|
+
if (arg === '--json') {
|
|
102
|
+
options.json = true;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (arg.startsWith('--status=')) {
|
|
106
|
+
const statuses = [...new Set(arg.slice('--status='.length)
|
|
107
|
+
.split(',')
|
|
108
|
+
.map((value) => value.trim().toLowerCase())
|
|
109
|
+
.filter(Boolean))];
|
|
110
|
+
if (statuses.length === 0 || statuses.some((status) => !ALLOWED_STATUSES.has(status)) ||
|
|
111
|
+
(statuses.includes('all') && statuses.length !== 1)) {
|
|
112
|
+
throw queueError(
|
|
113
|
+
'invalid_argument',
|
|
114
|
+
`status must be all or a comma-separated subset of ${[...ALLOWED_STATUSES].filter((value) => value !== 'all').join(', ')}.`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
options.statuses = statuses;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (arg.startsWith('--limit=')) {
|
|
121
|
+
options.limit = parsePositiveInteger(arg.slice('--limit='.length), 'limit', MAX_LIMIT);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (arg.startsWith('--timeout-ms=')) {
|
|
125
|
+
options.timeoutMs = parsePositiveInteger(arg.slice('--timeout-ms='.length), 'timeout-ms', 120000);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (arg.startsWith('--export-private=')) {
|
|
129
|
+
const outputPath = arg.slice('--export-private='.length).trim();
|
|
130
|
+
if (!outputPath || !path.isAbsolute(outputPath)) {
|
|
131
|
+
throw queueError('invalid_argument', 'export-private must be an absolute local file path.');
|
|
132
|
+
}
|
|
133
|
+
options.exportPrivatePath = path.resolve(outputPath);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
throw queueError('invalid_argument', `Unknown argument: ${arg}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return options;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function normalizeText(value) {
|
|
143
|
+
const normalized = String(value || '').trim();
|
|
144
|
+
return normalized || null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function loadOperatorConfig(configPath = OPERATOR_CONFIG_PATH) {
|
|
148
|
+
try {
|
|
149
|
+
const stat = fs.lstatSync(configPath);
|
|
150
|
+
if (!stat.isFile() || stat.isSymbolicLink()) return { operatorKey: null, baseUrl: null };
|
|
151
|
+
if (process.platform !== 'win32' && (stat.mode & 0o077) !== 0) {
|
|
152
|
+
return { operatorKey: null, baseUrl: null };
|
|
153
|
+
}
|
|
154
|
+
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
155
|
+
return {
|
|
156
|
+
operatorKey: normalizeText(parsed.operatorKey),
|
|
157
|
+
baseUrl: normalizeText(parsed.baseUrl),
|
|
158
|
+
};
|
|
159
|
+
} catch {
|
|
160
|
+
return { operatorKey: null, baseUrl: null };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function resolveHostedQueueConfig(env = process.env, operatorConfig = loadOperatorConfig()) {
|
|
165
|
+
const runtime = resolveHostedBillingConfig();
|
|
166
|
+
return {
|
|
167
|
+
apiBaseUrl: normalizeText(env.THUMBGATE_BILLING_API_BASE_URL)
|
|
168
|
+
|| normalizeText(operatorConfig.baseUrl)
|
|
169
|
+
|| runtime.billingApiBaseUrl,
|
|
170
|
+
apiKey: normalizeText(env.THUMBGATE_OPERATOR_KEY)
|
|
171
|
+
|| normalizeText(operatorConfig.operatorKey)
|
|
172
|
+
|| normalizeText(env.THUMBGATE_API_KEY),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function validateHostedConfig(config = {}) {
|
|
177
|
+
const apiBaseUrl = String(config.apiBaseUrl || '').trim();
|
|
178
|
+
const apiKey = String(config.apiKey || '').trim();
|
|
179
|
+
if (!apiBaseUrl || !apiKey) {
|
|
180
|
+
throw queueError(
|
|
181
|
+
'operator_config_missing',
|
|
182
|
+
'Hosted intake queue is not configured. Set THUMBGATE_OPERATOR_KEY or configure the local ThumbGate operator file.'
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
let parsed;
|
|
186
|
+
try {
|
|
187
|
+
parsed = new URL(apiBaseUrl);
|
|
188
|
+
} catch {
|
|
189
|
+
throw queueError('operator_config_invalid', 'Hosted intake queue base URL is invalid.');
|
|
190
|
+
}
|
|
191
|
+
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
|
|
192
|
+
throw queueError(
|
|
193
|
+
'operator_config_invalid',
|
|
194
|
+
'Hosted intake queue base URL must not contain credentials, query parameters, or fragments.'
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
const localHttp = parsed.protocol === 'http:' && ['localhost', '127.0.0.1', '::1'].includes(parsed.hostname);
|
|
198
|
+
if (parsed.protocol !== 'https:' && !localHttp) {
|
|
199
|
+
throw queueError('operator_config_invalid', 'Hosted intake queue requires HTTPS outside localhost.');
|
|
200
|
+
}
|
|
201
|
+
return { apiBaseUrl: parsed.toString(), apiKey };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function fetchWithTimeout(fetchImpl, url, options, timeoutMs) {
|
|
205
|
+
const controller = new AbortController();
|
|
206
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
207
|
+
try {
|
|
208
|
+
return await fetchImpl(url, { ...options, signal: controller.signal });
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (error && error.name === 'AbortError') {
|
|
211
|
+
throw queueError('intake_queue_timeout', `Hosted intake queue timed out after ${timeoutMs}ms.`);
|
|
212
|
+
}
|
|
213
|
+
throw queueError('intake_queue_unreachable', 'Hosted intake queue could not be reached.');
|
|
214
|
+
} finally {
|
|
215
|
+
clearTimeout(timer);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function assertPrivateResponse(response) {
|
|
220
|
+
const cacheControl = String(response.headers?.get?.('cache-control') || '').toLowerCase();
|
|
221
|
+
const vary = String(response.headers?.get?.('vary') || '').toLowerCase();
|
|
222
|
+
if (!cacheControl.includes('private') || !cacheControl.includes('no-store') ||
|
|
223
|
+
!vary.split(',').map((value) => value.trim()).includes('authorization')) {
|
|
224
|
+
throw queueError(
|
|
225
|
+
'unsafe_cache_policy',
|
|
226
|
+
'Hosted intake queue response is missing the required private, no-store, Vary: Authorization cache policy.'
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function validateQueuePayload(payload) {
|
|
232
|
+
const counters = payload && typeof payload === 'object' ? [
|
|
233
|
+
payload.total,
|
|
234
|
+
payload.eligibleTotal,
|
|
235
|
+
payload.returned,
|
|
236
|
+
payload.approvalReadyTotal,
|
|
237
|
+
payload.discoveryReadyTotal,
|
|
238
|
+
] : [];
|
|
239
|
+
if (!payload || typeof payload !== 'object' || !Array.isArray(payload.leads) ||
|
|
240
|
+
counters.some((value) => !Number.isSafeInteger(value) || value < 0) ||
|
|
241
|
+
payload.eligibleTotal > payload.total || payload.returned !== payload.leads.length ||
|
|
242
|
+
payload.returned > payload.eligibleTotal ||
|
|
243
|
+
payload.approvalReadyTotal + payload.discoveryReadyTotal > payload.eligibleTotal) {
|
|
244
|
+
throw queueError('invalid_queue_payload', 'Hosted intake queue returned an invalid payload.');
|
|
245
|
+
}
|
|
246
|
+
return payload;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function leadRef(leadId) {
|
|
250
|
+
return crypto.createHash('sha256').update(String(leadId || '')).digest('hex').slice(0, 12);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function allowlistedText(value, allowed) {
|
|
254
|
+
const normalized = String(value || '').trim();
|
|
255
|
+
return allowed.has(normalized) ? normalized : null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function allowlistedList(values, allowed) {
|
|
259
|
+
if (!Array.isArray(values)) return [];
|
|
260
|
+
return [...new Set(values.map((value) => allowlistedText(value, allowed)).filter(Boolean))];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function safeTimestamp(value) {
|
|
264
|
+
const parsed = new Date(String(value || ''));
|
|
265
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function summarizeLead(lead = {}) {
|
|
269
|
+
const card = lead.qualificationCard || {};
|
|
270
|
+
const discoveryPacket = lead.discoveryPacket || {};
|
|
271
|
+
const closePacket = lead.closePacket || {};
|
|
272
|
+
const closeOfferId = allowlistedText(closePacket.offer?.offerId, ALLOWED_OFFER_IDS);
|
|
273
|
+
const recommendedOfferId = allowlistedText(card.recommendedOffer?.offerId, ALLOWED_OFFER_IDS);
|
|
274
|
+
const closePriceCents = Number.isSafeInteger(closePacket.offer?.priceCents) && closePacket.offer.priceCents >= 0
|
|
275
|
+
? closePacket.offer.priceCents
|
|
276
|
+
: null;
|
|
277
|
+
const recommendedPriceCents = Number.isSafeInteger(card.recommendedOffer?.priceCents) && card.recommendedOffer.priceCents >= 0
|
|
278
|
+
? card.recommendedOffer.priceCents
|
|
279
|
+
: null;
|
|
280
|
+
return {
|
|
281
|
+
ref: leadRef(lead.leadId),
|
|
282
|
+
submittedAt: safeTimestamp(lead.submittedAt),
|
|
283
|
+
updatedAt: safeTimestamp(lead.updatedAt),
|
|
284
|
+
lifecycleStatus: allowlistedText(lead.status, ALLOWED_STATUSES),
|
|
285
|
+
priorityRank: Number.isInteger(lead.priorityRank) ? lead.priorityRank : null,
|
|
286
|
+
priorityScore: Number.isFinite(card.priorityScore) ? card.priorityScore : null,
|
|
287
|
+
priorityBand: allowlistedText(card.priorityBand, ALLOWED_PRIORITY_BANDS),
|
|
288
|
+
fitBand: allowlistedText(card.fitBand, ALLOWED_FIT_BANDS),
|
|
289
|
+
route: allowlistedText(card.route, ALLOWED_ROUTES),
|
|
290
|
+
unknowns: allowlistedList(card.unknowns, ALLOWED_UNKNOWNS),
|
|
291
|
+
nextOperatorStep: allowlistedText(lead.nextOperatorStep, ALLOWED_OPERATOR_STEPS),
|
|
292
|
+
discoveryStatus: allowlistedText(discoveryPacket.status, ALLOWED_DISCOVERY_STATUSES),
|
|
293
|
+
discoveryBlockers: allowlistedList(discoveryPacket.blockers, ALLOWED_DISCOVERY_BLOCKERS),
|
|
294
|
+
closeStatus: allowlistedText(closePacket.status, ALLOWED_CLOSE_STATUSES),
|
|
295
|
+
closeBlockers: allowlistedList(closePacket.blockers, ALLOWED_CLOSE_BLOCKERS),
|
|
296
|
+
offerId: closeOfferId || recommendedOfferId,
|
|
297
|
+
priceCents: closePriceCents ?? recommendedPriceCents,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function summarizeQueue(payload, apiBaseUrl) {
|
|
302
|
+
const byStatus = {};
|
|
303
|
+
for (const status of ALLOWED_STATUSES) {
|
|
304
|
+
if (status === 'all') continue;
|
|
305
|
+
const count = payload.byStatus?.[status];
|
|
306
|
+
if (Number.isSafeInteger(count) && count >= 0) byStatus[status] = count;
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
generatedAt: safeTimestamp(payload.generatedAt),
|
|
310
|
+
source: 'hosted_operator_intake_queue',
|
|
311
|
+
apiOrigin: new URL(apiBaseUrl).origin,
|
|
312
|
+
queueAvailable: true,
|
|
313
|
+
total: payload.total,
|
|
314
|
+
eligibleTotal: payload.eligibleTotal,
|
|
315
|
+
returned: Number.isInteger(payload.returned) ? payload.returned : payload.leads.length,
|
|
316
|
+
approvalReadyTotal: payload.approvalReadyTotal,
|
|
317
|
+
discoveryReadyTotal: payload.discoveryReadyTotal,
|
|
318
|
+
primaryApprovalActionAvailable: Boolean(payload.primaryApprovalAction),
|
|
319
|
+
primaryDiscoveryActionAvailable: Boolean(payload.primaryDiscoveryAction),
|
|
320
|
+
byStatus,
|
|
321
|
+
filters: {
|
|
322
|
+
statuses: allowlistedList(payload.filters?.statuses, ALLOWED_STATUSES),
|
|
323
|
+
limit: Number.isInteger(payload.filters?.limit) ? payload.filters.limit : null,
|
|
324
|
+
},
|
|
325
|
+
latestSubmittedAt: safeTimestamp(payload.latestSubmittedAt),
|
|
326
|
+
oldestSubmittedAt: safeTimestamp(payload.oldestSubmittedAt),
|
|
327
|
+
leads: payload.leads.map(summarizeLead),
|
|
328
|
+
privacy: {
|
|
329
|
+
contactDetailsIncluded: false,
|
|
330
|
+
draftsIncluded: false,
|
|
331
|
+
approvalTokensIncluded: false,
|
|
332
|
+
rawLeadIdsIncluded: false,
|
|
333
|
+
},
|
|
334
|
+
externalActionAuthorized: false,
|
|
335
|
+
revenueRecognized: false,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function exportPrivateQueue(payload, outputPath) {
|
|
340
|
+
const parent = path.dirname(outputPath);
|
|
341
|
+
const realParent = fs.realpathSync(parent);
|
|
342
|
+
const safePath = path.join(realParent, path.basename(outputPath));
|
|
343
|
+
const serialized = `${JSON.stringify({
|
|
344
|
+
exportedAt: new Date().toISOString(),
|
|
345
|
+
sensitivity: 'private_buyer_intake_do_not_commit_or_share',
|
|
346
|
+
externalActionAuthorized: false,
|
|
347
|
+
revenueRecognized: false,
|
|
348
|
+
queue: payload,
|
|
349
|
+
}, null, 2)}\n`;
|
|
350
|
+
fs.writeFileSync(safePath, serialized, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
|
|
351
|
+
fs.chmodSync(safePath, 0o600);
|
|
352
|
+
const stat = fs.statSync(safePath);
|
|
353
|
+
return {
|
|
354
|
+
path: safePath,
|
|
355
|
+
bytes: stat.size,
|
|
356
|
+
mode: (stat.mode & 0o777).toString(8).padStart(3, '0'),
|
|
357
|
+
sha256: crypto.createHash('sha256').update(serialized).digest('hex'),
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function fetchWorkflowIntakeQueue(options = {}, config = resolveHostedQueueConfig(), fetchImpl = fetch) {
|
|
362
|
+
const effectiveOptions = {
|
|
363
|
+
statuses: Array.isArray(options.statuses) ? options.statuses : ['new', 'qualified'],
|
|
364
|
+
limit: Number.isInteger(options.limit) ? options.limit : DEFAULT_LIMIT,
|
|
365
|
+
timeoutMs: Number.isInteger(options.timeoutMs) ? options.timeoutMs : DEFAULT_TIMEOUT_MS,
|
|
366
|
+
};
|
|
367
|
+
const hosted = validateHostedConfig(config);
|
|
368
|
+
const url = new URL('/v1/intake/workflow-sprint/queue', hosted.apiBaseUrl);
|
|
369
|
+
url.searchParams.set('status', effectiveOptions.statuses.join(','));
|
|
370
|
+
url.searchParams.set('limit', String(effectiveOptions.limit));
|
|
371
|
+
const response = await fetchWithTimeout(fetchImpl, url, {
|
|
372
|
+
method: 'GET',
|
|
373
|
+
headers: {
|
|
374
|
+
authorization: `Bearer ${hosted.apiKey}`,
|
|
375
|
+
accept: 'application/json',
|
|
376
|
+
},
|
|
377
|
+
}, effectiveOptions.timeoutMs);
|
|
378
|
+
|
|
379
|
+
if (response.status === 404) {
|
|
380
|
+
throw queueError(
|
|
381
|
+
'release_required',
|
|
382
|
+
'Production does not expose the authenticated intake queue yet; deploy the reviewed revenue-evidence candidate first.',
|
|
383
|
+
404
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
if (response.status === 401 || response.status === 403) {
|
|
387
|
+
const authProbeUrl = new URL('/v1/billing/summary?window=today', hosted.apiBaseUrl);
|
|
388
|
+
let authProbe = null;
|
|
389
|
+
try {
|
|
390
|
+
authProbe = await fetchWithTimeout(fetchImpl, authProbeUrl, {
|
|
391
|
+
method: 'GET',
|
|
392
|
+
headers: {
|
|
393
|
+
authorization: `Bearer ${hosted.apiKey}`,
|
|
394
|
+
accept: 'application/json',
|
|
395
|
+
},
|
|
396
|
+
}, effectiveOptions.timeoutMs);
|
|
397
|
+
} catch {
|
|
398
|
+
// Keep the original queue authorization result authoritative when the
|
|
399
|
+
// compatibility probe is unavailable.
|
|
400
|
+
}
|
|
401
|
+
if (authProbe?.ok) {
|
|
402
|
+
throw queueError(
|
|
403
|
+
'release_required',
|
|
404
|
+
'Production accepts the operator key but does not enable the authenticated intake queue yet; deploy the reviewed revenue-evidence candidate first.',
|
|
405
|
+
response.status
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
throw queueError('operator_credentials_rejected', 'Hosted intake queue rejected operator credentials.', response.status);
|
|
409
|
+
}
|
|
410
|
+
if (!response.ok) {
|
|
411
|
+
throw queueError('intake_queue_http_error', `Hosted intake queue returned HTTP ${response.status}.`, response.status);
|
|
412
|
+
}
|
|
413
|
+
assertPrivateResponse(response);
|
|
414
|
+
const payload = validateQueuePayload(await response.json());
|
|
415
|
+
return { payload, summary: summarizeQueue(payload, hosted.apiBaseUrl) };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function formatSummary(summary, privateExport = null) {
|
|
419
|
+
const lines = [
|
|
420
|
+
'ThumbGate hosted intake close queue',
|
|
421
|
+
`Source: ${summary.source}`,
|
|
422
|
+
`Total: ${summary.total} | eligible: ${summary.eligibleTotal} | returned: ${summary.returned}`,
|
|
423
|
+
`Approval-ready: ${summary.approvalReadyTotal} (not authorized to send)`,
|
|
424
|
+
`Discovery-ready: ${summary.discoveryReadyTotal} (not authorized to send)`,
|
|
425
|
+
`Status: ${Object.entries(summary.byStatus).map(([key, value]) => `${key}=${value}`).join(', ') || 'none'}`,
|
|
426
|
+
`Latest: ${summary.latestSubmittedAt || 'none'}`,
|
|
427
|
+
'Privacy: buyer details, drafts, approval tokens, and raw lead IDs are withheld from terminal output.',
|
|
428
|
+
'Revenue recognized: no',
|
|
429
|
+
];
|
|
430
|
+
if (privateExport) {
|
|
431
|
+
lines.push(`Private export: ${privateExport.path}`);
|
|
432
|
+
lines.push(`Private export proof: bytes=${privateExport.bytes} mode=${privateExport.mode} sha256=${privateExport.sha256}`);
|
|
433
|
+
}
|
|
434
|
+
return `${lines.join('\n')}\n`;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function main(argv = process.argv.slice(2)) {
|
|
438
|
+
const options = parseArgs(argv);
|
|
439
|
+
const { payload, summary } = await fetchWorkflowIntakeQueue(options);
|
|
440
|
+
const privateExport = options.exportPrivatePath
|
|
441
|
+
? exportPrivateQueue(payload, options.exportPrivatePath)
|
|
442
|
+
: null;
|
|
443
|
+
if (options.json) {
|
|
444
|
+
process.stdout.write(`${JSON.stringify({ ...summary, privateExport }, null, 2)}\n`);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
process.stdout.write(formatSummary(summary, privateExport));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
module.exports = {
|
|
451
|
+
ALLOWED_STATUSES,
|
|
452
|
+
ALLOWED_CLOSE_BLOCKERS,
|
|
453
|
+
ALLOWED_DISCOVERY_BLOCKERS,
|
|
454
|
+
ALLOWED_OFFER_IDS,
|
|
455
|
+
ALLOWED_UNKNOWNS,
|
|
456
|
+
DEFAULT_LIMIT,
|
|
457
|
+
DEFAULT_TIMEOUT_MS,
|
|
458
|
+
MAX_LIMIT,
|
|
459
|
+
OPERATOR_CONFIG_PATH,
|
|
460
|
+
parseArgs,
|
|
461
|
+
loadOperatorConfig,
|
|
462
|
+
resolveHostedQueueConfig,
|
|
463
|
+
validateHostedConfig,
|
|
464
|
+
assertPrivateResponse,
|
|
465
|
+
validateQueuePayload,
|
|
466
|
+
leadRef,
|
|
467
|
+
allowlistedText,
|
|
468
|
+
allowlistedList,
|
|
469
|
+
safeTimestamp,
|
|
470
|
+
summarizeLead,
|
|
471
|
+
summarizeQueue,
|
|
472
|
+
exportPrivateQueue,
|
|
473
|
+
fetchWorkflowIntakeQueue,
|
|
474
|
+
formatSummary,
|
|
475
|
+
main,
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
if (require.main === module) { // NOSONAR
|
|
479
|
+
main().catch((error) => {
|
|
480
|
+
process.stderr.write(`${error.code || 'intake_queue_error'}: ${error.message}\n`);
|
|
481
|
+
process.exit(1);
|
|
482
|
+
});
|
|
483
|
+
}
|