@velaro/cli 1.4.104 → 1.4.105
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/bin/velaro.js +6 -0
- package/lib/commands/enterprise-reports.js +17 -2
- package/lib/commands/netsuite.js +18 -0
- package/lib/commands/quality-reports.js +11 -3
- package/lib/commands/sign.js +49 -0
- package/lib/commands/usage.js +28 -0
- package/lib/commands/visitor-link.js +90 -0
- package/lib/enterprise-reports.test.js +17 -0
- package/lib/sign-versions.js +58 -0
- package/lib/sign-versions.test.js +50 -0
- package/lib/visitor-link.js +61 -0
- package/lib/visitor-link.test.js +67 -0
- package/package.json +1 -1
package/bin/velaro.js
CHANGED
|
@@ -98,6 +98,7 @@ import { orderFormCommand } from '../lib/commands/order-form.js';
|
|
|
98
98
|
import { billingEmailPreferenceCommand } from '../lib/commands/billing-email-preference.js';
|
|
99
99
|
import { billingCommand } from '../lib/commands/billing.js';
|
|
100
100
|
import { couponGrantCommand } from '../lib/commands/coupon-grant.js';
|
|
101
|
+
import { signCommand } from '../lib/commands/sign.js';
|
|
101
102
|
import { widgetContainerCommand } from '../lib/commands/widget-container.js';
|
|
102
103
|
import { inlineWidgetConfigCommand } from '../lib/commands/inline-widget-config.js';
|
|
103
104
|
import { pricingCommand } from '../lib/commands/pricing.js';
|
|
@@ -115,6 +116,7 @@ import { emailInboxCommand } from '../lib/commands/email-inbox.js';
|
|
|
115
116
|
import { callbackCommand } from '../lib/commands/callback.js';
|
|
116
117
|
import { caseCommand } from '../lib/commands/case.js';
|
|
117
118
|
import { pickupCommand } from '../lib/commands/pickup.js';
|
|
119
|
+
import { visitorLinkCommand } from '../lib/commands/visitor-link.js';
|
|
118
120
|
import { applePayCommand } from '../lib/commands/apple-pay.js';
|
|
119
121
|
import { siteBuilderCommand } from '../lib/commands/site-builder.js';
|
|
120
122
|
import { paymentRecoveryCommand } from '../lib/commands/payment-recovery.js';
|
|
@@ -125,6 +127,7 @@ import { chatWindowCommand } from '../lib/commands/chat-window.js';
|
|
|
125
127
|
import { featureDiscoveryCommand } from '../lib/commands/feature-discovery.js';
|
|
126
128
|
import { tourPolicyCommand } from '../lib/commands/tour-policy.js';
|
|
127
129
|
import { scraperCommand } from '../lib/commands/scraper.js';
|
|
130
|
+
import { usageCommand } from '../lib/commands/usage.js';
|
|
128
131
|
import { campaignsCommand } from '../lib/commands/campaigns.js';
|
|
129
132
|
import { senderDomainHealthCommand } from '../lib/commands/sender-domain-health.js';
|
|
130
133
|
import { contactsCommand } from '../lib/commands/contacts.js';
|
|
@@ -244,6 +247,7 @@ await yargs(hideBin(process.argv))
|
|
|
244
247
|
.command(billingEmailPreferenceCommand)
|
|
245
248
|
.command(billingCommand)
|
|
246
249
|
.command(couponGrantCommand)
|
|
250
|
+
.command(signCommand)
|
|
247
251
|
.command(widgetContainerCommand)
|
|
248
252
|
.command(inlineWidgetConfigCommand)
|
|
249
253
|
.command({ command: 'pricing', desc: 'View and manage the pricing catalog', builder: pricingCommand, handler: () => {} })
|
|
@@ -261,6 +265,7 @@ await yargs(hideBin(process.argv))
|
|
|
261
265
|
.command(callbackCommand)
|
|
262
266
|
.command(caseCommand)
|
|
263
267
|
.command(pickupCommand)
|
|
268
|
+
.command(visitorLinkCommand)
|
|
264
269
|
.command(applePayCommand)
|
|
265
270
|
.command(siteBuilderCommand)
|
|
266
271
|
.command(paymentRecoveryCommand)
|
|
@@ -269,6 +274,7 @@ await yargs(hideBin(process.argv))
|
|
|
269
274
|
.command(bundleCommand)
|
|
270
275
|
.command(chatWindowCommand)
|
|
271
276
|
.command({ command: 'feature-discovery', desc: 'View and act on feature-discovery suggestions for this site', builder: featureDiscoveryCommand, handler: () => {} })
|
|
277
|
+
.command(usageCommand)
|
|
272
278
|
.command({ command: 'scraper', desc: 'Velaro staff: scraper / upload / chunk usage for any site', builder: scraperCommand, handler: () => {} })
|
|
273
279
|
.command({ command: 'tour-policy', desc: 'Velaro staff: guided-tour auto-open policy (versioned, rollback-able)', builder: tourPolicyCommand, handler: () => {} })
|
|
274
280
|
.command(campaignsCommand)
|
|
@@ -21,6 +21,9 @@ const REPORTS = {
|
|
|
21
21
|
'promise-ledger': 'Agent commitments made in chat and whether a follow-up appeared; overdue list',
|
|
22
22
|
'new-agent-ramp': 'Agents whose first chat is in --from/--to: week-by-week ramp (chats, first response, QA, rating) against tenured agents',
|
|
23
23
|
'promise-dismiss': 'Dismiss one promise by --id (managers only)',
|
|
24
|
+
'bonus': 'Bonus insights, pick one with --name: ' + 'coverage_gap | next_best_fix | escalation_risk | skill_gap_matrix | handle_quality_outliers | abandon_causes' + ' (deterministic, no AI cost; assumptions are echoed in the result)',
|
|
25
|
+
'operations-briefing': 'One run of every package report from a single data load, plus one cached token-capped AI narrative (--no-narrative skips it); reports its own AI cost',
|
|
26
|
+
'entitled': 'Which Enterprise Reports items this site holds (includes Entry-point routing and Automatic tagging, which have no report of their own)',
|
|
24
27
|
'weekly-brief-send-test': 'Email this week\'s brief to the signed-in user',
|
|
25
28
|
'year-in-review': 'Build the 12-month Year in review document: --from/--to (default last 12 whole Pacific months) starts a build and prints its id; --id <id> checks status and the download link',
|
|
26
29
|
};
|
|
@@ -46,6 +49,13 @@ export const enterpriseReportsCommand = {
|
|
|
46
49
|
.option('time-zone', { type: 'string', describe: 'demand-coverage: IANA time zone (default America/Los_Angeles)' })
|
|
47
50
|
.option('low-score', { type: 'number', describe: 'bot-containment: QA score below which a handed-off chat counts as regret (1-100, default 60)' })
|
|
48
51
|
.option('include-demo', { type: 'boolean', describe: 'Show only the seeded sample (demo) data' })
|
|
52
|
+
.option('name', { type: 'string', describe: 'bonus: which analysis (coverage_gap|next_best_fix|escalation_risk|skill_gap_matrix|handle_quality_outliers|abandon_causes)' })
|
|
53
|
+
.option('narrative', { type: 'boolean', describe: 'operations-briefing: include the AI narrative (default true; --no-narrative to skip)' })
|
|
54
|
+
.option('minutes-per-review', { type: 'number', describe: 'bonus: assumed minutes per manual review (0-240)' })
|
|
55
|
+
.option('hourly-cost', { type: 'number', describe: 'bonus: assumed reviewer hourly cost (0-1000)' })
|
|
56
|
+
.option('deflection-pct', { type: 'number', describe: 'bonus: assumed deflection percent (0-100)' })
|
|
57
|
+
.option('cost-per-chat', { type: 'number', describe: 'bonus: assumed cost per chat (0-1000)' })
|
|
58
|
+
.option('wait-minutes', { type: 'number', describe: 'bonus: wait minutes that counts as abandoned (1-1440)' })
|
|
49
59
|
.option('id', { type: 'string', describe: 'promise-dismiss: the promise id; year-in-review: the build id to check' }),
|
|
50
60
|
handler: runCommand(async (argv) => {
|
|
51
61
|
if (argv.report === 'weekly-brief-send-test') {
|
|
@@ -66,10 +76,15 @@ export const enterpriseReportsCommand = {
|
|
|
66
76
|
console.log(JSON.stringify(await messagingPost(`/api/enterprise-reports/promise-ledger/${encodeURIComponent(String(argv.id))}/dismiss`, {}), null, 2));
|
|
67
77
|
return;
|
|
68
78
|
}
|
|
79
|
+
let seg = argv.report;
|
|
80
|
+
if (argv.report === 'bonus') {
|
|
81
|
+
if (!argv.name) throw new Error('--name is required for bonus (coverage_gap|next_best_fix|escalation_risk|skill_gap_matrix|handle_quality_outliers|abandon_causes)');
|
|
82
|
+
seg = `bonus/${encodeURIComponent(String(argv.name).replace(/-/g, '_'))}`;
|
|
83
|
+
}
|
|
69
84
|
const qs = new URLSearchParams();
|
|
70
|
-
const map = { from: 'from', to: 'to', groupBy: 'groupBy', deploymentId: 'deploymentId', teamId: 'teamId', threshold: 'threshold', perAgent: 'perAgent', limit: 'limit', days: 'days', windowDays: 'windowDays', weeks: 'weeks', handleMinutes: 'handleMinutes', maxConcurrent: 'maxConcurrent', timeZone: 'timeZone', lowScore: 'lowScore', includeDemo: 'includeDemo' };
|
|
85
|
+
const map = { minutesPerReview: 'minutesPerReview', hourlyCost: 'hourlyCost', deflectionPct: 'deflectionPct', costPerChat: 'costPerChat', waitMinutes: 'waitMinutes', narrative: 'narrative', from: 'from', to: 'to', groupBy: 'groupBy', deploymentId: 'deploymentId', teamId: 'teamId', threshold: 'threshold', perAgent: 'perAgent', limit: 'limit', days: 'days', windowDays: 'windowDays', weeks: 'weeks', handleMinutes: 'handleMinutes', maxConcurrent: 'maxConcurrent', timeZone: 'timeZone', lowScore: 'lowScore', includeDemo: 'includeDemo' };
|
|
71
86
|
for (const [arg, key] of Object.entries(map)) if (argv[arg] != null && argv[arg] !== '') qs.set(key, String(argv[arg]));
|
|
72
87
|
const q = qs.toString();
|
|
73
|
-
console.log(JSON.stringify(await messagingGet(`/api/enterprise-reports/${
|
|
88
|
+
console.log(JSON.stringify(await messagingGet(`/api/enterprise-reports/${seg}${q ? `?${q}` : ''}`), null, 2));
|
|
74
89
|
}),
|
|
75
90
|
};
|
package/lib/commands/netsuite.js
CHANGED
|
@@ -43,6 +43,13 @@ async function listHandler() {
|
|
|
43
43
|
printConfigs(configs);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
async function rollbackAuthHandler(argv) {
|
|
47
|
+
const r = await messagingPost('/NetSuite/Auth/RollbackAuthType', { id: argv.configId });
|
|
48
|
+
console.log(`
|
|
49
|
+
${GRN}✓${RST} Config ${r?.id ?? argv.configId} switched back to ${r?.authType ?? 'oauth1'}.
|
|
50
|
+
`);
|
|
51
|
+
}
|
|
52
|
+
|
|
46
53
|
async function urlsHandler(argv) {
|
|
47
54
|
const urls = await messagingGet(`/NetSuite/Integrations/${argv.configId}/CtiUrls`);
|
|
48
55
|
printUrls(urls);
|
|
@@ -147,6 +154,17 @@ export const netsuiteCommand = {
|
|
|
147
154
|
.strict(),
|
|
148
155
|
handler: () => {},
|
|
149
156
|
})
|
|
157
|
+
.command({
|
|
158
|
+
command: 'embed-status',
|
|
159
|
+
describe: 'Alias of: embed status',
|
|
160
|
+
handler: embedStatusHandler,
|
|
161
|
+
})
|
|
162
|
+
.command({
|
|
163
|
+
command: 'rollback-auth <configId>',
|
|
164
|
+
describe: 'Undo a switch to OAuth 2.0/M2M: restore OAuth 1.0a (only while old credentials still exist)',
|
|
165
|
+
builder: a => a.positional('configId', { type: 'number' }),
|
|
166
|
+
handler: rollbackAuthHandler,
|
|
167
|
+
})
|
|
150
168
|
.command({
|
|
151
169
|
command: 'suiteapp',
|
|
152
170
|
describe: 'Download the screen-pop SuiteApp zip (key included by default: no API Secret to create)',
|
|
@@ -71,18 +71,26 @@ const calibration = {
|
|
|
71
71
|
|
|
72
72
|
const rubric = {
|
|
73
73
|
command: 'rubric <action>',
|
|
74
|
-
describe: 'QA rubrics: list | get | default | create | update | delete (--json for the rubric body)',
|
|
75
|
-
builder: (y) => y.positional('action', { choices: ['list', 'get', 'default', 'create', 'update', 'delete'] })
|
|
76
|
-
.option('id', { type: 'number' }).option('json', { type: 'string', describe: 'Rubric JSON body' })
|
|
74
|
+
describe: 'QA rubrics: list | get | default | starter | create | update | clone | activate | deactivate | rescore | delete (--json for the rubric body)',
|
|
75
|
+
builder: (y) => y.positional('action', { choices: ['list', 'get', 'default', 'starter', 'create', 'update', 'clone', 'activate', 'deactivate', 'rescore', 'delete'] })
|
|
76
|
+
.option('id', { type: 'number' }).option('json', { type: 'string', describe: 'Rubric JSON body' })
|
|
77
|
+
.option('name', { type: 'string', describe: 'clone: name of the copy' })
|
|
78
|
+
.option('days', { type: 'number', default: 30, describe: 'rescore: look back this many days (1-90)' })
|
|
79
|
+
.option('limit', { type: 'number', default: 200, describe: 'rescore: max chats (1-500)' }),
|
|
77
80
|
handler: runCommand(async (a) => {
|
|
78
81
|
const base = '/api/admin/QualityQa/rubrics';
|
|
79
82
|
const body = a.json ? JSON.parse(a.json) : {};
|
|
80
83
|
if (a.action === 'list') return print(await messagingGet(base));
|
|
81
84
|
if (a.action === 'default') return print(await messagingGet(`${base}/default`));
|
|
85
|
+
if (a.action === 'starter') return print(await messagingPost(`${base}/starter`, {}));
|
|
86
|
+
if (a.action === 'rescore') return print(await messagingPost(`/api/admin/QualityQa/rescore?days=${a.days}&limit=${a.limit}`, {}));
|
|
82
87
|
if (a.action === 'create') return print(await messagingPost(base, body));
|
|
83
88
|
if (a.id == null) throw new Error('--id is required');
|
|
84
89
|
if (a.action === 'get') return print(await messagingGet(`${base}/${a.id}`));
|
|
85
90
|
if (a.action === 'update') return print(await messagingPut(`${base}/${a.id}`, body));
|
|
91
|
+
if (a.action === 'clone') return print(await messagingPost(`${base}/${a.id}/clone`, a.name ? { name: a.name } : {}));
|
|
92
|
+
if (a.action === 'activate') return print(await messagingPut(`${base}/${a.id}`, { isActive: true }));
|
|
93
|
+
if (a.action === 'deactivate') return print(await messagingPut(`${base}/${a.id}`, { isActive: false }));
|
|
86
94
|
print(await messagingDel(`${base}/${a.id}`));
|
|
87
95
|
}),
|
|
88
96
|
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// velaro sign - Velaro Sign document versions (owner-only). REST: GET /api/Contract/{id}/versions,
|
|
2
|
+
// GET /api/Contract/{id}/diff, POST /api/Contract/{id}/document (multipart, draft only).
|
|
3
|
+
import { getCredentials, get } from '../api.js';
|
|
4
|
+
import {
|
|
5
|
+
versionsPath, diffPath, uploadPath, describeVersions, describeDiff, buildUploadForm, OWNER_ONLY_RULE,
|
|
6
|
+
} from '../sign-versions.js';
|
|
7
|
+
|
|
8
|
+
async function versions(id, json) {
|
|
9
|
+
const res = await get(versionsPath(id));
|
|
10
|
+
console.log(json ? JSON.stringify(res, null, 2) : describeVersions(res));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function diff(id, from, to, json) {
|
|
14
|
+
const res = await get(diffPath(id, from, to));
|
|
15
|
+
console.log(json ? JSON.stringify(res, null, 2) : describeDiff(res));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function uploadVersion(id, file, note) {
|
|
19
|
+
const creds = await getCredentials();
|
|
20
|
+
const form = buildUploadForm(file, note);
|
|
21
|
+
const base = String(creds.adminApiBase).replace(/\/+$/, '');
|
|
22
|
+
const res = await fetch(`${base}${uploadPath(id)}`, {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: { Authorization: `Bearer ${creds.velaroToken}` },
|
|
25
|
+
body: form,
|
|
26
|
+
});
|
|
27
|
+
const text = await res.text();
|
|
28
|
+
if (!res.ok) throw new Error(`POST ${uploadPath(id)} -> ${res.status}: ${text.slice(0, 300)}\n${OWNER_ONLY_RULE}`);
|
|
29
|
+
console.log(`Uploaded new version for contract ${id}.`);
|
|
30
|
+
console.log(describeVersions(await get(versionsPath(id))));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const signCommand = {
|
|
34
|
+
command: 'sign <subcommand>',
|
|
35
|
+
describe: 'Velaro Sign document versions (owner-only; signers never see old versions)',
|
|
36
|
+
builder: (yargs) =>
|
|
37
|
+
yargs
|
|
38
|
+
.command('versions <contractId>', 'List every uploaded document version of a contract', {
|
|
39
|
+
json: { type: 'boolean', default: false },
|
|
40
|
+
}, (a) => versions(a.contractId, a.json))
|
|
41
|
+
.command('diff <contractId> <from> <to>', 'Text diff between two document versions', {
|
|
42
|
+
json: { type: 'boolean', default: false },
|
|
43
|
+
}, (a) => diff(a.contractId, a.from, a.to, a.json))
|
|
44
|
+
.command('upload-version <contractId> <file>', 'Upload a new PDF version (draft contracts only; after send/sign void and re-send)', {
|
|
45
|
+
note: { type: 'string', describe: 'Change note stored with the version' },
|
|
46
|
+
}, (a) => uploadVersion(a.contractId, a.file, a.note))
|
|
47
|
+
.demandCommand(1, 'Specify: versions | diff | upload-version'),
|
|
48
|
+
handler: () => {},
|
|
49
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// velaro usage alerts — plan-limit alerts for the authenticated site (GET Usage/alerts on the messaging API).
|
|
2
|
+
// Same rules as the usage-limit emails and the dashboard banner; site comes from auth, never an argument.
|
|
3
|
+
import { messagingGet } from '../api.js';
|
|
4
|
+
|
|
5
|
+
async function alerts(argv) {
|
|
6
|
+
const r = await messagingGet('/Usage/alerts');
|
|
7
|
+
if (argv.json) { console.log(JSON.stringify(r, null, 2)); return; }
|
|
8
|
+
if (!r.alerts?.length) { console.log('\nNo plan-limit alerts. Every metered resource is under its warning threshold.'); return; }
|
|
9
|
+
console.log(`\nPlan-limit alerts (${r.alerts.length})\n${'─'.repeat(50)}`);
|
|
10
|
+
for (const a of r.alerts) {
|
|
11
|
+
const tag = a.level === 'limit' ? 'LIMIT REACHED' : a.level === 'critical' ? 'CRITICAL' : 'WARNING';
|
|
12
|
+
console.log(` [${tag}] ${a.displayName}: ${a.current.toLocaleString('en-US')} of ${a.limit.toLocaleString('en-US')} (${a.percent}%)${a.softLimit ? ' overage is billed' : ''}`);
|
|
13
|
+
}
|
|
14
|
+
console.log('\nRequest more capacity: Billing > Contact Account Manager, or email sales@velaro.com.');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Object form (not a bare builder) so cli/bin/velaro.js registers it like every other group and the
|
|
18
|
+
// CLI/MCP group-parity check (scripts/validate-cli-mcp-group-parity.js) can see the "usage" group.
|
|
19
|
+
export const usageCommand = {
|
|
20
|
+
command: 'usage <subcommand>',
|
|
21
|
+
describe: 'Plan-limit usage and alerts for this site',
|
|
22
|
+
builder: (y) => y
|
|
23
|
+
.command('alerts', 'Show plan-limit alerts (resources at or above the warning threshold)', (y2) => y2
|
|
24
|
+
.option('json', { type: 'boolean', describe: 'Print raw JSON' }), (a) => alerts(a))
|
|
25
|
+
.demandCommand(1, 'Specify a subcommand: alerts')
|
|
26
|
+
.strict(),
|
|
27
|
+
handler: () => {},
|
|
28
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { messagingGet, messagingPost, messagingPut } from '../api.js';
|
|
2
|
+
import { runCommand } from '../run.js';
|
|
3
|
+
import { diagnoseLink, validateSettingsArgs } from '../visitor-link.js';
|
|
4
|
+
|
|
5
|
+
// ── velaro visitor-link ────────────────────────────────────────────────────────
|
|
6
|
+
// Signed visitor link: lets a chat visitor be tied to a later form or survey submission.
|
|
7
|
+
// Off by default per site; a link is only minted for a visitor who has chatted and (when the site uses a
|
|
8
|
+
// consent banner) granted functional storage. siteId comes from the session, never a CLI arg.
|
|
9
|
+
//
|
|
10
|
+
// Backend contract: server/Velaro.Messaging/Controllers/VisitorLinkControllers.cs (messaging)
|
|
11
|
+
// GET SiteSettings/visitor-linking -> { enabled, ttlMinutes, keyConfigured }
|
|
12
|
+
// PUT SiteSettings/visitor-linking { enabled?, ttlMinutes? (1..1440) } -> same shape
|
|
13
|
+
// POST api/v1/visitor-links/resolve { token } -> { data: { visitorId, conversationBaseId, contactId }, error }
|
|
14
|
+
|
|
15
|
+
const BOLD = '\x1b[1m';
|
|
16
|
+
const GRN = '\x1b[32m';
|
|
17
|
+
const YEL = '\x1b[33m';
|
|
18
|
+
const RST = '\x1b[0m';
|
|
19
|
+
|
|
20
|
+
function printSettings(s) {
|
|
21
|
+
console.log(`\n${BOLD}Visitor linking${RST}`);
|
|
22
|
+
console.log(` Enabled: ${s.enabled ? GRN + 'on' + RST : 'off'}`);
|
|
23
|
+
console.log(` Link lifetime: ${s.ttlMinutes} minutes`);
|
|
24
|
+
console.log(` Signing key: ${s.keyConfigured ? 'configured' : YEL + 'NOT configured (links fail closed)' + RST}\n`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function getHandler() {
|
|
28
|
+
printSettings(await messagingGet('/SiteSettings/visitor-linking'));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function setHandler(argv) {
|
|
32
|
+
const body = validateSettingsArgs({ enabled: argv.enabled, ttlMinutes: argv.ttlMinutes });
|
|
33
|
+
printSettings(await messagingPut('/SiteSettings/visitor-linking', body));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function resolveHandler(argv) {
|
|
37
|
+
const r = await messagingPost('/api/v1/visitor-links/resolve', { token: argv.token });
|
|
38
|
+
const d = r?.data ?? r;
|
|
39
|
+
console.log(`\n${GRN}✓${RST} Token resolved`);
|
|
40
|
+
console.log(` Visitor: ${d.visitorId}`);
|
|
41
|
+
console.log(` Conversation: ${d.conversationBaseId ?? '(none)'}`);
|
|
42
|
+
console.log(` Contact: ${d.contactId ?? '(none)'}\n`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function diagnoseHandler(argv) {
|
|
46
|
+
const settings = await messagingGet('/SiteSettings/visitor-linking');
|
|
47
|
+
const d = diagnoseLink({ token: argv.token, settings });
|
|
48
|
+
printSettings(settings);
|
|
49
|
+
if (argv.token) console.log(` Token claims: ${JSON.stringify(d.claims)}`);
|
|
50
|
+
console.log(d.healthy ? `\n${GRN}✓${RST} No problems found.\n` : `\n${YEL}Findings${RST}\n${d.findings.map((f) => ' - ' + f).join('\n')}\n`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const getCommand = { command: 'get', describe: 'Show visitor-linking settings for this site', builder: (y) => y, handler: runCommand(getHandler) };
|
|
54
|
+
const setCommand = {
|
|
55
|
+
command: 'set',
|
|
56
|
+
describe: 'Turn visitor linking on/off and set the link lifetime',
|
|
57
|
+
builder: (y) => y
|
|
58
|
+
.option('enabled', { describe: 'Enable (true) or disable (false) visitor linking', type: 'boolean' })
|
|
59
|
+
.option('ttl-minutes', { describe: 'Link lifetime in minutes, 1-1440', type: 'number' }),
|
|
60
|
+
handler: runCommand(setHandler),
|
|
61
|
+
};
|
|
62
|
+
const settingsCommand = {
|
|
63
|
+
command: 'settings <action>',
|
|
64
|
+
describe: 'Get or set visitor-linking settings',
|
|
65
|
+
builder: (y) => y.command(getCommand).command(setCommand).demandCommand(1, 'Specify: get | set'),
|
|
66
|
+
handler: () => {},
|
|
67
|
+
};
|
|
68
|
+
const resolveCommand = {
|
|
69
|
+
command: 'resolve <token>',
|
|
70
|
+
describe: 'Exchange a link token for the visitor and conversation ids (what your server does)',
|
|
71
|
+
builder: (y) => y.positional('token', { type: 'string', describe: 'vlk1 token from the form hidden field' }),
|
|
72
|
+
handler: runCommand(resolveHandler),
|
|
73
|
+
};
|
|
74
|
+
const diagnoseCommand = {
|
|
75
|
+
command: 'diagnose [token]',
|
|
76
|
+
describe: 'Check settings and, optionally, why a specific link token would fail',
|
|
77
|
+
builder: (y) => y.positional('token', { type: 'string', describe: 'Optional vlk1 token to inspect' }),
|
|
78
|
+
handler: runCommand(diagnoseHandler),
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const visitorLinkCommand = {
|
|
82
|
+
command: 'visitor-link <subcommand>',
|
|
83
|
+
describe: 'Link chat visitors to later form and survey submissions (settings, resolve, diagnose)',
|
|
84
|
+
builder: (y) => y
|
|
85
|
+
.command(settingsCommand)
|
|
86
|
+
.command(resolveCommand)
|
|
87
|
+
.command(diagnoseCommand)
|
|
88
|
+
.demandCommand(1, 'Specify a visitor-link subcommand: settings | resolve | diagnose'),
|
|
89
|
+
handler: () => {},
|
|
90
|
+
};
|
|
@@ -10,6 +10,23 @@ test('describe lists the five novel reports and the promise dismiss action', ()
|
|
|
10
10
|
}
|
|
11
11
|
});
|
|
12
12
|
|
|
13
|
+
// Parity burn-down (2026-10-14): Bonus insights, Operations Briefing and the shared `entitled` view (which also covers
|
|
14
|
+
// Entry-point routing and Automatic tagging, items without a report route) are on the CLI and the MCP tool.
|
|
15
|
+
test('describe lists bonus, operations-briefing and entitled', () => {
|
|
16
|
+
for (const r of ['bonus', 'operations-briefing', 'entitled']) {
|
|
17
|
+
assert.ok(enterpriseReportsCommand.describe.includes(r), `${r} missing from describe`);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('bonus requires --name and the MCP tool maps the same segments', async () => {
|
|
22
|
+
const { readFileSync } = await import('node:fs');
|
|
23
|
+
const { fileURLToPath } = await import('node:url');
|
|
24
|
+
const mcp = readFileSync(fileURLToPath(new URL('../../mcp/server.js', import.meta.url)), 'utf8');
|
|
25
|
+
for (const m of ["bonus_insights: 'bonus'", "operations_briefing: 'operations-briefing'", "entitled: 'entitled'"]) assert.ok(mcp.includes(m), m);
|
|
26
|
+
const cli = readFileSync(fileURLToPath(new URL('./commands/enterprise-reports.js', import.meta.url)), 'utf8');
|
|
27
|
+
assert.ok(cli.includes('--name is required for bonus') && cli.includes('`bonus/${encodeURIComponent'), 'bonus must require --name and hit bonus/{report}');
|
|
28
|
+
});
|
|
29
|
+
|
|
13
30
|
// Year in review (item 16): the CLI sends Pacific-midnight instants, defaulting to the last 12 whole Pacific months.
|
|
14
31
|
test('describe lists year-in-review', () => {
|
|
15
32
|
assert.ok(enterpriseReportsCommand.describe.includes('year-in-review'));
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Shared helpers for Velaro Sign document versions (owner-only; REST: ContractVersionsController +
|
|
2
|
+
// ContractController.UploadSourceDocument). Used by `velaro sign ...`; pure functions so they unit-test cleanly.
|
|
3
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
4
|
+
import { basename } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export function versionsPath(contractId) {
|
|
7
|
+
return `/api/Contract/${Number(contractId)}/versions`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function diffPath(contractId, from, to) {
|
|
11
|
+
return `/api/Contract/${Number(contractId)}/diff?from=${Number(from)}&to=${Number(to)}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function uploadPath(contractId) {
|
|
15
|
+
return `/api/Contract/${Number(contractId)}/document`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function describeVersions(res) {
|
|
19
|
+
const rows = res?.versions ?? [];
|
|
20
|
+
if (!rows.length) return `Contract ${res?.contractId}: no document versions yet.`;
|
|
21
|
+
const lines = [`Contract ${res.contractId}: ${rows.length} version(s), newest first`];
|
|
22
|
+
for (const v of rows) {
|
|
23
|
+
const kb = Math.max(1, Math.round((v.byteLength || 0) / 1024));
|
|
24
|
+
lines.push(` v${v.versionNumber} ${String(v.uploadedAt || '').slice(0, 19)} ${v.uploadedBy || '-'} ${v.fileName || '-'} ${v.pageCount ?? '?'}p ${kb}KB sha256:${String(v.sha256 || '').slice(0, 12)}${v.note ? ` note: ${v.note}` : ''}`);
|
|
25
|
+
}
|
|
26
|
+
return lines.join('\n');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function describeDiff(res) {
|
|
30
|
+
if (res?.identical) return `v${res.from} and v${res.to} are byte-identical (same SHA-256).`;
|
|
31
|
+
const blocks = res?.blocks ?? [];
|
|
32
|
+
const changed = blocks.filter(b => b.kind !== 'same' && b.kind !== 'equal');
|
|
33
|
+
const lines = [`Diff v${res.from} -> v${res.to}: ${changed.length} changed block(s)`];
|
|
34
|
+
for (const b of changed) {
|
|
35
|
+
for (const r of b.removed ?? []) lines.push(`- ${r}`);
|
|
36
|
+
for (const a of b.added ?? []) lines.push(`+ ${a}`);
|
|
37
|
+
lines.push('');
|
|
38
|
+
}
|
|
39
|
+
return lines.join('\n').trimEnd();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function readPdf(filePath) {
|
|
43
|
+
if (!filePath || !existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
|
|
44
|
+
const buf = readFileSync(filePath);
|
|
45
|
+
if (buf.length < 5 || buf.subarray(0, 5).toString('latin1') !== '%PDF-') throw new Error('Only PDF files can be uploaded as a contract version.');
|
|
46
|
+
return { buf, name: basename(filePath) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function buildUploadForm(filePath, note) {
|
|
50
|
+
const { buf, name } = readPdf(filePath);
|
|
51
|
+
const form = new FormData();
|
|
52
|
+
form.append('file', new Blob([buf], { type: 'application/pdf' }), name);
|
|
53
|
+
if (note) form.append('note', note);
|
|
54
|
+
return form;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const OWNER_ONLY_RULE =
|
|
58
|
+
'Versions are owner-only: signers never see older versions. A new version can be uploaded only while the contract is a draft; after send/sign, void it and re-send.';
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { writeFileSync, mkdtempSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { versionsPath, diffPath, uploadPath, describeVersions, describeDiff, buildUploadForm } from './sign-versions.js';
|
|
7
|
+
|
|
8
|
+
test('paths', () => {
|
|
9
|
+
assert.equal(versionsPath('12'), '/api/Contract/12/versions');
|
|
10
|
+
assert.equal(diffPath(12, 1, 3), '/api/Contract/12/diff?from=1&to=3');
|
|
11
|
+
assert.equal(uploadPath(12), '/api/Contract/12/document');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('describeVersions lists newest first with note', () => {
|
|
15
|
+
const out = describeVersions({ contractId: 5, versions: [
|
|
16
|
+
{ versionNumber: 2, fileName: 'b.pdf', sha256: 'abcdef1234567890', byteLength: 2048, pageCount: 3, uploadedBy: 'a@v.com', uploadedAt: '2026-10-11T10:00:00Z', note: 'fix term' },
|
|
17
|
+
{ versionNumber: 1, fileName: 'a.pdf', sha256: '1111111111111111', byteLength: 100, pageCount: 3, uploadedBy: 'a@v.com', uploadedAt: '2026-10-10T10:00:00Z' }] });
|
|
18
|
+
assert.match(out, /2 version\(s\)/);
|
|
19
|
+
assert.match(out, /v2 .*note: fix term/);
|
|
20
|
+
assert.equal(describeVersions({ contractId: 5, versions: [] }), 'Contract 5: no document versions yet.');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('describeDiff shows removed/added and identical case', () => {
|
|
24
|
+
assert.match(describeDiff({ from: 1, to: 2, identical: true }), /byte-identical/);
|
|
25
|
+
const out = describeDiff({ from: 1, to: 2, identical: false, blocks: [{ kind: 'changed', removed: ['Term 12 months'], added: ['Term 24 months'] }] });
|
|
26
|
+
assert.match(out, /- Term 12 months/);
|
|
27
|
+
assert.match(out, /\+ Term 24 months/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('buildUploadForm rejects non-PDF and accepts PDF', () => {
|
|
31
|
+
const d = mkdtempSync(join(tmpdir(), 'sv-'));
|
|
32
|
+
const bad = join(d, 'x.pdf'); writeFileSync(bad, 'hello');
|
|
33
|
+
assert.throws(() => buildUploadForm(bad), /Only PDF/);
|
|
34
|
+
assert.throws(() => buildUploadForm(join(d, 'missing.pdf')), /not found/);
|
|
35
|
+
const ok = join(d, 'ok.pdf'); writeFileSync(ok, '%PDF-1.4\n%%EOF');
|
|
36
|
+
const f = buildUploadForm(ok, 'n');
|
|
37
|
+
assert.equal(f.get('note'), 'n');
|
|
38
|
+
assert.equal(f.get('file').name, 'ok.pdf');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
import { readFileSync } from 'node:fs';
|
|
42
|
+
import { fileURLToPath } from 'node:url';
|
|
43
|
+
import { dirname, join as pjoin } from 'node:path';
|
|
44
|
+
const root = pjoin(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
45
|
+
for (const f of ['mcp/server.js', 'mcp/staff-server.js', 'server/Velaro.Admin/Controllers/StaffMcpController.cs']) {
|
|
46
|
+
test(`${f} registers sign_versions tools`, () => {
|
|
47
|
+
const s = readFileSync(pjoin(root, f), 'utf8');
|
|
48
|
+
for (const t of ['sign_versions_list', 'sign_versions_diff', 'sign_versions_upload']) assert.ok(s.includes(t), `${t} missing in ${f}`);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Shared helpers for `velaro visitor-link` (settings + token diagnose).
|
|
2
|
+
// Token format (velaro-messaging VisitorLinkTokenService): "vlk1." + b64url(payload) + "." + b64url(hmac)
|
|
3
|
+
// payload = siteId|visitorId|conversationBaseId|scope|iat|exp. The signature needs the server key, so this
|
|
4
|
+
// only reads the claims; it never proves a token valid. Resolve (server side) is the real validity check.
|
|
5
|
+
|
|
6
|
+
export const MAX_TTL_MINUTES = 1440;
|
|
7
|
+
|
|
8
|
+
export function decodeLinkToken(token) {
|
|
9
|
+
const out = { ok: false, problems: [] };
|
|
10
|
+
if (typeof token !== 'string' || !token.trim()) { out.problems.push('empty token'); return out; }
|
|
11
|
+
const parts = token.trim().split('.');
|
|
12
|
+
if (parts.length !== 3 || parts[0] !== 'vlk1') { out.problems.push('not a vlk1 link token (expected vlk1.<payload>.<signature>)'); return out; }
|
|
13
|
+
const payload = Buffer.from(parts[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8');
|
|
14
|
+
const f = payload.split('|');
|
|
15
|
+
if (f.length < 6) { out.problems.push('payload has fewer than 6 fields'); return out; }
|
|
16
|
+
const [siteId, visitorId, conversationBaseId, scope, iat, exp] = f;
|
|
17
|
+
const toMs = (v) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? (n > 1e12 ? n : n * 1000) : null; };
|
|
18
|
+
const issuedMs = toMs(iat);
|
|
19
|
+
const expMs = toMs(exp);
|
|
20
|
+
Object.assign(out, {
|
|
21
|
+
ok: true,
|
|
22
|
+
siteId: Number(siteId) || null,
|
|
23
|
+
visitorId,
|
|
24
|
+
conversationBaseId: Number(conversationBaseId) || null,
|
|
25
|
+
scope,
|
|
26
|
+
issuedAt: issuedMs ? new Date(issuedMs).toISOString() : null,
|
|
27
|
+
expiresAt: expMs ? new Date(expMs).toISOString() : null,
|
|
28
|
+
signaturePresent: parts[2].length > 0,
|
|
29
|
+
});
|
|
30
|
+
if (scope !== 'link') out.problems.push(`unexpected scope "${scope}" (v1 only issues "link")`);
|
|
31
|
+
if (!out.signaturePresent) out.problems.push('signature missing');
|
|
32
|
+
if (!expMs) out.problems.push('expiry not parseable');
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Full diagnosis: claims plus comparison with the site's current settings.
|
|
37
|
+
export function diagnoseLink({ token, siteId, settings, now = Date.now() }) {
|
|
38
|
+
const claims = token ? decodeLinkToken(token) : { ok: false, problems: [] };
|
|
39
|
+
const findings = [...claims.problems];
|
|
40
|
+
if (claims.ok) {
|
|
41
|
+
if (siteId && claims.siteId && Number(siteId) !== claims.siteId) findings.push(`token was minted for site ${claims.siteId}, not site ${siteId}: cross-site tokens always fail`);
|
|
42
|
+
if (claims.expiresAt && Date.parse(claims.expiresAt) <= now) findings.push(`token expired at ${claims.expiresAt}`);
|
|
43
|
+
if (!claims.conversationBaseId) findings.push('token carries no conversation id (visitor had not started a chat when it was minted)');
|
|
44
|
+
}
|
|
45
|
+
if (settings) {
|
|
46
|
+
if (!settings.enabled) findings.push('visitor linking is OFF for this site: mint returns linking_disabled');
|
|
47
|
+
if (settings.keyConfigured === false) findings.push('link signing key is not configured on the server: mint and resolve fail closed (503 key_not_configured)');
|
|
48
|
+
}
|
|
49
|
+
return { claims, settings: settings ?? null, findings, healthy: findings.length === 0 };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function validateSettingsArgs({ enabled, ttlMinutes }) {
|
|
53
|
+
if (enabled === undefined && ttlMinutes === undefined) throw new Error('Pass enabled and/or ttlMinutes.');
|
|
54
|
+
if (ttlMinutes !== undefined && (!Number.isInteger(ttlMinutes) || ttlMinutes < 1 || ttlMinutes > MAX_TTL_MINUTES)) {
|
|
55
|
+
throw new Error(`ttlMinutes must be a whole number from 1 to ${MAX_TTL_MINUTES}.`);
|
|
56
|
+
}
|
|
57
|
+
const body = {};
|
|
58
|
+
if (enabled !== undefined) body.enabled = !!enabled;
|
|
59
|
+
if (ttlMinutes !== undefined) body.ttlMinutes = ttlMinutes;
|
|
60
|
+
return body;
|
|
61
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { decodeLinkToken, diagnoseLink, validateSettingsArgs } from './visitor-link.js';
|
|
4
|
+
|
|
5
|
+
const b64 = (s) => Buffer.from(s, 'utf8').toString('base64url');
|
|
6
|
+
const mk = (fields, sig = 'sig') => `vlk1.${b64(fields.join('|'))}.${sig}`;
|
|
7
|
+
const NOW = Date.UTC(2026, 9, 13, 12, 0, 0);
|
|
8
|
+
const sec = (ms) => Math.floor(ms / 1000);
|
|
9
|
+
|
|
10
|
+
test('decodes a well-formed token', () => {
|
|
11
|
+
const c = decodeLinkToken(mk([1032, 'v-9', 555, 'link', sec(NOW), sec(NOW + 1800000)]));
|
|
12
|
+
assert.equal(c.ok, true);
|
|
13
|
+
assert.equal(c.siteId, 1032);
|
|
14
|
+
assert.equal(c.visitorId, 'v-9');
|
|
15
|
+
assert.equal(c.conversationBaseId, 555);
|
|
16
|
+
assert.deepEqual(c.problems, []);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('rejects wrong prefix, empty and short payload', () => {
|
|
20
|
+
assert.equal(decodeLinkToken('').ok, false);
|
|
21
|
+
assert.equal(decodeLinkToken('abc.def.ghi').ok, false);
|
|
22
|
+
assert.equal(decodeLinkToken(`vlk1.${b64('1|2')}.s`).ok, false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('flags cross-site, expired, no conversation, missing signature', () => {
|
|
26
|
+
const t = mk([1032, 'v', 0, 'link', sec(NOW - 7200000), sec(NOW - 3600000)], '');
|
|
27
|
+
const d = diagnoseLink({ token: t, siteId: 20365, now: NOW });
|
|
28
|
+
const text = d.findings.join('\n');
|
|
29
|
+
assert.match(text, /not site 20365/);
|
|
30
|
+
assert.match(text, /expired/);
|
|
31
|
+
assert.match(text, /no conversation id/);
|
|
32
|
+
assert.match(text, /signature missing/);
|
|
33
|
+
assert.equal(d.healthy, false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('settings findings: off and missing key fail closed', () => {
|
|
37
|
+
const d = diagnoseLink({ settings: { enabled: false, ttlMinutes: 30, keyConfigured: false } });
|
|
38
|
+
assert.match(d.findings.join('\n'), /OFF/);
|
|
39
|
+
assert.match(d.findings.join('\n'), /key is not configured/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('healthy when token valid and settings on', () => {
|
|
43
|
+
const t = mk([1032, 'v', 5, 'link', sec(NOW), sec(NOW + 600000)]);
|
|
44
|
+
const d = diagnoseLink({ token: t, siteId: 1032, settings: { enabled: true, keyConfigured: true }, now: NOW });
|
|
45
|
+
assert.equal(d.healthy, true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('validateSettingsArgs enforces 1..1440 and requires a field', () => {
|
|
49
|
+
assert.throws(() => validateSettingsArgs({}), /enabled and\/or ttlMinutes/);
|
|
50
|
+
assert.throws(() => validateSettingsArgs({ ttlMinutes: 0 }), /1 to 1440/);
|
|
51
|
+
assert.throws(() => validateSettingsArgs({ ttlMinutes: 1441 }), /1 to 1440/);
|
|
52
|
+
assert.throws(() => validateSettingsArgs({ ttlMinutes: 1.5 }), /1 to 1440/);
|
|
53
|
+
assert.deepEqual(validateSettingsArgs({ enabled: true, ttlMinutes: 30 }), { enabled: true, ttlMinutes: 30 });
|
|
54
|
+
assert.deepEqual(validateSettingsArgs({ enabled: false }), { enabled: false });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('MCP and staff-MCP inlined helpers stay in sync with the CLI helper', async () => {
|
|
58
|
+
const fs = await import('node:fs');
|
|
59
|
+
const path = await import('node:path');
|
|
60
|
+
const here = path.dirname(new URL(import.meta.url).pathname.replace(/^\/(\w:)/, '$1'));
|
|
61
|
+
const norm = (src) => src.replace(/\/\/.*$/gm, '').replace(/\bexport\s+/g, '').replace(/\s+/g, ' ');
|
|
62
|
+
const cli = norm(fs.readFileSync(path.join(here, 'visitor-link.js'), 'utf8')).trim();
|
|
63
|
+
for (const f of ['server.js', 'staff-server.js']) {
|
|
64
|
+
const src = norm(fs.readFileSync(path.join(here, '..', '..', 'mcp', f), 'utf8'));
|
|
65
|
+
assert.ok(src.includes(cli), `mcp/${f} inlined visitor-link helpers drifted from cli/lib/visitor-link.js`);
|
|
66
|
+
}
|
|
67
|
+
});
|
package/package.json
CHANGED