thumbgate 1.28.3 → 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 +5 -3
- package/scripts/commercial-offer.js +18 -10
- package/scripts/external-customer-audit.js +881 -0
- package/scripts/feedback-loop.js +49 -15
- 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/scripts/workflow-sentinel.js +4 -2
- package/src/api/server.js +521 -114
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Read recent JSONL rows since `sinceMs`, scanning at most maxBytes from EOF.
|
|
7
|
+
* Avoids loading multi-MB ledgers entirely into memory (export timeout fix).
|
|
8
|
+
*/
|
|
9
|
+
function readJsonlSinceTail(filePath, {
|
|
10
|
+
sinceMs = 0,
|
|
11
|
+
limit = 1000,
|
|
12
|
+
maxBytes = 8 * 1024 * 1024,
|
|
13
|
+
timestampKeys = ['timestamp', 'receivedAt', 'ts', 'createdAt'],
|
|
14
|
+
} = {}) {
|
|
15
|
+
if (!filePath || !fs.existsSync(filePath)) {
|
|
16
|
+
return { rows: [], totalAfterSince: 0, truncated: false, scannedBytes: 0 };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let stat;
|
|
20
|
+
try {
|
|
21
|
+
stat = fs.statSync(filePath);
|
|
22
|
+
} catch {
|
|
23
|
+
return { rows: [], totalAfterSince: 0, truncated: false, scannedBytes: 0 };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const size = stat.size || 0;
|
|
27
|
+
if (size === 0) {
|
|
28
|
+
return { rows: [], totalAfterSince: 0, truncated: false, scannedBytes: 0 };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const scanBytes = Math.min(size, Math.max(64 * 1024, maxBytes));
|
|
32
|
+
const start = Math.max(0, size - scanBytes);
|
|
33
|
+
let buf;
|
|
34
|
+
try {
|
|
35
|
+
const fd = fs.openSync(filePath, 'r');
|
|
36
|
+
try {
|
|
37
|
+
buf = Buffer.alloc(scanBytes);
|
|
38
|
+
fs.readSync(fd, buf, 0, scanBytes, start);
|
|
39
|
+
} finally {
|
|
40
|
+
fs.closeSync(fd);
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
return { rows: [], totalAfterSince: 0, truncated: false, scannedBytes: 0 };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let text = buf.toString('utf8');
|
|
47
|
+
// If we started mid-file, drop the partial first line.
|
|
48
|
+
if (start > 0) {
|
|
49
|
+
const nl = text.indexOf('\n');
|
|
50
|
+
text = nl >= 0 ? text.slice(nl + 1) : '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const matched = [];
|
|
54
|
+
for (const line of text.split('\n')) {
|
|
55
|
+
if (!line) continue;
|
|
56
|
+
let obj;
|
|
57
|
+
try {
|
|
58
|
+
obj = JSON.parse(line);
|
|
59
|
+
} catch {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
let parsed = NaN;
|
|
63
|
+
for (const key of timestampKeys) {
|
|
64
|
+
const raw = obj && obj[key];
|
|
65
|
+
if (raw == null) continue;
|
|
66
|
+
parsed = typeof raw === 'number' ? raw : Date.parse(raw);
|
|
67
|
+
if (Number.isFinite(parsed)) break;
|
|
68
|
+
}
|
|
69
|
+
// Preserve since filter: only include rows with a parseable timestamp
|
|
70
|
+
// inside the window. Legacy/malformed lines without timestamps must not
|
|
71
|
+
// inflate totalAfterSince or journey summaries.
|
|
72
|
+
if (Number.isFinite(parsed) && parsed >= sinceMs) {
|
|
73
|
+
matched.push(obj);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const totalAfterSince = matched.length;
|
|
78
|
+
const rows = matched.slice(-Math.max(1, limit));
|
|
79
|
+
return {
|
|
80
|
+
rows,
|
|
81
|
+
totalAfterSince,
|
|
82
|
+
truncated: totalAfterSince > rows.length || start > 0,
|
|
83
|
+
scannedBytes: scanBytes,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = {
|
|
88
|
+
readJsonlSinceTail,
|
|
89
|
+
};
|
|
@@ -99,16 +99,12 @@ function isEmbedderAvailable() {
|
|
|
99
99
|
const cfg = resolveGeminiEmbeddingConfig();
|
|
100
100
|
if (cfg && cfg.enabled && cfg.apiKey) return true;
|
|
101
101
|
} catch { /* policy module unavailable */ }
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
require.resolve('@huggingface/transformers');
|
|
105
|
-
return true;
|
|
106
|
-
} catch { /* not installed */ }
|
|
107
|
-
return false;
|
|
102
|
+
// The zero-dependency feature-hash provider is always available locally.
|
|
103
|
+
return true;
|
|
108
104
|
}
|
|
109
105
|
|
|
110
106
|
function defaultEmbedder() {
|
|
111
|
-
// Lazy: do not pull in LanceDB
|
|
107
|
+
// Lazy: do not pull in LanceDB or optional embedding providers at module require time.
|
|
112
108
|
const { embed } = require('./vector-store');
|
|
113
109
|
return embed;
|
|
114
110
|
}
|
|
@@ -581,6 +581,20 @@ async function main() {
|
|
|
581
581
|
const args = process.argv.slice(2);
|
|
582
582
|
const dryRun = args.includes('--dry-run');
|
|
583
583
|
const verbose = args.includes('--verbose') || args.includes('-v');
|
|
584
|
+
let hookMode = args.includes('--hook');
|
|
585
|
+
|
|
586
|
+
// Settings may already be cached by a running agent process. Detect the
|
|
587
|
+
// current Stop payload as well, so the legacy command (without --hook) is
|
|
588
|
+
// immediately JSON-safe before the host reloads its settings.
|
|
589
|
+
if (!hookMode && !process.stdin.isTTY) {
|
|
590
|
+
try {
|
|
591
|
+
const raw = fs.readFileSync(0, 'utf8');
|
|
592
|
+
const payload = raw ? JSON.parse(raw) : {};
|
|
593
|
+
hookMode = payload.hook_event_name === 'Stop';
|
|
594
|
+
} catch {
|
|
595
|
+
// Normal CLI invocation with empty/non-JSON stdin.
|
|
596
|
+
}
|
|
597
|
+
}
|
|
584
598
|
|
|
585
599
|
if (args.includes('--status')) {
|
|
586
600
|
const status = getMetaAgentStatus();
|
|
@@ -593,9 +607,13 @@ async function main() {
|
|
|
593
607
|
}
|
|
594
608
|
|
|
595
609
|
const mode = dryRun ? 'DRY RUN' : 'LIVE';
|
|
596
|
-
console.log(`Meta-agent loop starting [${mode}]...`);
|
|
610
|
+
if (!hookMode) console.log(`Meta-agent loop starting [${mode}]...`);
|
|
611
|
+
|
|
612
|
+
const manifest = await runMetaAgentLoop({ dryRun, verbose: hookMode ? false : true });
|
|
597
613
|
|
|
598
|
-
|
|
614
|
+
// Stop hooks may emit only empty stdout or a valid JSON object. The loop's
|
|
615
|
+
// work is intentionally silent in hook mode.
|
|
616
|
+
if (hookMode) return;
|
|
599
617
|
|
|
600
618
|
console.log(`Run ID : ${manifest.runId}`);
|
|
601
619
|
console.log(`Analysis mode : ${manifest.analysisMode}`);
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Load operator-local observability credentials into process.env.
|
|
5
|
+
*
|
|
6
|
+
* Sources (first wins per key, never overwrite an already-set env var):
|
|
7
|
+
* 1. process.env
|
|
8
|
+
* 2. ~/.config/thumbgate/observability.json
|
|
9
|
+
* 3. ~/.config/thumbgate/operator.json (operatorKey / baseUrl only)
|
|
10
|
+
* 4. Stripe managed secret files via resolveStripeSecretKey
|
|
11
|
+
*
|
|
12
|
+
* The JSON file is gitignored operator state. Never print secret values.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
const os = require('node:os');
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
|
|
19
|
+
const OBSERVABILITY_CONFIG_PATH = path.join(os.homedir(), '.config', 'thumbgate', 'observability.json');
|
|
20
|
+
const OPERATOR_CONFIG_PATH = path.join(os.homedir(), '.config', 'thumbgate', 'operator.json');
|
|
21
|
+
|
|
22
|
+
const JSON_KEY_TO_ENV = Object.freeze({
|
|
23
|
+
stripeSecretKey: 'STRIPE_SECRET_KEY',
|
|
24
|
+
plausibleApiKey: 'PLAUSIBLE_API_KEY',
|
|
25
|
+
plausibleSiteId: 'PLAUSIBLE_SITE_ID',
|
|
26
|
+
plausibleSiteIds: 'PLAUSIBLE_SITE_IDS',
|
|
27
|
+
plausibleRegisteredDomains: 'THUMBGATE_PLAUSIBLE_REGISTERED_DOMAINS',
|
|
28
|
+
posthogPersonalApiKey: 'POSTHOG_PERSONAL_API_KEY',
|
|
29
|
+
posthogProjectId: 'POSTHOG_PROJECT_ID',
|
|
30
|
+
operatorKey: 'THUMBGATE_OPERATOR_KEY',
|
|
31
|
+
apiKey: 'THUMBGATE_API_KEY',
|
|
32
|
+
publicAppOrigin: 'THUMBGATE_PUBLIC_APP_ORIGIN',
|
|
33
|
+
billingApiBaseUrl: 'THUMBGATE_BILLING_API_BASE_URL',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function normalizeText(value) {
|
|
37
|
+
if (value === undefined || value === null) return null;
|
|
38
|
+
const text = String(value).trim();
|
|
39
|
+
return text || null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readJsonFile(filePath) {
|
|
43
|
+
try {
|
|
44
|
+
if (!filePath || !fs.existsSync(filePath)) return null;
|
|
45
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function applyJsonToEnv(json, env) {
|
|
52
|
+
if (!json || typeof json !== 'object') return [];
|
|
53
|
+
const applied = [];
|
|
54
|
+
for (const [jsonKey, envKey] of Object.entries(JSON_KEY_TO_ENV)) {
|
|
55
|
+
if (normalizeText(env[envKey])) continue;
|
|
56
|
+
const value = normalizeText(json[jsonKey]);
|
|
57
|
+
if (!value) continue;
|
|
58
|
+
env[envKey] = value;
|
|
59
|
+
applied.push(envKey);
|
|
60
|
+
}
|
|
61
|
+
return applied;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function loadObservabilityEnv({
|
|
65
|
+
env = process.env,
|
|
66
|
+
observabilityPath = OBSERVABILITY_CONFIG_PATH,
|
|
67
|
+
operatorPath = OPERATOR_CONFIG_PATH,
|
|
68
|
+
applyStripeManagedFiles = true,
|
|
69
|
+
} = {}) {
|
|
70
|
+
const applied = [];
|
|
71
|
+
|
|
72
|
+
const observability = readJsonFile(observabilityPath);
|
|
73
|
+
applied.push(...applyJsonToEnv(observability, env));
|
|
74
|
+
|
|
75
|
+
const operator = readJsonFile(operatorPath);
|
|
76
|
+
if (operator) {
|
|
77
|
+
if (!normalizeText(env.THUMBGATE_OPERATOR_KEY) && normalizeText(operator.operatorKey)) {
|
|
78
|
+
env.THUMBGATE_OPERATOR_KEY = String(operator.operatorKey).trim();
|
|
79
|
+
applied.push('THUMBGATE_OPERATOR_KEY');
|
|
80
|
+
}
|
|
81
|
+
if (!normalizeText(env.THUMBGATE_BILLING_API_BASE_URL) && normalizeText(operator.baseUrl)) {
|
|
82
|
+
env.THUMBGATE_BILLING_API_BASE_URL = String(operator.baseUrl).trim();
|
|
83
|
+
applied.push('THUMBGATE_BILLING_API_BASE_URL');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (applyStripeManagedFiles && !normalizeText(env.STRIPE_SECRET_KEY)) {
|
|
88
|
+
try {
|
|
89
|
+
const { resolveStripeSecretKey } = require('./stripe-credentials');
|
|
90
|
+
const resolved = resolveStripeSecretKey({ env });
|
|
91
|
+
if (resolved.secretKey) {
|
|
92
|
+
env.STRIPE_SECRET_KEY = resolved.secretKey;
|
|
93
|
+
applied.push('STRIPE_SECRET_KEY');
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// stripe-credentials optional at load time
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Product-primary Plausible domain is always part of the registered set for
|
|
101
|
+
// doctor/automation unless the operator explicitly overrides site id.
|
|
102
|
+
if (!normalizeText(env.PLAUSIBLE_SITE_ID) && !normalizeText(env.THUMBGATE_PLAUSIBLE_REGISTERED_DOMAINS)) {
|
|
103
|
+
env.THUMBGATE_PLAUSIBLE_REGISTERED_DOMAINS = 'thumbgate.ai,thumbgate-production.up.railway.app';
|
|
104
|
+
applied.push('THUMBGATE_PLAUSIBLE_REGISTERED_DOMAINS');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
applied: [...new Set(applied)],
|
|
109
|
+
observabilityPath,
|
|
110
|
+
operatorPath,
|
|
111
|
+
hasStripe: Boolean(normalizeText(env.STRIPE_SECRET_KEY)),
|
|
112
|
+
hasPlausible: Boolean(normalizeText(env.PLAUSIBLE_API_KEY) && normalizeText(env.PLAUSIBLE_SITE_ID)),
|
|
113
|
+
hasPosthog: Boolean(normalizeText(env.POSTHOG_PERSONAL_API_KEY) && normalizeText(env.POSTHOG_PROJECT_ID)),
|
|
114
|
+
hasOperator: Boolean(normalizeText(env.THUMBGATE_OPERATOR_KEY) || normalizeText(env.THUMBGATE_API_KEY)),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function observabilityConfigTemplate() {
|
|
119
|
+
return {
|
|
120
|
+
stripeSecretKey: '',
|
|
121
|
+
plausibleApiKey: '',
|
|
122
|
+
plausibleSiteId: 'thumbgate.ai',
|
|
123
|
+
plausibleRegisteredDomains: 'thumbgate.ai,thumbgate-production.up.railway.app',
|
|
124
|
+
posthogPersonalApiKey: '',
|
|
125
|
+
posthogProjectId: '',
|
|
126
|
+
publicAppOrigin: 'https://thumbgate.ai',
|
|
127
|
+
billingApiBaseUrl: 'https://thumbgate-production.up.railway.app',
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = {
|
|
132
|
+
OBSERVABILITY_CONFIG_PATH,
|
|
133
|
+
OPERATOR_CONFIG_PATH,
|
|
134
|
+
JSON_KEY_TO_ENV,
|
|
135
|
+
loadObservabilityEnv,
|
|
136
|
+
observabilityConfigTemplate,
|
|
137
|
+
readJsonFile,
|
|
138
|
+
normalizeText,
|
|
139
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const {
|
|
8
|
+
OBSERVABILITY_CONFIG_PATH,
|
|
9
|
+
observabilityConfigTemplate,
|
|
10
|
+
loadObservabilityEnv,
|
|
11
|
+
} = require('./observability-env');
|
|
12
|
+
|
|
13
|
+
function ensureDir(filePath) {
|
|
14
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function main(argv = process.argv.slice(2)) {
|
|
18
|
+
const write = argv.includes('--write');
|
|
19
|
+
const print = argv.includes('--print') || !write;
|
|
20
|
+
const template = observabilityConfigTemplate();
|
|
21
|
+
|
|
22
|
+
if (print && !write) {
|
|
23
|
+
process.stdout.write(`${JSON.stringify(template, null, 2)}\n`);
|
|
24
|
+
process.stdout.write(
|
|
25
|
+
`\n# Write to ${OBSERVABILITY_CONFIG_PATH} with --write after filling secrets.\n` +
|
|
26
|
+
'# Never commit this file. Doctor/revenue tools load it automatically.\n'
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (write) {
|
|
31
|
+
ensureDir(OBSERVABILITY_CONFIG_PATH);
|
|
32
|
+
if (fs.existsSync(OBSERVABILITY_CONFIG_PATH)) {
|
|
33
|
+
process.stderr.write(`Refusing to overwrite existing ${OBSERVABILITY_CONFIG_PATH}\n`);
|
|
34
|
+
process.exitCode = 2;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
fs.writeFileSync(OBSERVABILITY_CONFIG_PATH, `${JSON.stringify(template, null, 2)}\n`, { mode: 0o600 });
|
|
38
|
+
process.stdout.write(`Wrote template ${OBSERVABILITY_CONFIG_PATH}\n`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const status = loadObservabilityEnv({ env: { ...process.env } });
|
|
42
|
+
process.stdout.write(JSON.stringify({
|
|
43
|
+
configPath: OBSERVABILITY_CONFIG_PATH,
|
|
44
|
+
hasStripe: status.hasStripe,
|
|
45
|
+
hasPlausible: status.hasPlausible,
|
|
46
|
+
hasPosthog: status.hasPosthog,
|
|
47
|
+
hasOperator: status.hasOperator,
|
|
48
|
+
}, null, 2) + '\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (require('node:path').resolve(process.argv[1] || '') === require('node:path').resolve(__filename)) {
|
|
52
|
+
main();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { main };
|
|
@@ -30,7 +30,11 @@ function getConfiguredRegisteredDomains(env = process.env) {
|
|
|
30
30
|
...splitDomains(env.PLAUSIBLE_REGISTERED_DOMAINS),
|
|
31
31
|
].map(normalizeDomain).filter(Boolean);
|
|
32
32
|
|
|
33
|
+
// Both product surfaces are first-class Plausible site ids for ThumbGate.
|
|
34
|
+
// Emitting data-domain=thumbgate.ai while only registering the Railway host
|
|
35
|
+
// previously made primary-domain traffic invisible to automation.
|
|
33
36
|
return [...new Set([
|
|
37
|
+
PRIMARY_PLAUSIBLE_DOMAIN,
|
|
34
38
|
FALLBACK_REGISTERED_PLAUSIBLE_DOMAIN,
|
|
35
39
|
...configured,
|
|
36
40
|
])];
|