thumbgate 1.27.20 → 1.28.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 +2 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +124 -129
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/mcp/server-stdio.js +18 -33
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +95 -78
- package/bin/postinstall.js +1 -1
- package/config/entitlement-public-keys.json +6 -0
- package/config/gates/default.json +3 -3
- package/config/github-about.json +2 -5
- package/config/merge-quality-checks.json +3 -0
- package/config/post-deploy-marketing-pages.json +1 -1
- package/hooks/hooks.json +38 -0
- package/package.json +28 -10
- package/public/about.html +1 -4
- package/public/agent-manager.html +6 -6
- package/public/agents-cost-savings.html +3 -3
- package/public/ai-malpractice-prevention.html +1 -1
- package/public/blog.html +12 -9
- package/public/chatgpt-app.html +3 -3
- package/public/codex-enterprise.html +3 -3
- package/public/codex-plugin.html +5 -5
- package/public/compare.html +10 -10
- package/public/dashboard.html +1 -1
- package/public/diagnostic.html +23 -1
- package/public/federal.html +2 -2
- package/public/guide.html +14 -14
- package/public/index.html +202 -220
- package/public/install.html +14 -14
- package/public/learn.html +4 -17
- package/public/numbers.html +2 -2
- package/public/partner-intake.html +198 -0
- package/public/pricing.html +61 -31
- package/public/pro.html +2 -2
- package/scripts/agent-reward-model.js +13 -0
- package/scripts/bayes-optimal-gate.js +6 -1
- package/scripts/billing.js +815 -137
- package/scripts/checkout-attribution-reference.js +85 -0
- package/scripts/claude-feedback-sync.js +23 -6
- package/scripts/cli-feedback.js +33 -8
- package/scripts/commercial-offer.js +3 -3
- package/scripts/entitlement.js +250 -0
- package/scripts/export-databricks-bundle.js +5 -0
- package/scripts/export-dpo-pairs.js +6 -0
- package/scripts/export-hf-dataset.js +5 -0
- package/scripts/feedback-loop.js +290 -1
- package/scripts/feedback-quality.js +25 -26
- package/scripts/feedback-sanitizer.js +151 -3
- package/scripts/gates-engine.js +134 -43
- package/scripts/hosted-config.js +9 -2
- package/scripts/imperative-detector.js +85 -0
- package/scripts/intervention-policy.js +13 -0
- package/scripts/mailer/resend-mailer.js +11 -5
- package/scripts/pr-manager.js +9 -22
- package/scripts/pro-local-dashboard.js +198 -0
- package/scripts/risk-scorer.js +6 -0
- package/scripts/secret-scanner.js +363 -68
- package/scripts/self-protection.js +21 -36
- package/scripts/seo-gsd.js +2 -2
- package/scripts/thompson-sampling.js +11 -2
- package/src/api/server.js +202 -22
- package/public/assets/brand/github-social-preview.png +0 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Attribution survival for EXTERNAL Stripe Payment Links.
|
|
4
|
+
//
|
|
5
|
+
// The $499 diagnostic and the sprint check out via raw `buy.stripe.com` Payment
|
|
6
|
+
// Links. Payment Links DROP appended utm_* query params and carry NO session
|
|
7
|
+
// metadata — but they DO preserve `client_reference_id` into
|
|
8
|
+
// `checkout.session.completed`. Without packing attribution into that field, a
|
|
9
|
+
// marketplace-attributed paid checkout (e.g. utm_source=aiventyx) is recorded as
|
|
10
|
+
// source=unknown, so it can neither be credited to the marketplace partner nor
|
|
11
|
+
// reported. This module packs/parses a compact, URL- and Stripe-safe reference.
|
|
12
|
+
//
|
|
13
|
+
// Current format: `tg2<source><traceId><acquisitionId><planId>`, with every
|
|
14
|
+
// field encoded as a two-digit length followed by the cleaned value. The
|
|
15
|
+
// parser remains backward-compatible with the original three-field `tg1`
|
|
16
|
+
// format. Stripe accepts only [A-Za-z0-9_-], so punctuation delimiters are
|
|
17
|
+
// invalid. Length-prefixing preserves valid hyphens and underscores without
|
|
18
|
+
// introducing an unsupported delimiter and keeps the value under Stripe's
|
|
19
|
+
// 200-character limit.
|
|
20
|
+
|
|
21
|
+
const CURRENT_PREFIX = 'tg2';
|
|
22
|
+
const LEGACY_PREFIX = 'tg1';
|
|
23
|
+
const MAX_FIELD = 45;
|
|
24
|
+
|
|
25
|
+
function cleanField(value, maxLength = MAX_FIELD) {
|
|
26
|
+
return String(value == null ? '' : value)
|
|
27
|
+
.replace(/[^A-Za-z0-9_-]/g, '')
|
|
28
|
+
.slice(0, maxLength);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function encodeField(value) {
|
|
32
|
+
const field = cleanField(value);
|
|
33
|
+
return `${String(field.length).padStart(2, '0')}${field}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Build a Stripe `client_reference_id` from checkout metadata. Returns null when
|
|
37
|
+
// there is no attributable source (so callers append nothing).
|
|
38
|
+
function packCheckoutReference(metadata = {}) {
|
|
39
|
+
const source = cleanField(metadata.utmSource || metadata.source);
|
|
40
|
+
if (!source) return null;
|
|
41
|
+
const traceId = cleanField(metadata.traceId);
|
|
42
|
+
const acquisitionId = cleanField(metadata.acquisitionId);
|
|
43
|
+
const planId = cleanField(metadata.planId || metadata.plan_id);
|
|
44
|
+
return `${CURRENT_PREFIX}${encodeField(source)}${encodeField(traceId)}${encodeField(acquisitionId)}${encodeField(planId)}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Parse a `client_reference_id` produced by packCheckoutReference. Returns null
|
|
48
|
+
// for anything that is not a recognized tg1 reference with a real source.
|
|
49
|
+
function parseCheckoutReference(clientReferenceId) {
|
|
50
|
+
const raw = String(clientReferenceId == null ? '' : clientReferenceId);
|
|
51
|
+
const prefix = raw.startsWith(CURRENT_PREFIX)
|
|
52
|
+
? CURRENT_PREFIX
|
|
53
|
+
: raw.startsWith(LEGACY_PREFIX)
|
|
54
|
+
? LEGACY_PREFIX
|
|
55
|
+
: null;
|
|
56
|
+
if (!prefix) return null;
|
|
57
|
+
const fieldCount = prefix === CURRENT_PREFIX ? 4 : 3;
|
|
58
|
+
const maxFieldLength = prefix === CURRENT_PREFIX ? MAX_FIELD : 60;
|
|
59
|
+
|
|
60
|
+
const fields = [];
|
|
61
|
+
let cursor = prefix.length;
|
|
62
|
+
for (let index = 0; index < fieldCount; index += 1) {
|
|
63
|
+
const lengthToken = raw.slice(cursor, cursor + 2);
|
|
64
|
+
if (!/^\d{2}$/.test(lengthToken)) return null;
|
|
65
|
+
const fieldLength = Number(lengthToken);
|
|
66
|
+
if (fieldLength > maxFieldLength) return null;
|
|
67
|
+
cursor += 2;
|
|
68
|
+
const field = raw.slice(cursor, cursor + fieldLength);
|
|
69
|
+
if (field.length !== fieldLength || cleanField(field, maxFieldLength) !== field) return null;
|
|
70
|
+
fields.push(field);
|
|
71
|
+
cursor += fieldLength;
|
|
72
|
+
}
|
|
73
|
+
if (cursor !== raw.length) return null;
|
|
74
|
+
|
|
75
|
+
const [source, traceId, acquisitionId, planId] = fields;
|
|
76
|
+
if (!source) return null;
|
|
77
|
+
return {
|
|
78
|
+
source,
|
|
79
|
+
traceId: traceId || null,
|
|
80
|
+
acquisitionId: acquisitionId || null,
|
|
81
|
+
planId: planId || null,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { packCheckoutReference, parseCheckoutReference };
|
|
@@ -5,11 +5,12 @@ const fs = require('fs');
|
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const {
|
|
7
7
|
captureFeedback,
|
|
8
|
+
buildFeedbackSourceIdentity,
|
|
8
9
|
getFeedbackPaths,
|
|
9
10
|
readJSONL,
|
|
10
11
|
analyzeFeedback,
|
|
11
12
|
} = require('./feedback-loop');
|
|
12
|
-
const { normalizeFeedbackText } = require('./feedback-quality');
|
|
13
|
+
const { detectFeedbackSignal, normalizeFeedbackText } = require('./feedback-quality');
|
|
13
14
|
const {
|
|
14
15
|
resolveFeedbackDir,
|
|
15
16
|
resolveProjectDir,
|
|
@@ -126,10 +127,8 @@ function parseHistoryTimestamp(value) {
|
|
|
126
127
|
}
|
|
127
128
|
|
|
128
129
|
function detectSignal(text) {
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
if (/(thumbs?\s*up|that worked|looks good|nice work|perfect|good job)/i.test(normalized)) return 'up';
|
|
132
|
-
return null;
|
|
130
|
+
const detected = detectFeedbackSignal(text);
|
|
131
|
+
return detected ? detected.signal : null;
|
|
133
132
|
}
|
|
134
133
|
|
|
135
134
|
function extractPromptText(entry) {
|
|
@@ -179,6 +178,9 @@ function toHistoryCandidate(entry, options = {}) {
|
|
|
179
178
|
promptText,
|
|
180
179
|
signal,
|
|
181
180
|
timestampMs: parseHistoryTimestamp(entry.timestamp),
|
|
181
|
+
sessionId: entry.sessionId || entry.session_id || null,
|
|
182
|
+
promptId: entry.promptId || entry.prompt_id || null,
|
|
183
|
+
projectDir: entry.project || entry.cwd || null,
|
|
182
184
|
};
|
|
183
185
|
}
|
|
184
186
|
|
|
@@ -261,15 +263,30 @@ function syncClaudeHistoryFeedback(options = {}) {
|
|
|
261
263
|
continue;
|
|
262
264
|
}
|
|
263
265
|
|
|
266
|
+
const sourceEvent = buildFeedbackSourceIdentity({
|
|
267
|
+
signal: candidate.signal,
|
|
268
|
+
promptText: candidate.promptText,
|
|
269
|
+
sessionId: candidate.sessionId,
|
|
270
|
+
promptId: candidate.promptId,
|
|
271
|
+
projectDir: candidate.projectDir,
|
|
272
|
+
timestamp: candidate.timestampMs,
|
|
273
|
+
source: 'claude-history',
|
|
274
|
+
});
|
|
264
275
|
const captureResult = captureFeedback({
|
|
265
276
|
signal: candidate.signal,
|
|
266
277
|
context: candidate.promptText,
|
|
267
278
|
whatWentWrong: candidate.signal === 'down' ? candidate.promptText : undefined,
|
|
268
279
|
whatWorked: candidate.signal === 'up' ? candidate.promptText : undefined,
|
|
269
280
|
tags: ['claude-history-sync', 'auto-capture-fallback'],
|
|
281
|
+
sourceEvent,
|
|
270
282
|
});
|
|
271
283
|
|
|
272
|
-
if (captureResult
|
|
284
|
+
if (captureResult?.duplicate) {
|
|
285
|
+
processedIds.add(candidate.externalId);
|
|
286
|
+
skippedCount += 1;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (captureResult?.feedbackEvent) {
|
|
273
290
|
existingEntries.push(captureResult.feedbackEvent);
|
|
274
291
|
}
|
|
275
292
|
processedIds.add(candidate.externalId);
|
package/scripts/cli-feedback.js
CHANGED
|
@@ -29,6 +29,7 @@ const G = '\x1b[32m';
|
|
|
29
29
|
const R = '\x1b[31m';
|
|
30
30
|
const C = '\x1b[36m';
|
|
31
31
|
const M = '\x1b[35m';
|
|
32
|
+
const Y = '\x1b[33m';
|
|
32
33
|
const D = '\x1b[90m';
|
|
33
34
|
const BD = '\x1b[1m';
|
|
34
35
|
const RST = '\x1b[0m';
|
|
@@ -43,9 +44,10 @@ const RST = '\x1b[0m';
|
|
|
43
44
|
* @param {Array} [opts.chatHistory] - Conversation messages for distillation
|
|
44
45
|
* @param {string} [opts.whatWentWrong] - For thumbs down
|
|
45
46
|
* @param {string} [opts.whatWorked] - For thumbs up
|
|
47
|
+
* @param {Object} [opts.sourceEvent] - Hashed source identity for idempotent hook capture
|
|
46
48
|
* @returns {Object} Result with feedback + lesson + stats
|
|
47
49
|
*/
|
|
48
|
-
function processInlineFeedback({ signal, context, chatHistory, whatWentWrong, whatWorked } = {}) {
|
|
50
|
+
function processInlineFeedback({ signal, context, chatHistory, whatWentWrong, whatWorked, sourceEvent } = {}) {
|
|
49
51
|
const isDown = signal === 'down' || signal === 'negative';
|
|
50
52
|
|
|
51
53
|
// 1. Capture the feedback
|
|
@@ -56,6 +58,7 @@ function processInlineFeedback({ signal, context, chatHistory, whatWentWrong, wh
|
|
|
56
58
|
context: context || (isDown ? 'Thumbs down from CLI' : 'Thumbs up from CLI'),
|
|
57
59
|
whatWentWrong: whatWentWrong || undefined,
|
|
58
60
|
whatWorked: whatWorked || undefined,
|
|
61
|
+
sourceEvent,
|
|
59
62
|
});
|
|
60
63
|
} catch (err) {
|
|
61
64
|
feedbackResult = { accepted: false, reason: err.message };
|
|
@@ -73,7 +76,15 @@ function processInlineFeedback({ signal, context, chatHistory, whatWentWrong, wh
|
|
|
73
76
|
const recentLesson = getRecentLesson();
|
|
74
77
|
const stats = getLessonStats();
|
|
75
78
|
|
|
76
|
-
|
|
79
|
+
// 4. If the user wrote an explicit "never …" / "always …" directive, surface
|
|
80
|
+
// an OFFER (never auto-act): "never" on a thumbs-down → offer force-gate now.
|
|
81
|
+
let forceGateHint = null;
|
|
82
|
+
try {
|
|
83
|
+
const { suggestForceGate } = require('./imperative-detector');
|
|
84
|
+
forceGateHint = suggestForceGate({ signal, text: whatWentWrong || whatWorked || context });
|
|
85
|
+
} catch { /* detector optional; never block capture on it */ }
|
|
86
|
+
|
|
87
|
+
return { feedbackResult, distillResult, recentLesson, stats, forceGateHint };
|
|
77
88
|
}
|
|
78
89
|
|
|
79
90
|
/**
|
|
@@ -81,23 +92,37 @@ function processInlineFeedback({ signal, context, chatHistory, whatWentWrong, wh
|
|
|
81
92
|
*/
|
|
82
93
|
function formatCliOutput(result) {
|
|
83
94
|
const lines = [];
|
|
84
|
-
const
|
|
95
|
+
const feedbackResult = result.feedbackResult;
|
|
96
|
+
const feedbackSignal = feedbackResult?.signal || feedbackResult?.feedbackEvent?.signal;
|
|
85
97
|
const isDown = ['down', 'negative', 'thumbs_down'].includes(feedbackSignal);
|
|
86
98
|
|
|
87
99
|
// Header
|
|
88
|
-
if (
|
|
100
|
+
if (feedbackResult?.duplicate) {
|
|
101
|
+
lines.push(`${Y}${BD}Feedback already captured${RST}`);
|
|
102
|
+
const feedbackId = feedbackResult.feedbackEvent?.id;
|
|
103
|
+
const memoryId = feedbackResult.memoryRecord?.id;
|
|
104
|
+
if (feedbackId) lines.push(`${D} Feedback ID: ${feedbackId}${RST}`);
|
|
105
|
+
if (memoryId) lines.push(`${D} Memory ID : ${memoryId}${RST}`);
|
|
106
|
+
} else if (feedbackResult && feedbackResult.accepted !== false) {
|
|
89
107
|
lines.push(`${isDown ? R : G}${BD}${isDown ? '👎 Thumbs down recorded' : '👍 Thumbs up recorded'}${RST}`);
|
|
90
|
-
const feedbackId =
|
|
91
|
-
const memoryId =
|
|
108
|
+
const feedbackId = feedbackResult.feedbackEvent?.id || feedbackResult.id;
|
|
109
|
+
const memoryId = feedbackResult.memoryRecord?.id || feedbackResult.memoryId;
|
|
92
110
|
if (feedbackId) {
|
|
93
111
|
lines.push(`${D} Feedback ID: ${feedbackId}${RST}`);
|
|
94
112
|
if (memoryId) lines.push(`${D} Memory ID : ${memoryId}${RST}`);
|
|
95
113
|
// Echo feedback ID to stderr so it's visible directly in the terminal,
|
|
96
114
|
// not hidden behind Claude Code's "ctrl+o to expand" MCP call collapse.
|
|
97
|
-
|
|
115
|
+
const capturedIds = memoryId ? `${feedbackId}, ${memoryId}` : feedbackId;
|
|
116
|
+
process.stderr.write(`✅ Feedback captured (${capturedIds})\n`);
|
|
98
117
|
}
|
|
99
118
|
} else {
|
|
100
|
-
lines.push(`${R}Feedback not accepted: ${
|
|
119
|
+
lines.push(`${R}Feedback not accepted: ${feedbackResult?.reason || 'unknown'}${RST}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Explicit-directive offer (e.g. "never …" → offer immediate force-gate).
|
|
123
|
+
if (result.forceGateHint?.message) {
|
|
124
|
+
const color = result.forceGateHint.kind === 'force-gate-offer' ? Y : D;
|
|
125
|
+
lines.push('', `${color}💡 ${result.forceGateHint.message}${RST}`);
|
|
101
126
|
}
|
|
102
127
|
|
|
103
128
|
// Distilled lesson (if thumbs down)
|
|
@@ -67,11 +67,11 @@ function buildCaptureReceipt({ signal, feedbackId, memoryId, actionType } = {})
|
|
|
67
67
|
memoryId ? ` Local memory : ${memoryId}` : ' Local memory : saved locally',
|
|
68
68
|
actionType ? ` Rule pressure : ${actionType}` : ' Rule pressure : available for promotion',
|
|
69
69
|
' Free today : this proof protects this local machine',
|
|
70
|
-
' Pro
|
|
70
|
+
' Pro recall : keep this lesson searchable, exportable, and visible',
|
|
71
71
|
' Next proof : npx thumbgate stats',
|
|
72
72
|
' Cost proof : npx thumbgate cost',
|
|
73
73
|
'',
|
|
74
|
-
` Solo Pro : ${PRO_PRICE_LABEL} for
|
|
74
|
+
` Solo Pro : ${PRO_PRICE_LABEL} for recall, dashboard, adapters, and exports`,
|
|
75
75
|
` Upgrade : ${trackedProUrl('cli_capture_receipt', actionType || normalizedSignal.toLowerCase())}`,
|
|
76
76
|
` Enterprise : ${ENTERPRISE_PRICE_LABEL}; start with one repeated workflow failure`,
|
|
77
77
|
' https://thumbgate.ai/#workflow-sprint-intake',
|
|
@@ -105,7 +105,7 @@ function buildStatsReceipt(stats = {}) {
|
|
|
105
105
|
lines.push(` Failure pressure : ${negatives} negative ${pluralize(negatives, 'signal')}`);
|
|
106
106
|
}
|
|
107
107
|
lines.push(' Show the buyer : npx thumbgate cost');
|
|
108
|
-
lines.push(' Pro
|
|
108
|
+
lines.push(' Pro recall value : keep lessons/rules searchable, exportable, and visible');
|
|
109
109
|
lines.push(` Solo Pro : ${trackedProUrl('cli_stats_receipt', 'proof_seen')}`);
|
|
110
110
|
lines.push(' Enterprise : https://thumbgate.ai/#workflow-sprint-intake');
|
|
111
111
|
lines.push('');
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* entitlement.js — signed license entitlements for ThumbGate's paid tier.
|
|
5
|
+
*
|
|
6
|
+
* Replaces the old bypassable `tg_`/`tg_pro_` prefix check in license.js with a
|
|
7
|
+
* cryptographically-verifiable, offline-checkable license token.
|
|
8
|
+
*
|
|
9
|
+
* Token format (compact JWS-like, Ed25519 / "EdDSA"):
|
|
10
|
+
* base64url(header) "." base64url(payload) "." base64url(signature)
|
|
11
|
+
* header = { alg: "EdDSA", kid } // kid selects the public key
|
|
12
|
+
* payload = { tier, features[], exp, iat, customerId, keyId }
|
|
13
|
+
* sig = Ed25519 over `${b64url(header)}.${b64url(payload)}`
|
|
14
|
+
*
|
|
15
|
+
* The PRIVATE signing key lives only in the hosted billing service (or a local
|
|
16
|
+
* gitignored dev path). Only PUBLIC keys ship, in config/entitlement-public-keys.json,
|
|
17
|
+
* so any client can verify a license offline without contacting a server.
|
|
18
|
+
*
|
|
19
|
+
* Enforcement is opt-in via THUMBGATE_ENFORCE_ENTITLEMENTS so paid features can be
|
|
20
|
+
* gated without breaking existing users during rollout:
|
|
21
|
+
* - advisory (default): requireEntitlement returns {entitled:false, reason} and
|
|
22
|
+
* the caller may warn but proceed.
|
|
23
|
+
* - enforced (flag set): requireEntitlement throws EntitlementError when not entitled.
|
|
24
|
+
*
|
|
25
|
+
* LIMITATION — read before overselling this. This gate runs CLIENT-SIDE in an
|
|
26
|
+
* MIT, open-source, un-compiled package. It makes a license UN-FORGEABLE (you
|
|
27
|
+
* cannot mint a valid token without the private key), but it does NOT make the
|
|
28
|
+
* check UN-BYPASSABLE: anyone with the source can monkey-patch requireEntitlement
|
|
29
|
+
* or delete the gate. So this protects against fake keys and honest free-riding,
|
|
30
|
+
* and it is the correct authorization primitive for the HOSTED service — but the
|
|
31
|
+
* only real, un-bypassable protection for the crown-jewel intelligence is to run
|
|
32
|
+
* it SERVER-SIDE (client sends inputs, gets outputs, never sees the code/weights),
|
|
33
|
+
* with these tokens as the auth. Client-side gating is a speed bump, not a wall.
|
|
34
|
+
* See docs/COMMERCIALIZATION_STRATEGY.md.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const crypto = require('node:crypto');
|
|
38
|
+
const fs = require('node:fs');
|
|
39
|
+
const os = require('node:os');
|
|
40
|
+
const path = require('node:path');
|
|
41
|
+
|
|
42
|
+
const TIER_FEATURES = {
|
|
43
|
+
free: [],
|
|
44
|
+
pro: ['recall', 'lesson-search', 'unlimited-rules', 'data-export', 'hosted-dashboard', 'hosted-sync', 'learned-models'],
|
|
45
|
+
team: ['recall', 'lesson-search', 'unlimited-rules', 'data-export', 'hosted-dashboard', 'hosted-sync', 'learned-models', 'org-visibility'],
|
|
46
|
+
enterprise: ['recall', 'lesson-search', 'unlimited-rules', 'data-export', 'hosted-dashboard', 'hosted-sync', 'learned-models', 'org-visibility', 'sso', 'audit-log', 'compliance-export'],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
class EntitlementError extends Error {
|
|
50
|
+
constructor(message, code = 'entitlement_denied') {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = 'EntitlementError';
|
|
53
|
+
this.code = code;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const advisoryWarnings = new Set();
|
|
58
|
+
|
|
59
|
+
function b64urlEncode(buf) {
|
|
60
|
+
return Buffer.from(buf).toString('base64url');
|
|
61
|
+
}
|
|
62
|
+
function b64urlDecodeJson(str) {
|
|
63
|
+
return JSON.parse(Buffer.from(str, 'base64url').toString('utf8'));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isTrueEnv(value) {
|
|
67
|
+
if (!value) return false;
|
|
68
|
+
const v = String(value).trim().toLowerCase();
|
|
69
|
+
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isEnforced(env = process.env) {
|
|
73
|
+
return isTrueEnv(env.THUMBGATE_ENFORCE_ENTITLEMENTS);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Load the shipped public keyset ({ activeKid, keys: {kid: pem} }). */
|
|
77
|
+
function loadTrustedKeys(root = path.resolve(__dirname, '..')) {
|
|
78
|
+
try {
|
|
79
|
+
const p = path.join(root, 'config', 'entitlement-public-keys.json');
|
|
80
|
+
const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
81
|
+
return raw.keys || {};
|
|
82
|
+
} catch {
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Verify a license token offline. Returns a normalized result — never throws on
|
|
89
|
+
* bad input (returns { valid:false, reason }).
|
|
90
|
+
* @param {string} token
|
|
91
|
+
* @param {{ trustedKeys?: Record<string,string>, now?: number }} [opts]
|
|
92
|
+
*/
|
|
93
|
+
function verifyLicense(token, opts = {}) {
|
|
94
|
+
const trustedKeys = opts.trustedKeys || loadTrustedKeys();
|
|
95
|
+
const now = opts.now || Math.floor(nowMs() / 1000);
|
|
96
|
+
if (typeof token !== 'string' || !token.trim()) {
|
|
97
|
+
return { valid: false, reason: 'missing_token' };
|
|
98
|
+
}
|
|
99
|
+
// Reject the legacy bypassable prefix keys outright.
|
|
100
|
+
if (/^tg_/.test(token.trim())) {
|
|
101
|
+
return { valid: false, reason: 'legacy_prefix_key_not_a_signed_license' };
|
|
102
|
+
}
|
|
103
|
+
const parts = token.trim().split('.');
|
|
104
|
+
if (parts.length !== 3) return { valid: false, reason: 'malformed_token' };
|
|
105
|
+
const [h, p, s] = parts;
|
|
106
|
+
let header;
|
|
107
|
+
let payload;
|
|
108
|
+
try {
|
|
109
|
+
header = b64urlDecodeJson(h);
|
|
110
|
+
payload = b64urlDecodeJson(p);
|
|
111
|
+
} catch {
|
|
112
|
+
return { valid: false, reason: 'undecodable_token' };
|
|
113
|
+
}
|
|
114
|
+
if (header.alg !== 'EdDSA' || !header.kid) return { valid: false, reason: 'bad_header' };
|
|
115
|
+
const pubPem = trustedKeys[header.kid];
|
|
116
|
+
if (!pubPem) return { valid: false, reason: 'unknown_key_id' };
|
|
117
|
+
|
|
118
|
+
let signatureOk = false;
|
|
119
|
+
try {
|
|
120
|
+
signatureOk = crypto.verify(
|
|
121
|
+
null,
|
|
122
|
+
Buffer.from(`${h}.${p}`),
|
|
123
|
+
crypto.createPublicKey(pubPem),
|
|
124
|
+
Buffer.from(s, 'base64url')
|
|
125
|
+
);
|
|
126
|
+
} catch {
|
|
127
|
+
return { valid: false, reason: 'signature_verify_error' };
|
|
128
|
+
}
|
|
129
|
+
if (!signatureOk) return { valid: false, reason: 'bad_signature' };
|
|
130
|
+
|
|
131
|
+
if (typeof payload.exp === 'number' && payload.exp < now) {
|
|
132
|
+
return { valid: false, reason: 'expired', tier: payload.tier };
|
|
133
|
+
}
|
|
134
|
+
const tier = payload.tier || 'free';
|
|
135
|
+
const features = Array.isArray(payload.features) && payload.features.length
|
|
136
|
+
? payload.features
|
|
137
|
+
: (TIER_FEATURES[tier] || []);
|
|
138
|
+
return {
|
|
139
|
+
valid: true,
|
|
140
|
+
tier,
|
|
141
|
+
features,
|
|
142
|
+
customerId: payload.customerId || null,
|
|
143
|
+
keyId: header.kid,
|
|
144
|
+
exp: payload.exp || null,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Sign a license token. PRIVATE-key operation — runs in the hosted billing
|
|
150
|
+
* service (or a local dev/setup script), never in the shipped client at runtime.
|
|
151
|
+
* @param {string} privateKeyPem
|
|
152
|
+
* @param {{ tier:string, features?:string[], customerId?:string, kid:string, expSeconds?:number, iat?:number, exp?:number }} claims
|
|
153
|
+
*/
|
|
154
|
+
function issueLicense(privateKeyPem, claims) {
|
|
155
|
+
const header = { alg: 'EdDSA', kid: claims.kid };
|
|
156
|
+
const iat = claims.iat || Math.floor(nowMs() / 1000);
|
|
157
|
+
const exp = claims.exp || (claims.expSeconds ? iat + claims.expSeconds : undefined);
|
|
158
|
+
const payload = {
|
|
159
|
+
tier: claims.tier,
|
|
160
|
+
features: claims.features || TIER_FEATURES[claims.tier] || [],
|
|
161
|
+
customerId: claims.customerId || null,
|
|
162
|
+
keyId: claims.kid,
|
|
163
|
+
iat,
|
|
164
|
+
...(exp ? { exp } : {}),
|
|
165
|
+
};
|
|
166
|
+
const h = b64urlEncode(JSON.stringify(header));
|
|
167
|
+
const p = b64urlEncode(JSON.stringify(payload));
|
|
168
|
+
const sig = crypto.sign(null, Buffer.from(`${h}.${p}`), crypto.createPrivateKey(privateKeyPem));
|
|
169
|
+
return `${h}.${p}.${b64urlEncode(sig)}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Resolve the active license token from env or the local config file. */
|
|
173
|
+
function resolveLicenseToken(env = process.env) {
|
|
174
|
+
if (env.THUMBGATE_LICENSE && env.THUMBGATE_LICENSE.trim()) return env.THUMBGATE_LICENSE.trim();
|
|
175
|
+
try {
|
|
176
|
+
const p = path.join(os.homedir(), '.config', 'thumbgate', 'license.json');
|
|
177
|
+
const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
178
|
+
if (raw.license || raw.token) return String(raw.license || raw.token).trim();
|
|
179
|
+
} catch { /* no local license */ }
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Gate a paid feature. Advisory by default; throws EntitlementError only when
|
|
185
|
+
* THUMBGATE_ENFORCE_ENTITLEMENTS is set. Returns { entitled, tier, reason }.
|
|
186
|
+
* @param {string} feature
|
|
187
|
+
* @param {{ env?: NodeJS.ProcessEnv, trustedKeys?: object, token?: string }} [opts]
|
|
188
|
+
*/
|
|
189
|
+
function requireEntitlement(feature, opts = {}) {
|
|
190
|
+
const env = opts.env || process.env;
|
|
191
|
+
const token = opts.token !== undefined ? opts.token : resolveLicenseToken(env);
|
|
192
|
+
const result = verifyLicense(token, { trustedKeys: opts.trustedKeys });
|
|
193
|
+
const entitled = result.valid && result.features.includes(feature);
|
|
194
|
+
const decision = {
|
|
195
|
+
entitled,
|
|
196
|
+
tier: result.valid ? result.tier : 'free',
|
|
197
|
+
feature,
|
|
198
|
+
reason: entitled ? 'entitled' : (result.valid ? 'feature_not_in_tier' : result.reason),
|
|
199
|
+
enforced: isEnforced(env),
|
|
200
|
+
};
|
|
201
|
+
if (!entitled && decision.enforced) {
|
|
202
|
+
throw new EntitlementError(
|
|
203
|
+
`ThumbGate: "${feature}" requires a paid license (current tier: ${decision.tier}, reason: ${decision.reason}). `
|
|
204
|
+
+ `Get a license at https://thumbgate.ai/pricing, then set THUMBGATE_LICENSE or ~/.config/thumbgate/license.json.`,
|
|
205
|
+
decision.reason
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
return decision;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Shared paid-feature wrapper for commercial entrypoints. In advisory mode it
|
|
213
|
+
* emits one warning per feature/label and lets the caller continue; in enforced
|
|
214
|
+
* mode `requireEntitlement()` throws before this function returns.
|
|
215
|
+
*/
|
|
216
|
+
function requirePaidFeature(feature, opts = {}) {
|
|
217
|
+
const decision = requireEntitlement(feature, opts);
|
|
218
|
+
const label = opts.label || feature;
|
|
219
|
+
const warningKey = `${feature}:${label}`;
|
|
220
|
+
if (!decision.entitled && !opts.silent && !advisoryWarnings.has(warningKey)) {
|
|
221
|
+
advisoryWarnings.add(warningKey);
|
|
222
|
+
console.error(`⚠️ ThumbGate: ${label} requires a paid license (tier: ${decision.tier}). Advisory mode — set THUMBGATE_ENFORCE_ENTITLEMENTS=1 to enforce. License: https://thumbgate.ai/pricing`);
|
|
223
|
+
}
|
|
224
|
+
return decision;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function requireLearnedModelsEntitlement(opts = {}) {
|
|
228
|
+
return requirePaidFeature('learned-models', {
|
|
229
|
+
...opts,
|
|
230
|
+
label: opts.label || 'learned-models intelligence',
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Injectable clock (Date.now is fine at runtime; kept in one place for testability).
|
|
235
|
+
function nowMs() {
|
|
236
|
+
return Date.now();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
module.exports = {
|
|
240
|
+
verifyLicense,
|
|
241
|
+
issueLicense,
|
|
242
|
+
requireEntitlement,
|
|
243
|
+
requirePaidFeature,
|
|
244
|
+
requireLearnedModelsEntitlement,
|
|
245
|
+
resolveLicenseToken,
|
|
246
|
+
loadTrustedKeys,
|
|
247
|
+
isEnforced,
|
|
248
|
+
TIER_FEATURES,
|
|
249
|
+
EntitlementError,
|
|
250
|
+
};
|
|
@@ -129,6 +129,11 @@ function timestampSlug() {
|
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
function exportDatabricksBundle(feedbackDir = getDefaultFeedbackDir(), outputPath, options = {}) {
|
|
132
|
+
// Paid-feature gate (advisory by default; enforced via THUMBGATE_ENFORCE_ENTITLEMENTS=1).
|
|
133
|
+
const _ent = require('./entitlement').requireEntitlement('data-export');
|
|
134
|
+
if (!_ent.entitled) {
|
|
135
|
+
console.error(`⚠️ ThumbGate: Databricks bundle export is a paid feature (tier: ${_ent.tier}). Advisory mode — set THUMBGATE_ENFORCE_ENTITLEMENTS=1 to enforce. License: https://thumbgate.ai/pricing`);
|
|
136
|
+
}
|
|
132
137
|
const resolvedFeedbackDir = path.resolve(feedbackDir || getDefaultFeedbackDir());
|
|
133
138
|
const resolvedProofDir = path.resolve(options.proofDir || DEFAULT_PROOF_DIR);
|
|
134
139
|
const exportedAt = new Date().toISOString();
|
|
@@ -180,6 +180,12 @@ function toJSONL(pairs) {
|
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
function exportDpoFromMemories(memories) {
|
|
183
|
+
// Paid-feature gate: DPO export is a commercial (Pro+) capability. Advisory by
|
|
184
|
+
// default; enforced when THUMBGATE_ENFORCE_ENTITLEMENTS=1 (then this throws).
|
|
185
|
+
const _ent = require('./entitlement').requireEntitlement('data-export');
|
|
186
|
+
if (!_ent.entitled) {
|
|
187
|
+
console.error(`⚠️ ThumbGate: DPO export is a paid feature (tier: ${_ent.tier}). Advisory mode — set THUMBGATE_ENFORCE_ENTITLEMENTS=1 to enforce. License: https://thumbgate.ai/pricing`);
|
|
188
|
+
}
|
|
183
189
|
const errors = memories.filter((m) => m.category === 'error');
|
|
184
190
|
const learnings = memories.filter((m) => m.category === 'learning');
|
|
185
191
|
const result = buildDpoPairs(errors, learnings);
|
|
@@ -177,6 +177,11 @@ function buildDatasetInfo({ traceCount, preferenceCount, exportedAt }) {
|
|
|
177
177
|
* @returns {Object} Export summary
|
|
178
178
|
*/
|
|
179
179
|
function exportHfDataset(options = {}) {
|
|
180
|
+
// Paid-feature gate (advisory by default; enforced via THUMBGATE_ENFORCE_ENTITLEMENTS=1).
|
|
181
|
+
const _ent = require('./entitlement').requireEntitlement('data-export');
|
|
182
|
+
if (!_ent.entitled) {
|
|
183
|
+
console.error(`⚠️ ThumbGate: HuggingFace dataset export is a paid feature (tier: ${_ent.tier}). Advisory mode — set THUMBGATE_ENFORCE_ENTITLEMENTS=1 to enforce. License: https://thumbgate.ai/pricing`);
|
|
184
|
+
}
|
|
180
185
|
const feedbackDir = options.feedbackDir || resolveFeedbackDir();
|
|
181
186
|
const outputDir = options.outputDir || path.join(feedbackDir, 'hf-dataset');
|
|
182
187
|
const includeProvenance = options.includeProvenance !== false;
|