@checkbox-site/mcp 0.1.1 → 0.2.0
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/README.md +3 -3
- package/dist/api.d.ts +7 -0
- package/dist/api.js +11 -1
- package/dist/format.d.ts +23 -1
- package/dist/format.js +190 -16
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/resources.js +11 -10
- package/dist/server.d.ts +27 -3
- package/dist/server.js +97 -10
- package/dist/todo.d.ts +25 -0
- package/dist/todo.js +35 -0
- package/dist/tools.d.ts +150 -3
- package/dist/tools.js +855 -191
- package/dist/unknown-args.d.ts +22 -0
- package/dist/unknown-args.js +119 -0
- package/dist/version-check.d.ts +22 -0
- package/dist/version-check.js +50 -0
- package/package.json +2 -2
package/dist/tools.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// The Checkbox MCP tools. Names are stable snake_case; descriptions are written for an LLM (when to use, what to pass,
|
|
2
2
|
// what the output means, what to do next). Every tool is a thin call (or two) to the public /v1 API, so scopes,
|
|
3
3
|
// test/live isolation and validation are exactly the API's.
|
|
4
|
+
import { nestedUnknownMessage, unknownArgumentsMessage, unknownKeysParams } from "./unknown-args.js";
|
|
4
5
|
import { z } from 'zod';
|
|
6
|
+
import { planTodo, todoLine } from "./todo.js";
|
|
5
7
|
import { CheckboxApiError } from "./api.js";
|
|
6
|
-
import { nextActionOf, untrusted } from "./format.js";
|
|
8
|
+
import { ciHint, nextActionOf, untrusted } from "./format.js";
|
|
7
9
|
import { keyAllows } from "./permissions.js";
|
|
8
10
|
/** A failure the tool explains itself (not an API error): rendered as an error result with a next step. */
|
|
9
11
|
export class ToolError extends Error {
|
|
@@ -31,20 +33,47 @@ const jurisdictions = z
|
|
|
31
33
|
.union([z.literal('all'), z.array(z.string().max(10)).max(60)])
|
|
32
34
|
.describe('Where the business sells: "all" (default) or state codes like ["US-CA","US-NY"] ("CA" works too). Federal law is always included.');
|
|
33
35
|
const asOf = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe('Evaluate the law as of this date, YYYY-MM-DD (default today).');
|
|
34
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Audit P2: notice kinds have two names for the trial email (trial_reminder in checks / verify, trial_ending in Notices).
|
|
38
|
+
* Every MCP tool accepts both (and charge_notice where the API checks it) and sends the API the name it expects.
|
|
39
|
+
*/
|
|
40
|
+
const EMAIL_KINDS = ['acknowledgment', 'trial_reminder', 'trial_ending', 'renewal_reminder', 'price_change', 'annual_terms', 'charge_notice', 'cancellation_confirmation'];
|
|
41
|
+
const emailKind = z.enum(EMAIL_KINDS);
|
|
42
|
+
/** The checks / verify API name (trial_reminder). */
|
|
43
|
+
export const checkKind = (k) => (k === 'trial_ending' ? 'trial_reminder' : k);
|
|
44
|
+
/** The Notices API name (trial_ending). */
|
|
45
|
+
export const noticeKind = (k) => (k === 'trial_reminder' ? 'trial_ending' : k);
|
|
46
|
+
const intervalStr = z.string().max(20).describe('Billing interval: day, week, month or year.');
|
|
35
47
|
const planTermsShape = {
|
|
36
48
|
merchant_name: z.string().max(200).optional().describe('Brand shown to subscribers, e.g. "Acme".'),
|
|
49
|
+
brand_name: z.string().max(200).optional().describe('Alias of merchant_name.'),
|
|
37
50
|
plan_name: z.string().max(200).optional().describe('Plan / product name, e.g. "Pro".'),
|
|
38
|
-
price: z.union([z.number().positive(), z.string().max(40)]).optional().describe('Recurring price
|
|
39
|
-
amount: z.number().int().positive().optional().describe('
|
|
51
|
+
price: z.union([z.number().positive(), z.string().max(40)]).optional().describe('Recurring price, major units (19.99).'),
|
|
52
|
+
amount: z.number().int().positive().optional().describe('Or minor units (1999 = $19.99).'),
|
|
40
53
|
currency: z.string().length(3).optional().describe('ISO currency, default "usd".'),
|
|
41
54
|
interval: intervalStr.optional(),
|
|
42
|
-
interval_count: z.number().int().min(1).max(36).optional().describe('
|
|
55
|
+
interval_count: z.number().int().min(1).max(36).optional().describe('Default 1; 3 + month = quarterly.'),
|
|
43
56
|
trial_days: z.number().int().min(0).max(730).optional().describe('Free-trial days (0 or absent = no trial).'),
|
|
44
|
-
|
|
57
|
+
// Pilot round 12 (change-guard): no way to describe a promo, so "$5 first month (50% off)" passed against a 20% coupon.
|
|
58
|
+
// The API also takes intro_amount / intro_amount_off (minor units); kept out here for the tool list's size.
|
|
59
|
+
intro_price: z.number().positive().optional().describe('Promo first-period price, major units ($8 → 8).'),
|
|
60
|
+
intro_percent_off: z.number().gt(0).lt(100).optional().describe('Or the promo as % off (coupon percent_off).'),
|
|
61
|
+
intro_periods: z.number().int().min(1).max(36).optional().describe('Billing periods the promo lasts (default 1).'),
|
|
62
|
+
cancel_url: z.string().max(2048).optional().describe('https URL where subscribers cancel online (used in generated text).'),
|
|
45
63
|
cancel_timing: z.enum(['period_end', 'immediately']).optional().describe('When a cancellation takes effect (default period_end).'),
|
|
46
64
|
};
|
|
47
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Pilot round 13 (sveltekit, change-guard, api-only): nested objects were not strict, so plans[].price_cents, a bogus
|
|
67
|
+
* key or merchant.name were dropped with no word. Nested objects now refuse unknown keys with the closest known one.
|
|
68
|
+
*/
|
|
69
|
+
export function strictObject(shape, where, hints = {}) {
|
|
70
|
+
const known = Object.keys(shape);
|
|
71
|
+
return z
|
|
72
|
+
.object(shape, unknownKeysParams((keys) => nestedUnknownMessage(where, keys, known, hints)))
|
|
73
|
+
.strict();
|
|
74
|
+
}
|
|
75
|
+
const planTerms = strictObject(planTermsShape, 'terms');
|
|
76
|
+
const merchantTerms = strictObject(planTermsShape, 'merchant');
|
|
48
77
|
// Values above 30 are clamped to 30 instead of rejected (pilot: wait=60 was a raw -32602 validation error).
|
|
49
78
|
const wait = z
|
|
50
79
|
.number()
|
|
@@ -55,32 +84,51 @@ const wait = z
|
|
|
55
84
|
.describe('Seconds to wait for the result before answering (0-30; larger values wait 30; default 25). Use 0 to return at once and poll later.');
|
|
56
85
|
const listLimit = z.number().int().min(1).max(100).optional().describe('How many to return (1-100, default 10).');
|
|
57
86
|
const startingAfter = z.string().max(100).optional().describe('Pagination cursor: the last id of the previous page (when has_more was true).');
|
|
87
|
+
/** Plan fields agents reach for, mapped to the setup field (plans[] amounts are minor units). */
|
|
88
|
+
const PLAN_FIELD_HINTS = Object.freeze({
|
|
89
|
+
price: 'amount (minor units: 800 = $8.00)',
|
|
90
|
+
price_cents: 'amount (minor units: 800 = $8.00)',
|
|
91
|
+
amount_cents: 'amount',
|
|
92
|
+
unit_amount: 'amount',
|
|
93
|
+
trial_period_days: 'trial_days',
|
|
94
|
+
billing_interval: 'interval',
|
|
95
|
+
plan_name: 'name',
|
|
96
|
+
stripe_price: 'price_id',
|
|
97
|
+
intro_price: 'intro_amount (minor units)',
|
|
98
|
+
cancel_url: 'account_url (a site field, not a plan field)',
|
|
99
|
+
});
|
|
58
100
|
const setupFactsShape = {
|
|
59
101
|
site_url: z.string().max(2048).optional().describe('Public URL of the site (production, or a preview deployment), e.g. "https://acme.example".'),
|
|
60
102
|
checkout_url: z.string().max(2048).optional().describe('Page where customers subscribe (pricing or checkout page).'),
|
|
61
|
-
account_url: z.string().max(2048).optional().describe('Account page where subscribers manage / cancel.'),
|
|
62
|
-
terms_url: z.string().max(2048).optional().describe('Terms of service URL. Checkbox fetches and checks it.'),
|
|
63
|
-
privacy_url: z.string().max(2048).optional().describe('Privacy policy URL.'),
|
|
64
|
-
refund_url: z.string().max(2048).optional().describe('Refund policy URL.'),
|
|
65
|
-
cancellation_url: z.string().max(2048).optional().describe('Cancellation policy URL, if separate from the Terms.'),
|
|
103
|
+
account_url: z.string().max(2048).nullable().optional().describe('Account page where subscribers manage / cancel (null clears a URL).'),
|
|
104
|
+
terms_url: z.string().max(2048).nullable().optional().describe('Terms of service URL. Checkbox fetches and checks it.'),
|
|
105
|
+
privacy_url: z.string().max(2048).nullable().optional().describe('Privacy policy URL.'),
|
|
106
|
+
refund_url: z.string().max(2048).nullable().optional().describe('Refund policy URL.'),
|
|
107
|
+
cancellation_url: z.string().max(2048).nullable().optional().describe('Cancellation policy URL, if separate from the Terms.'),
|
|
66
108
|
support_email: z.string().max(320).optional().describe('Support address subscribers can write to.'),
|
|
67
|
-
merchant_name: z.string().max(100).optional().describe('The brand subscribers know (names the seller in generated disclosures), e.g. from the <title> or package.json. Default: the host name, which the plan then asks you to replace.'),
|
|
109
|
+
merchant_name: z.string().max(100).optional().describe('The brand subscribers know (names the seller in generated disclosures), e.g. from the <title> or package.json (also accepted as "brand"). Default: the host name, which the plan then asks you to replace.'),
|
|
68
110
|
billing_platform: z
|
|
69
111
|
.enum(['stripe', 'shopify', 'recharge', 'chargebee', 'recurly', 'paddle', 'woocommerce', 'custom'])
|
|
70
112
|
.optional()
|
|
71
113
|
.describe('Billing platform the code uses.'),
|
|
72
114
|
plans: z
|
|
73
|
-
.array(
|
|
115
|
+
.array(strictObject({
|
|
74
116
|
name: z.string().max(200).optional(),
|
|
75
117
|
amount: z.number().int().min(0).optional().describe('Minor units (1900 = $19).'),
|
|
76
118
|
currency: z.string().length(3).optional(),
|
|
77
119
|
interval: z.enum(['day', 'week', 'month', 'year']).optional(),
|
|
78
120
|
interval_count: z.number().int().min(1).max(36).optional(),
|
|
79
121
|
trial_days: z.number().int().min(0).max(730).optional(),
|
|
80
|
-
|
|
122
|
+
// Pilot round 13 (change-guard): intro / promo terms had nowhere to live, so the generated copy dropped them.
|
|
123
|
+
intro_amount: z.number().int().min(0).optional().describe('Promo first-period price, minor units.'),
|
|
124
|
+
intro_percent_off: z.number().gt(0).lt(100).optional(),
|
|
125
|
+
intro_amount_off: z.number().int().positive().optional(),
|
|
126
|
+
intro_periods: z.number().int().min(1).max(36).optional(),
|
|
127
|
+
price_id: z.string().max(200).optional().describe('Stripe Price id.'),
|
|
128
|
+
}, 'plans[]', PLAN_FIELD_HINTS))
|
|
81
129
|
.max(50)
|
|
82
130
|
.optional()
|
|
83
|
-
.describe('Plans and prices found in the code, e.g. [{"name":"Pro","amount":1900,"currency":"usd","interval":"month","trial_days":14}]. They decide which laws apply
|
|
131
|
+
.describe('Plans and prices found in the code, e.g. [{"name":"Pro","amount":1900,"currency":"usd","interval":"month","trial_days":14}]; a promo adds intro_percent_off and intro_periods. They decide which laws apply and what generated copy states.'),
|
|
84
132
|
project: z
|
|
85
133
|
.object({
|
|
86
134
|
name: z.string().max(200).optional().describe('package.json name.'),
|
|
@@ -132,9 +180,7 @@ async function resolveSite(api, site) {
|
|
|
132
180
|
operation_id: 'createSetupSession',
|
|
133
181
|
});
|
|
134
182
|
}
|
|
135
|
-
throw new ToolError(`The account has ${list.data.length}${list.has_more ? '+' : ''} sites; pass "site". Sites: ${list.data.map((s) => `${s.id} (${s.url})`).join(', ')}.`,
|
|
136
|
-
sites: list.data.map((s) => ({ id: s.id, url: s.url })),
|
|
137
|
-
});
|
|
183
|
+
throw new ToolError(`The account has ${list.data.length}${list.has_more ? '+' : ''} sites; pass "site". Sites: ${list.data.map((s) => `${s.id} (${s.name ? `${untrusted(s.name, 60)}, ` : ''}${untrusted(s.url, 100)})`).join(', ')}.`, { type: 'pass_site', message: `Call the same tool again with "site" set to one of the ids above${list.has_more ? ' (list_sites shows the rest)' : ''}.` }, { sites: list.data.map((s) => ({ id: s.id, name: s.name ?? null, url: s.url })) });
|
|
138
184
|
}
|
|
139
185
|
/** The setup session to act on: the given id or the newest. */
|
|
140
186
|
async function resolveSetupSession(api, id) {
|
|
@@ -148,32 +194,42 @@ async function resolveSetupSession(api, id) {
|
|
|
148
194
|
}
|
|
149
195
|
const statusLine = (v) => (typeof v === 'string' ? v.toUpperCase() : String(v));
|
|
150
196
|
/**
|
|
151
|
-
* One bullet per check that needs something from the human, in the user-facing labels (status.ts):
|
|
197
|
+
* One bullet per check that needs something from the human, in the user-facing labels (status.ts): FIX NEEDED with the
|
|
152
198
|
* one change, YOUR CALL with the question and the recommended answer, LIVE CHECK PENDING with how it completes. The
|
|
153
199
|
* texts are Checkbox's own wording (never quoted from the checked page / email).
|
|
200
|
+
*
|
|
201
|
+
* Pilot round 12 (change-guard): the bullet said "NEEDS A FIX" / "LOOKS COMPLIANT" while the JSON said needs_review.
|
|
202
|
+
* Each bullet now carries the JSON status in brackets and its label never says more than the status does: a fix
|
|
203
|
+
* Checkbox could not confirm (needs_review) is YOUR CALL, and a note is CHECKS PASS (on a pass) or NOTE (needs_review).
|
|
204
|
+
* Round 16 (item 21, change-guard: the headline said "1 fix needed" over a "YOUR CALL" bullet): the bullet uses the
|
|
205
|
+
* roll-up's own word. A check the roll-up counts as a fix is FIX NEEDED; the bracket still says when it is needs_review.
|
|
154
206
|
*/
|
|
155
207
|
export function complianceBullet(checkId, c, fallbackStatus, title) {
|
|
208
|
+
const st = ['pass', 'fail', 'needs_review', 'not_applicable'].includes(fallbackStatus) ? ` [${fallbackStatus}]` : '';
|
|
156
209
|
if (!c)
|
|
157
|
-
return fallbackStatus === 'fail' || fallbackStatus === 'needs_review' ? `${fallbackStatus === 'fail' ? '
|
|
210
|
+
return fallbackStatus === 'fail' || fallbackStatus === 'needs_review' ? `${fallbackStatus === 'fail' ? 'FIX NEEDED' : 'YOUR CALL'} ${checkId}${st}: ${title}` : null;
|
|
158
211
|
switch (c.status) {
|
|
159
212
|
case 'needs_fix':
|
|
160
|
-
return `
|
|
213
|
+
return fallbackStatus === 'needs_review' ? `FIX NEEDED ${checkId}${st}: Checkbox could not confirm this is met; the fix: ${c.fix ?? title}` : `FIX NEEDED ${checkId}${st}: ${c.fix ?? title}`;
|
|
161
214
|
case 'your_call':
|
|
162
|
-
return `YOUR CALL ${checkId}: ${c.question?.text ?? title}${c.question?.recommended ? ` Recommended: ${c.question.recommended}` : ''}`;
|
|
215
|
+
return `YOUR CALL ${checkId}${st}: ${c.question?.text ?? title}${c.question?.recommended ? ` Recommended: ${c.question.recommended}` : ''}`;
|
|
163
216
|
case 'pending_live_check':
|
|
164
|
-
return `LIVE CHECK PENDING ${checkId}: ${c.pending_live_check ? `${c.pending_live_check.what} — ${c.pending_live_check.completes}` : title} (counts as
|
|
217
|
+
return `LIVE CHECK PENDING ${checkId}${st}: ${c.pending_live_check ? `${c.pending_live_check.what} — ${c.pending_live_check.completes}` : title} (counts as passing the checks)`;
|
|
165
218
|
case 'compliant':
|
|
166
|
-
return c.note ?
|
|
219
|
+
return c.note ? `${fallbackStatus === 'needs_review' ? 'NOTE' : 'CHECKS PASS'} ${checkId}${st}: ${c.note}` : null;
|
|
167
220
|
default:
|
|
168
221
|
return null;
|
|
169
222
|
}
|
|
170
223
|
}
|
|
171
224
|
/** Bullets for the checks of a /v1/checks result that need something (the model reads these before the JSON). */
|
|
172
|
-
function checkNotes(r, max = 8) {
|
|
225
|
+
function checkNotes(r, max = 8, covered) {
|
|
173
226
|
const rank = { needs_fix: 0, your_call: 1, pending_live_check: 2, compliant: 3 };
|
|
227
|
+
// Agent audit round 2: six "NEEDS A FIX" lines with generic "<interval> at <price>" wording competed with the one
|
|
228
|
+
// engine-verified replacement that fixes all of them; when a verified fix covers a check, the line says so instead.
|
|
229
|
+
const coveredBy = (c) => !!covered && c.compliance?.status === 'needs_fix' && !(covered.except ?? []).includes(c.check_id);
|
|
174
230
|
const lines = r.checks
|
|
175
231
|
.map((c) => {
|
|
176
|
-
const line = complianceBullet(c.check_id, c.compliance, c.status, c.title);
|
|
232
|
+
const line = coveredBy(c) ? `FIX NEEDED ${c.check_id} [${c.status}]: fixed by ${covered.by}` : complianceBullet(c.check_id, c.compliance, c.status, c.title);
|
|
177
233
|
const cite = c.requirements?.[0]?.citation;
|
|
178
234
|
return { c, line: line && cite && c.compliance_status !== 'compliant' ? `${line} [${cite}]` : line, r: rank[c.compliance_status ?? (c.status === 'fail' ? 'needs_fix' : 'your_call')] ?? 9 };
|
|
179
235
|
})
|
|
@@ -184,16 +240,85 @@ function checkNotes(r, max = 8) {
|
|
|
184
240
|
notes.push(`…and ${lines.length - max} more (see checks in the JSON).`);
|
|
185
241
|
for (const w of r.warnings ?? [])
|
|
186
242
|
notes.push(`Warning ${w.code}: ${w.message}`);
|
|
243
|
+
// A pass, or a needs_review whose roll-up is compliant (e.g. Stripe's own styling of custom_text, shown as a note).
|
|
244
|
+
const ci = ciHint(r.verdict === 'pass' || ['compliant', 'pending_live_check', 'verified_live'].includes(r.compliance?.status ?? ''));
|
|
245
|
+
if (ci)
|
|
246
|
+
notes.push(ci);
|
|
187
247
|
return notes;
|
|
188
248
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
249
|
+
/**
|
|
250
|
+
* Audit P1: a headline never says "Compliant": it says "Checks pass" (or "Not required for this plan" when no check
|
|
251
|
+
* applies), then the counts.
|
|
252
|
+
*/
|
|
253
|
+
export function plainLabel(label) {
|
|
254
|
+
return label
|
|
255
|
+
.replace(/^Looks compliant(?: with a note)?(?: — live check pending)?/i, 'Checks pass')
|
|
256
|
+
.replace(/\bCompliant\b/g, 'Checks pass')
|
|
257
|
+
.replace(/ is compliant\b/g, ': checks pass');
|
|
258
|
+
}
|
|
259
|
+
/** Audit P3: a check's evidence.summary / item details that only repeat its explanation are dropped. */
|
|
260
|
+
function withoutDuplicateEvidence(c) {
|
|
261
|
+
const explanation = typeof c.explanation === 'string' ? c.explanation : '';
|
|
262
|
+
const ev = c.evidence;
|
|
263
|
+
if (!ev || !explanation)
|
|
264
|
+
return c;
|
|
265
|
+
const { summary, items, ...rest } = ev;
|
|
266
|
+
const same = (t) => typeof t === 'string' && t.length > 0 && explanation.includes(t);
|
|
267
|
+
return {
|
|
268
|
+
...c,
|
|
269
|
+
evidence: {
|
|
270
|
+
...rest,
|
|
271
|
+
...(summary && !same(summary) ? { summary } : {}),
|
|
272
|
+
...(items ? { items: items.map(({ detail, ...i }) => (same(detail) ? i : { ...i, detail })) } : {}),
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
/** Audit P3: requirement ids as their citations (deduplicated), at most `max` plus "and N more". */
|
|
277
|
+
export function citationList(ids, requirements, max = 8) {
|
|
278
|
+
const byId = new Map((requirements ?? []).map((q) => [q.id, q.citation]));
|
|
279
|
+
const cites = [...new Set(ids.map((id) => byId.get(id) ?? id))];
|
|
280
|
+
return `${cites.slice(0, max).join('; ')}${cites.length > max ? `; and ${cites.length - max} more` : ''}`;
|
|
281
|
+
}
|
|
282
|
+
/** Audit P3: shortens at a word boundary, never mid-word. */
|
|
283
|
+
export function clipWords(v, n) {
|
|
284
|
+
if (typeof v !== 'string' || v.length <= n)
|
|
285
|
+
return v;
|
|
286
|
+
const cut = v.slice(0, n - 1);
|
|
287
|
+
const space = cut.lastIndexOf(' ');
|
|
288
|
+
return `${(space > n * 0.6 ? cut.slice(0, space) : cut).replace(/[\s,;:.(—-]+$/, '')}…`;
|
|
289
|
+
}
|
|
290
|
+
const verdictHeadline = (what, r) => {
|
|
291
|
+
if (r.verdict === 'not_applicable')
|
|
292
|
+
return `${what}: Not required for this plan.`;
|
|
293
|
+
const label = r.compliance ? plainLabel(r.compliance.label) : statusLine(r.verdict);
|
|
294
|
+
// One counting system per headline: the task-based status label only (the per-check counts stay in data.summary).
|
|
295
|
+
// Pilot round 12: "Checks pass" next to verdict needs_review read as a contradiction; the headline names the verdict
|
|
296
|
+
// and the checks that keep it open.
|
|
297
|
+
if (r.verdict !== 'pass' && /^Checks pass/.test(label)) {
|
|
298
|
+
const open = r.checks.filter((c) => c.status === r.verdict).map((c) => c.check_id);
|
|
299
|
+
return `${what}: ${label} (verdict ${r.verdict}${open.length ? `: ${open.slice(0, 4).join(', ')}` : ''}).`;
|
|
300
|
+
}
|
|
301
|
+
return `${what}: ${label}.`;
|
|
302
|
+
};
|
|
192
303
|
/**
|
|
193
304
|
* Keeps what the model needs to act and drops the bulk (everything stays available through the API): failing / unclear
|
|
194
305
|
* checks with their first citations, the ids of passing checks, and failing / unclear requirements only. A check run
|
|
195
306
|
* over "all" jurisdictions otherwise lists every state's requirement on every check (> 60 KB).
|
|
196
307
|
*/
|
|
308
|
+
const FAILING_REQUIREMENTS_SHOWN = 8;
|
|
309
|
+
/** Agent audit round 2: a passing result no longer echoes the 2 KB replacement / corrected params it did not need. */
|
|
310
|
+
function withoutUnneededFix(r) {
|
|
311
|
+
const counts = r.compliance?.counts;
|
|
312
|
+
if (!counts || (counts.needs_fix ?? 0) > 0 || r.verdict === 'fail')
|
|
313
|
+
return r;
|
|
314
|
+
const { replacement: _r, plan_replacements: _pr, ...rest } = r;
|
|
315
|
+
const correction = rest.correction;
|
|
316
|
+
if (correction && !correction.patch?.length) {
|
|
317
|
+
const { corrected_params: _c, ...c } = correction;
|
|
318
|
+
return { ...rest, correction: c };
|
|
319
|
+
}
|
|
320
|
+
return rest;
|
|
321
|
+
}
|
|
197
322
|
function slimCheckResult(r) {
|
|
198
323
|
const { checks, requirements, ...rest } = r;
|
|
199
324
|
const open = (s) => s === 'fail' || s === 'needs_review';
|
|
@@ -203,12 +328,23 @@ function slimCheckResult(r) {
|
|
|
203
328
|
checks: (checks ?? [])
|
|
204
329
|
.filter((c) => needsSomething(c))
|
|
205
330
|
.map(({ requirements: reqs, ...c }) => ({
|
|
206
|
-
...c,
|
|
331
|
+
...withoutDuplicateEvidence(c),
|
|
207
332
|
citations: [...new Set((reqs ?? []).map((q) => q.citation))].slice(0, 6),
|
|
208
333
|
jurisdictions: [...new Set((reqs ?? []).map((q) => q.jurisdiction))],
|
|
209
334
|
})),
|
|
210
335
|
passing_checks: (checks ?? []).filter((c) => c.status === 'pass').map((c) => c.check_id),
|
|
211
|
-
|
|
336
|
+
// Audit P1: failing_requirements lists only failing requirements; the ones left to review are counted apart.
|
|
337
|
+
// Agent audit round 2: a failing disclosure listed ~58 failing requirements (most of the payload); every check
|
|
338
|
+
// already carries its citations. The first ones are kept, with the total.
|
|
339
|
+
...(() => {
|
|
340
|
+
const failing = (requirements ?? []).filter((q) => q.status === 'fail').map((q) => ({ id: q.id, citation: q.citation, status: q.status }));
|
|
341
|
+
return { failing_requirements: failing.slice(0, FAILING_REQUIREMENTS_SHOWN), ...(failing.length > FAILING_REQUIREMENTS_SHOWN ? { failing_requirements_total: failing.length } : {}) };
|
|
342
|
+
})(),
|
|
343
|
+
review_requirements: (() => {
|
|
344
|
+
const review = (requirements ?? []).filter((q) => q.status === 'needs_review');
|
|
345
|
+
const citations = [...new Set(review.map((q) => q.citation))];
|
|
346
|
+
return { count: review.length, citations: citations.slice(0, 8), ...(citations.length > 8 ? { more: citations.length - 8 } : {}) };
|
|
347
|
+
})(),
|
|
212
348
|
};
|
|
213
349
|
}
|
|
214
350
|
async function optional(p) {
|
|
@@ -243,12 +379,42 @@ async function pollDeviceToken(api, deviceCode, maxWaitMs, intervalMs) {
|
|
|
243
379
|
}
|
|
244
380
|
}
|
|
245
381
|
/** What the first checks did: ran, or held because the site's domain is not verified yet (scan gate). */
|
|
246
|
-
function firstCheckNote(next) {
|
|
382
|
+
function firstCheckNote(next, verify) {
|
|
247
383
|
if (next?.type === 'verify_domain') {
|
|
248
|
-
|
|
384
|
+
const ro = verify?.unverified_scan?.queued
|
|
385
|
+
? ' First read-only scan queued (unverified: no checkout is submitted until you verify the domain); its results are labeled "unverified scan" and are not evidence.'
|
|
386
|
+
: '';
|
|
387
|
+
return `Site registered, but Checkbox has not scanned it fully: it only submits checkouts on websites their owners have verified. Until then it reads the site's own public homepage and Terms / privacy pages as an unverified preview (nothing stored as evidence; see the policies step).${ro} Follow Next to verify the domain (ask the human to publish the DNS TXT record or file, or add the meta tag; or install the snippet: Checkbox verifies the domain when it sees it live), then call get_setup_status with verify=true. Meanwhile check pages by sending their HTML (check_page_html / verify_task with html).`;
|
|
249
388
|
}
|
|
250
389
|
return 'First checks ran (site, Terms and policies fetched, first evaluation); follow Next.';
|
|
251
390
|
}
|
|
391
|
+
/** One line for a read-only (unverified) scan: always labeled, never evidence. */
|
|
392
|
+
export function unverifiedScanNote(u) {
|
|
393
|
+
if (!u || typeof u !== 'object')
|
|
394
|
+
return null;
|
|
395
|
+
const x = u;
|
|
396
|
+
const c = x.counts ?? {};
|
|
397
|
+
if (x.status === 'queued' || x.status === 'running')
|
|
398
|
+
return 'UNVERIFIED SCAN queued: a read-only look at the site\'s own public pages (no checkout is submitted until the domain is verified).';
|
|
399
|
+
if (x.status !== 'completed')
|
|
400
|
+
return `UNVERIFIED SCAN ${x.status ?? 'unknown'}${x.error ? `: ${untrusted(x.error, 160)}` : ''}.`;
|
|
401
|
+
const failing = (x.findings ?? []).filter((f) => f.status === 'fail').slice(0, 5).map((f) => f.check_id).filter(Boolean);
|
|
402
|
+
return `UNVERIFIED SCAN (read-only, not evidence, no checkout submitted): ${c.pages_captured ?? 0} page(s) read; ${c.failing ?? 0} failing, ${c.needs_review ?? 0} to review, ${c.passing ?? 0} passing${failing.length ? ` (failing: ${failing.join(', ')})` : ''}. Verify the domain for the full scan, which replaces it.`;
|
|
403
|
+
}
|
|
404
|
+
/** Round 20: one line with the plans Checkbox judges the site against (setup's site_plans), so no second call is needed. */
|
|
405
|
+
export function sitePlansNote(plans) {
|
|
406
|
+
if (!Array.isArray(plans))
|
|
407
|
+
return null;
|
|
408
|
+
if (!plans.length)
|
|
409
|
+
return 'Plans: none known yet. Pass plans[] (name, price, interval, trial, Stripe price id) with update_setup, or ask the human to connect Stripe.';
|
|
410
|
+
const lines = plans.slice(0, 6).map((p) => {
|
|
411
|
+
const every = (p.interval_count ?? 1) > 1 ? `every ${p.interval_count} ${p.interval}s` : `/${p.interval ?? '?'}`;
|
|
412
|
+
const trial = p.trial_days ? `, ${p.trial_days}-day trial` : ', no trial';
|
|
413
|
+
const ids = p.stripe_price_ids?.length ? `, ${p.stripe_price_ids.slice(0, 3).join(' ')}` : '';
|
|
414
|
+
return `${untrusted(p.name ?? 'plan', 80)} ${p.price ?? ''}${every}${trial}${ids}`;
|
|
415
|
+
});
|
|
416
|
+
return `Plans Checkbox judges against (${plans.length}): ${lines.join('; ')}${plans.length > 6 ? '; …' : ''}.`;
|
|
417
|
+
}
|
|
252
418
|
/**
|
|
253
419
|
* setup_checkbox on an OAuth connection (one-click connector): the human approved in the browser already and the
|
|
254
420
|
* connection itself is the credential, so nothing is pasted or saved. The site is registered by the setup session
|
|
@@ -263,12 +429,22 @@ async function setupOverOAuth(api, session, ctx) {
|
|
|
263
429
|
notes.push(`Human review page: ${session.review_url}`);
|
|
264
430
|
let next;
|
|
265
431
|
let firstCheck;
|
|
432
|
+
// Normal round 3 (nextjs): the response's steps said "checks: action_required, Not started" next to first_check's
|
|
433
|
+
// "checks: done". The session returned is the one after the first check, so both agree.
|
|
434
|
+
let fresh;
|
|
266
435
|
if (session.business?.site_url) {
|
|
267
436
|
try {
|
|
268
437
|
const v = await api.request('POST', `/v1/setup_sessions/${enc(session.id)}/verify`, { body: {} });
|
|
438
|
+
if (v.setup_session && typeof v.setup_session === 'object')
|
|
439
|
+
fresh = v.setup_session;
|
|
269
440
|
next = v.setup_session?.next_action;
|
|
270
|
-
firstCheck = { evaluated: v.evaluated ?? false, scan: v.scan ?? null, steps: v.setup_session?.steps?.map((st) => `${st.key}: ${st.status}`) ?? [] };
|
|
271
|
-
|
|
441
|
+
firstCheck = { evaluated: v.evaluated ?? false, scan: v.scan ?? null, unverified_scan: v.unverified_scan ?? null, steps: v.setup_session?.steps?.map((st) => `${st.key}: ${st.status}`) ?? [] };
|
|
442
|
+
if (v.domain_verified)
|
|
443
|
+
notes.push(`Domain verified: ${v.domain_verified.method === 'snippet' ? `the Checkbox snippet is live on ${v.domain_verified.domain}` : `${v.domain_verified.domain} (${v.domain_verified.method})`}.`);
|
|
444
|
+
notes.push(firstCheckNote(next, v));
|
|
445
|
+
const pl = sitePlansNote(v.setup_session?.site_plans);
|
|
446
|
+
if (pl)
|
|
447
|
+
notes.push(pl);
|
|
272
448
|
}
|
|
273
449
|
catch (err) {
|
|
274
450
|
notes.push(`First checks did not run yet (${err instanceof Error ? err.message.slice(0, 160) : 'error'}); call get_setup_status with verify=true.`);
|
|
@@ -281,8 +457,41 @@ async function setupOverOAuth(api, session, ctx) {
|
|
|
281
457
|
headline: `Setup session ${session.id} started${session.business?.site_url ? ` for ${untrusted(session.business.site_url, 100)}` : ''}${session.site ? ` (test-mode site ${session.site})` : ''}.`,
|
|
282
458
|
notes,
|
|
283
459
|
...(next ? { next } : {}),
|
|
284
|
-
data:
|
|
460
|
+
data: (() => {
|
|
461
|
+
const doc = { ...session, ...(fresh ?? {}) };
|
|
462
|
+
const keys = labelledPublicKeys(doc.install?.snippet_key, doc.publishable_key);
|
|
463
|
+
return withoutLegacyScore({ ...doc, ...(keys.length ? { public_keys: keys } : {}), ...(firstCheck ? { first_check: firstCheck } : {}) });
|
|
464
|
+
})(),
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Pilot round 9 (nextjs): setup_checkbox still showed `insurable: false` and `score: 26.8` next to the status label.
|
|
469
|
+
* The API keeps both (deprecated) for backward compatibility; agents see only the one status.
|
|
470
|
+
*/
|
|
471
|
+
export function withoutLegacyScore(data) {
|
|
472
|
+
if (!data || typeof data !== 'object')
|
|
473
|
+
return data;
|
|
474
|
+
const d = data;
|
|
475
|
+
const strip = (c) => {
|
|
476
|
+
if (!c || typeof c !== 'object')
|
|
477
|
+
return c;
|
|
478
|
+
// Audit P2: the deprecated per-check counts (failing / needs_review) disagreed with the label; the label and todo stay.
|
|
479
|
+
const { score: _score, insurable: _insurable, failing: _failing, needs_review: _needsReview, ...rest } = c;
|
|
480
|
+
return rest;
|
|
285
481
|
};
|
|
482
|
+
const out = { ...d };
|
|
483
|
+
if (d.compliance)
|
|
484
|
+
out.compliance = strip(d.compliance);
|
|
485
|
+
// Audit P3: a step whose next_action is the session's own next_action does not repeat it.
|
|
486
|
+
if (Array.isArray(d.steps) && d.next_action && typeof d.next_action === 'object') {
|
|
487
|
+
const top = JSON.stringify(d.next_action);
|
|
488
|
+
out.steps = d.steps.map((st) => (st && typeof st === 'object' && JSON.stringify(st.next_action) === top ? { ...st, next_action: 'same as next_action' } : st));
|
|
489
|
+
}
|
|
490
|
+
if (d.session && typeof d.session === 'object')
|
|
491
|
+
out.session = withoutLegacyScore(d.session);
|
|
492
|
+
if (d.first_check && typeof d.first_check === 'object')
|
|
493
|
+
out.first_check = withoutLegacyScore(d.first_check);
|
|
494
|
+
return out;
|
|
286
495
|
}
|
|
287
496
|
/**
|
|
288
497
|
* Facts given with the first setup_checkbox call, kept (in this MCP process) until the device_code poll applies them, so
|
|
@@ -300,19 +509,15 @@ const setupCheckbox = {
|
|
|
300
509
|
name: 'setup_checkbox',
|
|
301
510
|
title: 'Set up Checkbox for this project',
|
|
302
511
|
description: [
|
|
303
|
-
'Start here when the user says "add Checkbox", "make my subscriptions compliant", or
|
|
304
|
-
'Checkbox monitors a subscription business against automatic-renewal laws (FTC ROSCA, California ARL, New York and
|
|
305
|
-
'',
|
|
306
|
-
'Without an API key (first run): returns a link for the HUMAN to open and click Approve (they sign up with an email link or GitHub/Google if new; free, no card).',
|
|
307
|
-
'Show them the link in one line, then call setup_checkbox again with the returned device_code — it waits up to ~50 s for the approval; call again while it says pending.',
|
|
308
|
-
'On approval it returns test-mode API keys (shown once) and a setup session. Save CHECKBOX_SECRET_KEY to .env.local (git-ignored) and never commit it.',
|
|
512
|
+
'Start here when the user says "add Checkbox", "make my subscriptions compliant", or the project is not connected to Checkbox yet.',
|
|
513
|
+
'Checkbox monitors a subscription business against automatic-renewal laws (federal law, 27 states, DC and New York City: FTC ROSCA, California ARL, New York and the other state laws).',
|
|
309
514
|
'',
|
|
310
|
-
'
|
|
311
|
-
'Connected through the Checkbox connector (OAuth: claude.ai, Claude Desktop, Claude Code /mcp, Cursor): the human already approved; there is NO key to paste or save. Call it with site_url (and what else you know): it registers the site and runs the first checks in the same call.',
|
|
515
|
+
'Connected through the Checkbox connector (OAuth: claude.ai, Claude Desktop, Claude Code /mcp, Cursor, VS Code): the human already approved in the browser; there is NO key to ask for, paste or save. Call it with site_url (and what else you know): it registers the site and runs the first checks in the same call.',
|
|
312
516
|
'Pass everything you already know from the codebase: site_url, checkout_url, terms/privacy/refund/cancellation URLs, billing_platform, plans, project.layout_file. Checkbox fetches and checks the Terms itself.',
|
|
313
517
|
'',
|
|
314
|
-
'
|
|
315
|
-
'
|
|
518
|
+
'Only for clients without OAuth (no API key configured on this server): it returns a link for the HUMAN to open and click Approve (free, no card). Show them the link in one line, then call setup_checkbox again with the returned device_code — it waits up to ~50 s; call again while it says pending. On approval it returns test-mode API keys (shown once): save CHECKBOX_SECRET_KEY to .env.local (git-ignored), never commit it, and never ask the human to paste a key.',
|
|
519
|
+
'',
|
|
520
|
+
'After this: follow next_action (usually verify the domain and install the snippet, then get_setup_status with verify=true), then get_compliance_plan.',
|
|
316
521
|
].join('\n'),
|
|
317
522
|
input: {
|
|
318
523
|
...setupFactsShape,
|
|
@@ -347,6 +552,8 @@ const setupCheckbox = {
|
|
|
347
552
|
notes.push(`claude mcp add --transport http checkbox ${ctx.mcpUrl} --header "Authorization: Bearer $CHECKBOX_SECRET_KEY" (Cursor / VS Code: add the same header to the server config). Until then, the plan below is all you need; the HTTP API works with the key right away.`);
|
|
348
553
|
}
|
|
349
554
|
notes.push('The secret key is shown only this once. Never commit it; the publishable key (pk_) is safe in browser code (e.g. NEXT_PUBLIC_CHECKBOX_PUBLISHABLE_KEY).');
|
|
555
|
+
// Round 16 (pilot 15, sveltekit / change-guard): two public keys in one response, unexplained.
|
|
556
|
+
notes.push("Two public keys: the snippet tag's data-key is the site's snippet key (install.snippet_key; use the script_tag exactly as given); publishable_key is the account key for Checkbox Elements and consent receipts only.");
|
|
350
557
|
// In the same call, with the new key: apply the facts given (they are not lost between the two calls), run the
|
|
351
558
|
// first checks, and return the setup checklist's next step — exactly what `checkbox init` does.
|
|
352
559
|
// Normal-round pilot (sveltekit): the first checks failed with "No API key provided" and the facts given with the
|
|
@@ -354,26 +561,53 @@ const setupCheckbox = {
|
|
|
354
561
|
const keyed = ctx.apiForKey?.(res.secret_key) ?? (ctx.adoptKeys ? ctx.api : undefined);
|
|
355
562
|
let next;
|
|
356
563
|
let firstCheck;
|
|
564
|
+
let site = res.site ?? null;
|
|
565
|
+
let inputWarnings = [];
|
|
566
|
+
let snippetKey;
|
|
357
567
|
if (keyed && res.setup_session) {
|
|
358
568
|
try {
|
|
359
569
|
const facts = { ...(PENDING_SETUP_FACTS.get(args.device_code) ?? {}), ...pick(args, Object.keys(setupFactsShape)) };
|
|
360
570
|
PENDING_SETUP_FACTS.delete(args.device_code);
|
|
361
|
-
|
|
362
|
-
|
|
571
|
+
// Pilot round 13 (change-guard): `agent` given with the device_code call (or known only now: a stateless
|
|
572
|
+
// remote connection has no client name on the first call) was never stored, so the session said agent null.
|
|
573
|
+
const agentName = args.agent ?? ctx.key.appName ?? ctx.clientName;
|
|
574
|
+
if (agentName)
|
|
575
|
+
facts.client = { name: 'checkbox-mcp', version: ctx.serverVersion, agent: String(agentName).slice(0, 100) };
|
|
576
|
+
if (Object.keys(facts).length) {
|
|
577
|
+
const upd = await keyed.request('POST', `/v1/setup_sessions/${res.setup_session}`, { body: facts });
|
|
578
|
+
if (Array.isArray(upd?.warnings))
|
|
579
|
+
inputWarnings = upd.warnings;
|
|
580
|
+
}
|
|
363
581
|
const v = await keyed.request('POST', `/v1/setup_sessions/${res.setup_session}/verify`, { body: {} });
|
|
364
582
|
next = v.setup_session?.next_action;
|
|
365
|
-
|
|
583
|
+
snippetKey = v.setup_session?.install?.snippet_key;
|
|
584
|
+
// Pilot round 13 (change-guard): the site created by these facts was missing from the result (site: null next
|
|
585
|
+
// to "site: done"), and the JSON next_action was the device token's, not the checklist's.
|
|
586
|
+
if (typeof v.setup_session?.site === 'string')
|
|
587
|
+
site = v.setup_session.site;
|
|
588
|
+
firstCheck = { evaluated: v.evaluated ?? false, scan: v.scan ?? null, steps: v.setup_session?.steps?.map((st) => `${st.key}: ${st.status}`) ?? [], ...(Array.isArray(v.setup_session?.site_plans) ? { site_plans: v.setup_session.site_plans } : {}) };
|
|
366
589
|
notes.push(firstCheckNote(next));
|
|
590
|
+
const pl = sitePlansNote(v.setup_session?.site_plans);
|
|
591
|
+
if (pl)
|
|
592
|
+
notes.push(pl);
|
|
367
593
|
}
|
|
368
594
|
catch (err) {
|
|
369
595
|
notes.push(`First checks did not run yet (${err instanceof Error ? err.message.slice(0, 160) : 'error'}); call get_setup_status with verify=true.`);
|
|
370
596
|
}
|
|
371
597
|
}
|
|
372
598
|
return {
|
|
373
|
-
headline: `Approved. Checkbox account ready${
|
|
599
|
+
headline: `Approved. Checkbox account ready${site ? ` with test-mode site ${site}` : ''}; setup session ${res.setup_session ?? '(none)'}.`,
|
|
374
600
|
notes,
|
|
375
601
|
...(next ? { next } : {}),
|
|
376
|
-
data: {
|
|
602
|
+
data: {
|
|
603
|
+
...res,
|
|
604
|
+
site,
|
|
605
|
+
...(next ? { next_action: next } : {}),
|
|
606
|
+
env: { CHECKBOX_SECRET_KEY: res.secret_key, ...(res.publishable_key ? { CHECKBOX_PUBLISHABLE_KEY: res.publishable_key } : {}) },
|
|
607
|
+
public_keys: labelledPublicKeys(snippetKey ?? res.install?.snippet_key, res.publishable_key),
|
|
608
|
+
...(firstCheck ? { first_check: firstCheck } : {}),
|
|
609
|
+
...(inputWarnings.length ? { warnings: inputWarnings } : {}),
|
|
610
|
+
},
|
|
377
611
|
};
|
|
378
612
|
}
|
|
379
613
|
if (!api.hasKey) {
|
|
@@ -412,13 +646,31 @@ const setupCheckbox = {
|
|
|
412
646
|
const notes = session.review_url ? [`Human review page: ${session.review_url}`] : [];
|
|
413
647
|
if (session.next_action?.type === 'verify_domain')
|
|
414
648
|
notes.push(firstCheckNote(session.next_action));
|
|
649
|
+
const plansLine = sitePlansNote(session.site_plans);
|
|
650
|
+
if (plansLine)
|
|
651
|
+
notes.push(plansLine);
|
|
415
652
|
return {
|
|
416
653
|
headline: `Setup session ${session.id} started${session.business?.site_url ? ` for ${untrusted(session.business.site_url, 100)}` : ''}${session.site ? ` (test-mode site ${session.site})` : ''}.`,
|
|
417
654
|
notes,
|
|
418
|
-
data:
|
|
655
|
+
data: (() => {
|
|
656
|
+
const keys = labelledPublicKeys(session.install?.snippet_key, session.publishable_key);
|
|
657
|
+
return keys.length ? { ...withoutLegacyScore(session), public_keys: keys } : withoutLegacyScore(session);
|
|
658
|
+
})(),
|
|
419
659
|
};
|
|
420
660
|
},
|
|
421
661
|
};
|
|
662
|
+
/**
|
|
663
|
+
* Round 20 (pilot 19 item 16, sveltekit / change-guard: a pk_ site key and a pk_test_ key side by side, unlabelled): each
|
|
664
|
+
* public key says what it is for.
|
|
665
|
+
*/
|
|
666
|
+
export function labelledPublicKeys(snippetKey, publishableKey) {
|
|
667
|
+
const out = [];
|
|
668
|
+
if (typeof snippetKey === 'string' && snippetKey)
|
|
669
|
+
out.push({ key: snippetKey, name: 'Site snippet key', use: 'The data-key of the Checkbox snippet <script> tag, for this site only (use install.script_tag exactly as given).' });
|
|
670
|
+
if (typeof publishableKey === 'string' && publishableKey)
|
|
671
|
+
out.push({ key: publishableKey, name: 'Account publishable key', use: 'For Checkbox Elements (<checkbox-disclosure>) and consent receipts (data-publishable-key) only; never the snippet data-key.' });
|
|
672
|
+
return out;
|
|
673
|
+
}
|
|
422
674
|
const getSetupStatus = {
|
|
423
675
|
name: 'get_setup_status',
|
|
424
676
|
title: 'Get the setup checklist (optionally re-check the site)',
|
|
@@ -439,14 +691,30 @@ const getSetupStatus = {
|
|
|
439
691
|
if (!keyAllows(key, 'setup_sessions:write'))
|
|
440
692
|
throw new ToolError('verify=true needs a key with setup_sessions write access; call again without verify.');
|
|
441
693
|
const res = await api.request('POST', `/v1/setup_sessions/${enc(id)}/verify`, { body: {} });
|
|
442
|
-
|
|
694
|
+
const notes = [];
|
|
695
|
+
if (res.domain_verified)
|
|
696
|
+
notes.push(`Domain verified: ${res.domain_verified.method === 'snippet' ? `the Checkbox snippet is live on ${res.domain_verified.domain}` : `${res.domain_verified.domain} (${res.domain_verified.method})`}.`);
|
|
697
|
+
if (res.unverified_scan?.queued)
|
|
698
|
+
notes.push('First read-only scan queued (unverified: no checkout is submitted until you verify the domain).');
|
|
699
|
+
else if (res.unverified_scan?.reason)
|
|
700
|
+
notes.push(`No new read-only scan: ${untrusted(res.unverified_scan.reason, 200)}.`);
|
|
701
|
+
const ro = unverifiedScanNote(res.setup_session?.unverified_scan);
|
|
702
|
+
if (ro && !res.unverified_scan?.queued)
|
|
703
|
+
notes.push(ro);
|
|
704
|
+
const pl = sitePlansNote(res.setup_session?.site_plans);
|
|
705
|
+
if (pl)
|
|
706
|
+
notes.push(pl);
|
|
707
|
+
return { headline: `Re-checked setup session ${id}.`, ...(notes.length ? { notes } : {}), data: withoutLegacyScore(res), next: nextActionOf(res) ?? nextActionOf(res.session) };
|
|
443
708
|
}
|
|
444
709
|
const session = await api.request('GET', `/v1/setup_sessions/${enc(id)}`);
|
|
445
710
|
const open = (session.steps ?? []).filter((s) => s.status && !['done', 'skipped', 'not_applicable'].includes(s.status));
|
|
711
|
+
const ro = unverifiedScanNote(session.unverified_scan);
|
|
712
|
+
const plansLine = sitePlansNote(session.site_plans);
|
|
446
713
|
return {
|
|
447
714
|
headline: `Setup session ${session.id}: ${session.status}; ${open.length} checklist step(s) open.`,
|
|
448
|
-
|
|
449
|
-
|
|
715
|
+
// The policies step says what an unverified preview read (or that the pages wait for domain verification).
|
|
716
|
+
notes: [...open.slice(0, 8).map((s) => `${s.status}: ${s.title ?? s.key}${s.key === 'policies' && s.summary ? ` (${s.summary.slice(0, 240)})` : ''}`), ...(ro ? [ro] : []), ...(plansLine ? [plansLine] : [])],
|
|
717
|
+
data: withoutLegacyScore(session),
|
|
450
718
|
};
|
|
451
719
|
},
|
|
452
720
|
};
|
|
@@ -472,9 +740,24 @@ const updateSetup = {
|
|
|
472
740
|
const id = await resolveSetupSession(api, args.setup_session);
|
|
473
741
|
const body = pick(args, [...Object.keys(setupFactsShape), 'snippet_installed_in', 'env_written', 'live_site', 'refetch_policies']);
|
|
474
742
|
const session = await api.request('POST', `/v1/setup_sessions/${enc(id)}`, { body });
|
|
475
|
-
|
|
743
|
+
// Round 20 (pilot 19 item 6): a plans change re-evaluates the site; say where the new plan's tasks show up.
|
|
744
|
+
const notes = Array.isArray(args.plans) && args.plans.length
|
|
745
|
+
? ['Plans saved: Checkbox re-evaluates the site with them now, so get_compliance_plan lists each plan\'s tasks (e.g. the trial reminder of a plan with a trial). If one seems missing, call verify_site to re-check at once.']
|
|
746
|
+
: [];
|
|
747
|
+
return { headline: `Updated setup session ${session.id}.`, notes, data: withoutLegacyScore(session) };
|
|
476
748
|
},
|
|
477
749
|
};
|
|
750
|
+
/** Round 13 (pilot 12, codex: "sent" while nothing new arrived): the summary email's real delivery status, with when. */
|
|
751
|
+
export function summaryEmailLine(status, d) {
|
|
752
|
+
const when = d?.at ? ` at ${new Date(d.at * 1000).toISOString().slice(0, 16).replace('T', ' ')} UTC` : '';
|
|
753
|
+
if (status === 'sent')
|
|
754
|
+
return `sent${d?.to ? ` to ${d.to}` : ''} (the mail server accepted it${when}; it is sent once per setup)`;
|
|
755
|
+
if (status === 'failed')
|
|
756
|
+
return `failed${d?.error ? ` (${d.error})` : ''}; call complete_setup again to retry`;
|
|
757
|
+
if (status === 'not_configured')
|
|
758
|
+
return 'not sent: this deployment has no email delivery';
|
|
759
|
+
return 'not sent';
|
|
760
|
+
}
|
|
478
761
|
const completeSetup = {
|
|
479
762
|
name: 'complete_setup',
|
|
480
763
|
title: 'Finish setup and email the human a summary',
|
|
@@ -486,15 +769,32 @@ const completeSetup = {
|
|
|
486
769
|
const id = await resolveSetupSession(api, args.setup_session);
|
|
487
770
|
const session = await api.request('POST', `/v1/setup_sessions/${enc(id)}/complete`, { body: {} });
|
|
488
771
|
return {
|
|
489
|
-
headline: `Setup ${session.id} completed; summary email: ${session.owner_email
|
|
772
|
+
headline: `Setup ${session.id} completed; summary email: ${summaryEmailLine(session.owner_email, session.owner_email_detail)}.`,
|
|
490
773
|
notes: session.review_url ? [`Human review page: ${session.review_url}`] : [],
|
|
491
|
-
data: session,
|
|
774
|
+
data: withoutLegacyScore(session),
|
|
492
775
|
};
|
|
493
776
|
},
|
|
494
777
|
};
|
|
778
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
779
|
+
// Compliance plan loop
|
|
780
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
781
|
+
/**
|
|
782
|
+
* Round 21 (pilot 20 item 8, lawyer): the plan status in words ("Checks pass"), never the bare machine value `compliant`
|
|
783
|
+
* an agent would repeat to a merchant. Older APIs without status_label: the same mapping here.
|
|
784
|
+
*/
|
|
785
|
+
const PLAN_STATUS_WORDS = { action_required: 'Fixes needed', verifying: 'Checking your changes', compliant: 'Checks pass', blocked: 'Your call' };
|
|
786
|
+
export function planStatusWords(status, label) {
|
|
787
|
+
return label || PLAN_STATUS_WORDS[status] || status;
|
|
788
|
+
}
|
|
789
|
+
/** "Old task (covered by: Open task)": the open task sharing a check with a superseded one. Pure, exported for tests. */
|
|
790
|
+
export function coveredLine(t, open) {
|
|
791
|
+
const ids = t.check_ids ?? [];
|
|
792
|
+
const by = open.find((o) => o.key !== t.key && (o.check_ids ?? []).some((c) => ids.includes(c)));
|
|
793
|
+
return by ? `${t.title} (covered by: ${by.title})` : `${t.title} (covered by another open task)`;
|
|
794
|
+
}
|
|
495
795
|
/** The plan with each task reduced to what an agent needs to choose the next one (the full task: get_task). */
|
|
496
796
|
export function slimPlan(plan) {
|
|
497
|
-
const clip =
|
|
797
|
+
const clip = clipWords;
|
|
498
798
|
const tasks = (plan.tasks ?? []).map((t) => {
|
|
499
799
|
const full = t;
|
|
500
800
|
const change = (full.change ?? {});
|
|
@@ -518,15 +818,28 @@ export function slimPlan(plan) {
|
|
|
518
818
|
});
|
|
519
819
|
return { ...plan, tasks: tasks };
|
|
520
820
|
}
|
|
821
|
+
/**
|
|
822
|
+
* One-time review hint: when a site's plan is compliant, the API hands out the prompt once per site
|
|
823
|
+
* (claimReviewPrompt). Never fails the tool: older servers or a missing scope simply mean no hint.
|
|
824
|
+
*/
|
|
825
|
+
async function reviewHint(api, site, trigger) {
|
|
826
|
+
try {
|
|
827
|
+
const p = await api.request('POST', `/v1/sites/${enc(site)}/review_prompt`, { body: { trigger } });
|
|
828
|
+
return p.show && p.message ? [`One-time: ${p.message}`] : [];
|
|
829
|
+
}
|
|
830
|
+
catch {
|
|
831
|
+
return [];
|
|
832
|
+
}
|
|
833
|
+
}
|
|
521
834
|
const getCompliancePlan = {
|
|
522
835
|
name: 'get_compliance_plan',
|
|
523
836
|
title: 'Get the compliance plan (ordered tasks)',
|
|
524
837
|
description: [
|
|
525
|
-
'The main tool after setup. Returns the ordered tasks that make the site
|
|
838
|
+
'The main tool after setup. Returns the ordered tasks that make the site pass the automatic-renewal checks, recomputed from the latest observations.',
|
|
526
839
|
'Each task has: requirement + legal citation, why (what Checkbox observed), where (file / component / URL / CSS selector / Stripe object), change (exact unified-diff template, copy text or Stripe params), and verify (how to check).',
|
|
527
|
-
'Plan status: action_required (apply open tasks), verifying (waiting for confirming observations), compliant (done — "pending_live_check" tasks count as
|
|
840
|
+
'Plan status: action_required (apply open tasks), verifying (waiting for confirming observations), compliant (done: the checks pass — "pending_live_check" tasks count as passing: they complete on their own or with a proof run), blocked (only human questions — "Your call" — left).',
|
|
528
841
|
'Tasks are tagged tier required / recommended / extra. Only required tasks matter for compliance; recommended and extra tasks are optional suggestions (mention them once, do not push them).',
|
|
529
|
-
'Loop: apply the first open required task in the code → verify_task → get_compliance_plan → … until status
|
|
842
|
+
'Loop: apply the first open required task in the code → verify_task → get_compliance_plan → … until the checks pass (status compliant). Tasks with status "manual" carry a question with a recommended answer: ask the HUMAN, then answer_task. Never invent answers.',
|
|
530
843
|
].join('\n'),
|
|
531
844
|
input: { site: siteId.optional() },
|
|
532
845
|
scopes: ['compliance_plans:read'],
|
|
@@ -537,17 +850,39 @@ const getCompliancePlan = {
|
|
|
537
850
|
const open = (plan.tasks ?? []).filter((t) => !['done', 'wont_fix'].includes(t.status));
|
|
538
851
|
const required = open.filter((t) => (t.tier ?? 'required') === 'required');
|
|
539
852
|
const optional = open.filter((t) => (t.tier ?? 'required') !== 'required');
|
|
853
|
+
// One to-do list: the same ordered open tasks (titles, order, counts) as the dashboard, report, emails and CLI.
|
|
854
|
+
const todo = planTodo((plan.tasks ?? []));
|
|
855
|
+
const rest = required.filter((t) => !todo.some((x) => x.id === t.id));
|
|
856
|
+
const { score: _score, insurable: _insurable, ...shown } = plan;
|
|
540
857
|
return {
|
|
541
|
-
|
|
858
|
+
// Audit P2: one Next line (the plan's next_action below); the summary's own "Next: …" is not repeated.
|
|
859
|
+
headline: `Plan for ${site}: ${plainLabel(plan.compliance?.label ?? 'Not checked yet')} (plan status: ${planStatusWords(plan.status, plan.status_label)}).`,
|
|
542
860
|
notes: [
|
|
543
|
-
...
|
|
544
|
-
...(
|
|
545
|
-
...(
|
|
861
|
+
...(todo.length ? [`To do (${todo.length}, in order):`] : []),
|
|
862
|
+
...todo.map((t, i) => `${i + 1}. ${t.id} ${todoLine(t)}`),
|
|
863
|
+
...rest.map((t) => `${t.id} [${t.status}${t.priority ? `, ${t.priority}` : ''}${t.change?.type ? `, ${t.change.type}` : ''}] ${t.title}`),
|
|
864
|
+
// Round 14: a task closed because it no longer applies is "Not applicable", never "done". Round 16 (pilot 15,
|
|
865
|
+
// codex): a task closed by a passing verification is "Done (verified)", never "Not applicable".
|
|
866
|
+
...(() => {
|
|
867
|
+
const closed = (plan.tasks ?? []);
|
|
868
|
+
const verified = closed.filter((t) => t.status === 'done' && t.closed_reason === 'verified' && t.compliance_status !== 'not_applicable');
|
|
869
|
+
// Round 20 (pilot 19 item 9): a task merged into another open task is "covered by" it, never "not applicable".
|
|
870
|
+
const covered = closed.filter((t) => t.closed_reason === 'superseded');
|
|
871
|
+
const na = closed.filter((t) => t.compliance_status === 'not_applicable' && t.closed_reason !== 'verified' && t.closed_reason !== 'superseded');
|
|
872
|
+
return [
|
|
873
|
+
...(verified.length ? [`Done (verified): ${verified.map((t) => t.title).join('; ')}`] : []),
|
|
874
|
+
...(covered.length ? [`Merged into another task: ${covered.map((t) => coveredLine(t, open)).join('; ')}`] : []),
|
|
875
|
+
...(na.length ? [`Not applicable (closed without a change, nothing observed passing): ${na.map((t) => t.title).join('; ')}`] : []),
|
|
876
|
+
];
|
|
877
|
+
})(),
|
|
878
|
+
...(optional.length ? [`Optional suggestions (${plan.protection_level ?? 'balanced'} protection; never block the checks from passing): ${optional.map((t) => t.title).join('; ')}`] : []),
|
|
879
|
+
...(plan.optional_improvements ? ['Optional (never blocking, not part of compliance): conversion suggestions for this site with `suggest_improvements`.'] : []),
|
|
546
880
|
'Each task below is a summary; call get_task with its id for the full change (diff, copy or params) before applying it.',
|
|
881
|
+
...(plan.status === 'compliant' ? await reviewHint(api, site, 'plan_compliant') : []),
|
|
547
882
|
],
|
|
548
883
|
// Normal-round pilot (sveltekit): the full plan (53K characters) was cut mid-JSON by the client. The plan here is a
|
|
549
884
|
// compact summary of every task; get_task returns one task in full.
|
|
550
|
-
data: slimPlan(
|
|
885
|
+
data: slimPlan(shown),
|
|
551
886
|
};
|
|
552
887
|
},
|
|
553
888
|
};
|
|
@@ -560,9 +895,23 @@ const getTask = {
|
|
|
560
895
|
annotations: { ...ro, title: 'Task' },
|
|
561
896
|
async run(args, { api }) {
|
|
562
897
|
const task = await api.request('GET', `/v1/tasks/${enc(String(args.task))}`);
|
|
563
|
-
|
|
898
|
+
// Audit P3: a task citing every state's law listed dozens of citations; the first 8 and a count are enough here.
|
|
899
|
+
const cites = task.requirement?.citations;
|
|
900
|
+
const shown = Array.isArray(cites) && cites.length > 8 ? { ...task, requirement: { ...task.requirement, citations: cites.slice(0, 8), citations_more: cites.length - 8 } } : task;
|
|
901
|
+
const word = task.compliance_status === 'not_applicable' ? 'not applicable' : task.status;
|
|
902
|
+
return { headline: `Task ${task.id} [${word}]: ${task.title}`, data: shown };
|
|
564
903
|
},
|
|
565
904
|
};
|
|
905
|
+
/** Audit: the pre-check's per-check results, one compact line each (agents only saw the overall verdict). */
|
|
906
|
+
export function precheckLines(pre, max = 12) {
|
|
907
|
+
const checks = pre?.checks ?? [];
|
|
908
|
+
if (!checks.length)
|
|
909
|
+
return [];
|
|
910
|
+
const order = { fail: 0, needs_review: 1, missing_data: 1, pass: 2, not_applicable: 3 };
|
|
911
|
+
const sorted = [...checks].sort((a, b) => (order[a.status] ?? 1) - (order[b.status] ?? 1));
|
|
912
|
+
const lines = sorted.slice(0, max).map((c) => `${c.check_id}: ${c.status}${c.reason ? ` — ${untrusted(c.reason, 180)}` : ''}${c.fix ? ` Fix: ${untrusted(c.fix, 200)}` : ''}`);
|
|
913
|
+
return [`Pre-check (${pre?.verdict ?? 'n/a'}), per check:`, ...lines, ...(checks.length > max ? [`and ${checks.length - max} more (data.precheck.checks)`] : [])];
|
|
914
|
+
}
|
|
566
915
|
const verifyTask = {
|
|
567
916
|
name: 'verify_task',
|
|
568
917
|
title: 'Verify a task after applying its change',
|
|
@@ -578,29 +927,56 @@ const verifyTask = {
|
|
|
578
927
|
task: z.string().max(100).describe('Task id (task_…).'),
|
|
579
928
|
html: z.string().max(950_000).optional().describe('Page tasks: the rendered page HTML.'),
|
|
580
929
|
stripe_checkout_session: z.record(z.string(), z.unknown()).optional().describe('Stripe tasks: the params your code passes to stripe.checkout.sessions.create.'),
|
|
930
|
+
stripe_checkout_sessions: z.array(z.record(z.string(), z.unknown())).max(10).optional().describe('Stripe tasks, one params object per plan.'),
|
|
581
931
|
email: z
|
|
582
932
|
.object({
|
|
583
|
-
kind:
|
|
933
|
+
kind: emailKind.optional(),
|
|
584
934
|
subject: z.string().max(1000),
|
|
585
935
|
text: z.string().max(100_000).optional(),
|
|
586
936
|
html: z.string().max(100_000).optional(),
|
|
587
937
|
})
|
|
588
938
|
.optional()
|
|
589
|
-
.describe('Email tasks: the new template.'),
|
|
939
|
+
.describe('Email tasks: the new template (passes when it matches one plan fully).'),
|
|
940
|
+
plan: z.string().min(1).max(200).optional().describe('Email tasks: only this plan (name).'),
|
|
941
|
+
price_id: z.string().min(1).max(200).optional().describe('Email tasks: only this plan (Price id).'),
|
|
590
942
|
recheck: z.boolean().optional().describe('true re-checks a task that is already done (evaluates again now and queues a scan) instead of returning its last result.'),
|
|
591
943
|
wait,
|
|
592
944
|
},
|
|
593
945
|
scopes: ['compliance_plans:write'],
|
|
594
946
|
annotations: { ...write(true, true), title: 'Verify task' },
|
|
595
947
|
async run(args, { api }) {
|
|
596
|
-
const body = pick(args, ['html', 'stripe_checkout_session', 'email', 'recheck']);
|
|
948
|
+
const body = pick(args, ['html', 'stripe_checkout_session', 'stripe_checkout_sessions', 'email', 'plan', 'price_id', 'recheck']);
|
|
949
|
+
if (body.email && typeof body.email === 'object' && body.email.kind)
|
|
950
|
+
body.email = { ...body.email, kind: checkKind(body.email.kind) };
|
|
597
951
|
const v = await api.request('POST', `/v1/tasks/${enc(String(args.task))}/verify`, {
|
|
598
952
|
query: { wait: args.wait ?? 25 },
|
|
599
953
|
body,
|
|
600
954
|
});
|
|
955
|
+
const hint = v.plan_status === 'compliant' && v.site ? await reviewHint(api, v.site, 'last_task_verified') : [];
|
|
601
956
|
return {
|
|
602
|
-
headline: `Verification ${v.id} of ${String(args.task)}: ${statusLine(v.status)}${v.plan_status ? `; plan is now ${v.plan_status}` : ''}
|
|
603
|
-
notes:
|
|
957
|
+
headline: `Verification ${v.id} of ${String(args.task)}: ${statusLine(v.status)}${v.plan_status ? `; plan is now ${planStatusWords(v.plan_status, v.plan_status_label)}` : ''}.${v.checked ? ` Checked: ${untrusted(v.checked, 300)}.` : ''}`,
|
|
958
|
+
notes: [
|
|
959
|
+
...precheckLines(v.precheck),
|
|
960
|
+
...((v.status === 'failed' || v.status === 'not_checked') && v.next_action?.type === 'verify_domain'
|
|
961
|
+
? ['The domain is not verified yet. Waiting for the human to publish the DNS record / file / meta tag or install the snippet; don\'t retry until they say it\'s done. Nothing to change in the code.']
|
|
962
|
+
: v.next_action?.type === 'retry_later'
|
|
963
|
+
? ['Checkbox could not confirm it (a problem on Checkbox\'s side): nothing to change in the code. Continue with the next task; verify again later.']
|
|
964
|
+
: v.next_action?.type === 'live_check_pending' && v.status === 'pending'
|
|
965
|
+
? // Round 14 (pilot 13): Checkbox Notices sends this email; never "add the BCC".
|
|
966
|
+
[v.next_action.description ?? 'Checkbox Notices sends this email: nothing to add to your code. Continue with the next task.']
|
|
967
|
+
: v.next_action?.type === 'bcc_then_continue'
|
|
968
|
+
? ['The copy is checked; the task closes when the first real email arrives with the BCC. Add the BCC to your sending code and continue with the next task (no need to poll).']
|
|
969
|
+
: v.status === 'failed'
|
|
970
|
+
? ['The change did not satisfy the check: read diff (expected vs observed), fix the code, and call verify_task again.']
|
|
971
|
+
: v.status === 'not_checked'
|
|
972
|
+
? ['Not checked yet: Checkbox is missing evidence (see next_action), not a code problem. Do not change the code for this; provide what next_action asks for.']
|
|
973
|
+
: v.status === 'pending_live_check'
|
|
974
|
+
? ['Live check pending: the email content passes; Checkbox has not seen it sent for a real event yet. Nothing to change in the code: it completes on the first real send (or a proof run).']
|
|
975
|
+
: v.status === 'pending'
|
|
976
|
+
? ['Waiting for a confirming observation; call get_verification later (or continue with other tasks).']
|
|
977
|
+
: []),
|
|
978
|
+
...hint,
|
|
979
|
+
],
|
|
604
980
|
data: v,
|
|
605
981
|
};
|
|
606
982
|
},
|
|
@@ -608,7 +984,7 @@ const verifyTask = {
|
|
|
608
984
|
const answerTask = {
|
|
609
985
|
name: 'answer_task',
|
|
610
986
|
title: 'Record the human\'s answer to a manual task',
|
|
611
|
-
description: 'For tasks with status "manual": record the HUMAN\'s answer to task.question. Ask the human first and pass their words; never answer on their behalf (answers are legal attestations stored in the evidence vault). answer is one of task.question.options
|
|
987
|
+
description: 'For tasks with status "manual": record the HUMAN\'s answer to task.question. Ask the human first and pass their words; never answer on their behalf (answers are legal attestations stored in the evidence vault). answer is one of task.question.options; yes / no also work (= confirmed / needs_change). "no" turns the task into a change you can apply.',
|
|
612
988
|
input: {
|
|
613
989
|
task: z.string().max(100).describe('Task id (task_…) with status manual.'),
|
|
614
990
|
answer: z.string().max(40).describe('One of the task.question.options, as the human chose.'),
|
|
@@ -618,25 +994,31 @@ const answerTask = {
|
|
|
618
994
|
annotations: { ...write(false), title: 'Answer task' },
|
|
619
995
|
async run(args, { api }) {
|
|
620
996
|
const task = await api.request('POST', `/v1/tasks/${enc(String(args.task))}/answer`, { body: pick(args, ['answer', 'note']) });
|
|
997
|
+
// Round 21 (pilot 20 item 16): "done" on a task Checkbox already closed succeeds without recording anything.
|
|
998
|
+
if (task.status === 'done' && task.next_action?.type === 'retrieve_plan')
|
|
999
|
+
return { headline: `${task.id} was already done (Checkbox closed it itself): nothing to answer, nothing recorded.`, data: task };
|
|
621
1000
|
return { headline: `Recorded the answer on ${task.id}; task is now ${task.status}.`, data: task };
|
|
622
1001
|
},
|
|
623
1002
|
};
|
|
624
1003
|
const verifySite = {
|
|
625
1004
|
name: 'verify_site',
|
|
626
1005
|
title: 'Re-check the whole site now',
|
|
627
|
-
description: 'Re-fetches the registered policies, pre-checks the checkout, re-evaluates every law and queues a scan. Use it for the first check after setup, after deploying several changes, or when the plan looks stale. Waits up to `wait` seconds, then call get_compliance_plan.',
|
|
1006
|
+
description: 'Re-reads the connected billing platform (Stripe portal, prices, Checkout Sessions; rate-limited), re-fetches the registered policies, pre-checks the checkout, re-evaluates every law and queues a scan. Use it for the first check after setup, after deploying several changes, after changing a Stripe setting, or when the plan looks stale. Waits up to `wait` seconds, then call get_compliance_plan.',
|
|
628
1007
|
input: { site: siteId.optional(), wait },
|
|
629
1008
|
scopes: ['compliance_plans:write'],
|
|
630
1009
|
annotations: { ...write(true, true), title: 'Verify site' },
|
|
631
1010
|
async run(args, { api }) {
|
|
632
1011
|
const site = await resolveSite(api, args.site);
|
|
633
1012
|
const v = await api.request('POST', `/v1/sites/${enc(site)}/verify`, { query: { wait: args.wait ?? 25 }, body: {} });
|
|
1013
|
+
// Pilot 11: say what was re-read from the billing platform (the note's first sentence), so a stale answer is visible.
|
|
1014
|
+
const reread = /^(?:Re-read |Could not re-read |[A-Z][\w ]{0,40} was read ).*?(?:before judging\.|seconds\)\.|ago\.|earlier\.)/.exec(v.precheck?.note ?? '')?.[0];
|
|
634
1015
|
// A whole-site check that finds open checks produced the to-do list; it is not a failed change.
|
|
635
|
-
const
|
|
1016
|
+
const base = v.next_action?.type === 'verify_domain'
|
|
636
1017
|
? `Site check ${v.id}: nothing was fetched or scanned because the site's domain is not verified yet (Checkbox only visits websites their owners have verified). Follow Next to verify it.`
|
|
637
1018
|
: v.status === 'failed'
|
|
638
|
-
? `${v.next_action?.type === 'first_check_complete' ? 'First check complete' : 'Site check complete'} (${v.id}):
|
|
1019
|
+
? `${v.next_action?.type === 'first_check_complete' ? 'First check complete' : 'Site check complete'} (${v.id}): the open tasks of get_compliance_plan are the to-do list, not a failed change.${v.plan_status ? ` Plan: ${v.plan_status}.` : ''}`
|
|
639
1020
|
: `Site check ${v.id}: ${statusLine(v.status)}${v.plan_status ? `; plan is ${v.plan_status}` : ''}.`;
|
|
1021
|
+
const headline = reread ? `${base} ${reread}` : base;
|
|
640
1022
|
return { headline, data: v };
|
|
641
1023
|
},
|
|
642
1024
|
};
|
|
@@ -644,12 +1026,13 @@ const getVerification = {
|
|
|
644
1026
|
name: 'get_verification',
|
|
645
1027
|
title: 'Get a verification result',
|
|
646
1028
|
description: 'Status of a verification from verify_task / verify_site: pending, passed or failed, expected vs observed per check, the diff and next_action. Pass wait (≤ 30 s) to wait for a pending one.',
|
|
647
|
-
input: { verification: z.string().max(100).describe('
|
|
1029
|
+
input: { verification: z.string().max(100).describe('ver_…, or task_… (its newest)'), wait: wait },
|
|
648
1030
|
scopes: ['compliance_plans:read'],
|
|
649
1031
|
annotations: { ...ro, title: 'Verification' },
|
|
650
1032
|
async run(args, { api }) {
|
|
651
1033
|
const v = await api.request('GET', `/v1/verifications/${enc(String(args.verification))}`, { query: { wait: args.wait ?? 0 } });
|
|
652
|
-
|
|
1034
|
+
const notes = precheckLines(v.precheck);
|
|
1035
|
+
return { headline: `Verification ${v.id}: ${statusLine(v.status)}.`, ...(notes.length ? { notes } : {}), data: v };
|
|
653
1036
|
},
|
|
654
1037
|
};
|
|
655
1038
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
@@ -671,8 +1054,8 @@ const startTestRun = {
|
|
|
671
1054
|
title: 'Run a synthetic test subscription (test mode)',
|
|
672
1055
|
description: [
|
|
673
1056
|
'Observes the WHOLE subscription lifecycle, not just the checkout: in the billing platform\'s TEST mode Checkbox subscribes a test customer (card 4242…) whose email is a Checkbox inbox, waits for your acknowledgment email, fast-forwards a Stripe test clock through every legal notice window before trial end and renewal (collecting reminder emails), then cancels online and waits for the cancellation confirmation. Nothing is charged.',
|
|
674
|
-
'Needs a Stripe TEST key stored for the site
|
|
675
|
-
'
|
|
1057
|
+
'Needs a Stripe TEST key stored for the site — the human adds it in the Checkbox dashboard (the site\'s Proof page); never ask them to paste it into the chat. If missing, the error\'s next_action says so. For non-Stripe sites use type "generic".',
|
|
1058
|
+
'Usually 2-4 minutes: poll get_test_run (its next_action says when). Each step reports observations, check results and agent-readable fixes; apply them and run again.',
|
|
676
1059
|
'mode "real_checkout" is the PROOF run: the shopper buys through the merchant\'s own checkout on its test deployment (stripe.checkout_url or stripe.payment_link), so the merchant\'s webhook handlers and email code fire; give stripe.test_account + stripe.cancel_url when customers cancel in the app. A step is "passed" only with evidence; "warning" means needs review.',
|
|
677
1060
|
].join('\n'),
|
|
678
1061
|
input: {
|
|
@@ -727,8 +1110,12 @@ const startTestRun = {
|
|
|
727
1110
|
const getTestRun = {
|
|
728
1111
|
name: 'get_test_run',
|
|
729
1112
|
title: 'Get a test run (steps, observations, fixes)',
|
|
730
|
-
description: 'A synthetic test subscription with every step (checkout, acknowledgment email, reminders at each legal window, cancellation, confirmation): what was observed, check results, evidence ids and fixes. While running, next_action.type is "wait" (
|
|
731
|
-
input: {
|
|
1113
|
+
description: 'A synthetic test subscription with every step (checkout, acknowledgment email, reminders at each legal window, cancellation, confirmation): what was observed, check results, evidence ids and fixes. While running, next_action.type is "wait" and waiting_for says what it waits for (pass wait: 30 to get the answer as soon as the run moves on). When finished, apply the fixes (usually via get_compliance_plan) and start another run to verify. Without test_run, returns the newest run of the site.',
|
|
1114
|
+
input: {
|
|
1115
|
+
test_run: z.string().max(100).optional().describe('Test run id (run_…). Default: the newest run of the site.'),
|
|
1116
|
+
site: siteId.optional(),
|
|
1117
|
+
wait: z.number().int().min(0).max(30).optional().describe('Long-poll: wait up to this many seconds for the run to move on before answering.'),
|
|
1118
|
+
},
|
|
732
1119
|
scopes: ['test_runs:read'],
|
|
733
1120
|
annotations: { ...ro, title: 'Test run' },
|
|
734
1121
|
async run(args, { api }) {
|
|
@@ -740,10 +1127,14 @@ const getTestRun = {
|
|
|
740
1127
|
throw new ToolError(`Site ${site} has no test run yet.`, { type: 'start_test_run', message: 'Call start_test_run.', operation_id: 'createTestRun' });
|
|
741
1128
|
id = list.data[0].id;
|
|
742
1129
|
}
|
|
743
|
-
const
|
|
1130
|
+
const wait = typeof args.wait === 'number' && args.wait > 0 ? Math.min(30, Math.floor(args.wait)) : undefined;
|
|
1131
|
+
const run = await api.request('GET', `/v1/test_runs/${enc(id)}`, wait ? { query: { wait } } : undefined);
|
|
1132
|
+
const w = run.waiting_for;
|
|
1133
|
+
const progress = w ? [w.step && w.steps_total ? `step ${w.step} of ${w.steps_total}` : '', w.minutes_left ? `about ${w.minutes_left} min left` : ''].filter(Boolean).join(', ') : '';
|
|
1134
|
+
const waiting = w ? `Waiting for: ${w.what}${w.until ? ` (until ${new Date(w.until * 1000).toISOString().slice(0, 16).replace('T', ' ')} UTC at the latest)` : ''}${progress ? ` [${progress}]` : ''}` : null;
|
|
744
1135
|
return {
|
|
745
|
-
headline: `Test run ${run.id}: ${statusLine(run.status)}${run.phase ? ` (phase ${run.phase})` : ''}.`,
|
|
746
|
-
notes: (run.steps ?? []).map((s) => `${s.status ?? '?'}: ${s.title ?? s.key}${s.code ? ` (${s.code})` : ''}`).slice(0,
|
|
1136
|
+
headline: `Test run ${run.id}: ${run.status_label ?? statusLine(run.status)}${run.phase && run.status !== 'succeeded' && run.status !== 'failed' ? ` (phase ${run.phase})` : ''}.`,
|
|
1137
|
+
notes: [...(waiting ? [waiting] : []), ...(run.steps ?? []).map((s) => `${s.status ?? '?'}: ${s.title ?? s.key}${s.code ? ` (${s.code})` : ''}`)].slice(0, 13),
|
|
747
1138
|
data: run,
|
|
748
1139
|
};
|
|
749
1140
|
},
|
|
@@ -755,9 +1146,9 @@ const checkDisclosure = {
|
|
|
755
1146
|
name: 'check_disclosure',
|
|
756
1147
|
title: 'Check checkout disclosure text',
|
|
757
1148
|
description: [
|
|
758
|
-
'Before shipping a checkout: checks the automatic-renewal disclosure shown next to the purchase button against every applicable law (ROSCA, CA, NY, …) and returns per-check verdicts with citations plus a REPLACEMENT verified by the rules engine (disclosure_text, consent_label, button_text
|
|
1149
|
+
'Before shipping a checkout: checks the automatic-renewal disclosure shown next to the purchase button against every applicable law (ROSCA, CA, NY, …) and returns per-check verdicts with citations plus a REPLACEMENT verified by the rules engine (disclosure_text, consent_label, button_text, drop-in html and jsx).',
|
|
759
1150
|
'Pass the text exactly as rendered plus the plan terms. Example: {"text":"Renews monthly at $19.99 until you cancel.","price":19.99,"interval":"month","trial_days":14,"cancel_url":"https://acme.example/account/cancel","merchant_name":"Acme","consent":{"checkbox":true,"prechecked":false}}.',
|
|
760
|
-
'If verdict is fail,
|
|
1151
|
+
'If verdict is fail, put replacement.jsx (React) or .html above the button, then call again with the new text, consent and placement.bold: true (it is semibold). needs_review on styling means a rendered scan must confirm.',
|
|
761
1152
|
].join('\n'),
|
|
762
1153
|
input: {
|
|
763
1154
|
text: z.string().min(1).max(5000).describe('The disclosure shown next to the purchase button, exactly as rendered.'),
|
|
@@ -765,8 +1156,7 @@ const checkDisclosure = {
|
|
|
765
1156
|
interval: intervalStr,
|
|
766
1157
|
button_text: z.string().max(200).optional().describe('Purchase button label, e.g. "Start free trial".'),
|
|
767
1158
|
order_summary: z.string().max(1000).optional().describe('Other text next to the button (order summary), if any.'),
|
|
768
|
-
placement:
|
|
769
|
-
.object({
|
|
1159
|
+
placement: strictObject({
|
|
770
1160
|
above_button: z.boolean().optional(),
|
|
771
1161
|
same_page_as_payment: z.boolean().optional(),
|
|
772
1162
|
font_size_px: z.number().min(4).max(96).optional(),
|
|
@@ -774,16 +1164,15 @@ const checkDisclosure = {
|
|
|
774
1164
|
bold: z.boolean().optional(),
|
|
775
1165
|
boxed: z.boolean().optional(),
|
|
776
1166
|
body_font_size_px: z.number().min(4).max(96).optional(),
|
|
777
|
-
})
|
|
1167
|
+
}, 'placement')
|
|
778
1168
|
.optional()
|
|
779
1169
|
.describe('Where and how the disclosure is shown (defaults: above the button, same page, 14px, high contrast).'),
|
|
780
|
-
consent:
|
|
781
|
-
.object({
|
|
1170
|
+
consent: strictObject({
|
|
782
1171
|
checkbox: z.boolean(),
|
|
783
1172
|
prechecked: z.boolean().optional(),
|
|
784
1173
|
label: z.string().max(1000).optional(),
|
|
785
1174
|
required: z.boolean().optional(),
|
|
786
|
-
})
|
|
1175
|
+
}, 'consent')
|
|
787
1176
|
.optional()
|
|
788
1177
|
.describe('The consent checkbox, if the page has one. Omit when there is none.'),
|
|
789
1178
|
jurisdictions: jurisdictions.optional(),
|
|
@@ -793,7 +1182,8 @@ const checkDisclosure = {
|
|
|
793
1182
|
annotations: { ...ro, title: 'Check disclosure' },
|
|
794
1183
|
async run(args, { api }) {
|
|
795
1184
|
const r = await api.request('POST', '/v1/checks/disclosure', { body: args });
|
|
796
|
-
|
|
1185
|
+
const covered = r.replacement?.verified ? { by: 'replacement (disclosure_text above the button + consent_label, or paste replacement.jsx in React / replacement.html elsewhere)', except: r.replacement.remaining_checks ?? [] } : undefined;
|
|
1186
|
+
return { headline: verdictHeadline('Disclosure', r), notes: checkNotes(r, 8, covered), data: withoutUnneededFix(slimCheckResult(r)) };
|
|
797
1187
|
},
|
|
798
1188
|
};
|
|
799
1189
|
const checkStripeCheckoutSession = {
|
|
@@ -801,12 +1191,14 @@ const checkStripeCheckoutSession = {
|
|
|
801
1191
|
title: 'Check Stripe Checkout Session params (returns corrected params)',
|
|
802
1192
|
description: [
|
|
803
1193
|
'Before shipping Stripe Checkout code: pass EXACTLY the object your code passes to stripe.checkout.sessions.create (mode "subscription"). Checkbox evaluates it against automatic-renewal laws and returns correction.corrected_params — the full params with the fix applied, ready to paste — plus an RFC 6902 JSON patch and before/after verification per check.',
|
|
804
|
-
'
|
|
805
|
-
'Note:
|
|
1194
|
+
'With a Price id (price_…) in line_items, pass site (its plans are used) or merchant { price, interval, trial_days, merchant_name, cancel_url }.',
|
|
1195
|
+
'Note: Stripe styles custom_text itself, so "clear and conspicuous" stays needs_review here; the plan shows it as a note, never a fix.',
|
|
806
1196
|
].join('\n'),
|
|
807
1197
|
input: {
|
|
808
1198
|
params: z.record(z.string(), z.unknown()).describe('The Checkout Session params object, e.g. {"mode":"subscription","line_items":[{"price":"price_123","quantity":1}],"success_url":"https://acme.example/welcome"}.'),
|
|
809
|
-
merchant:
|
|
1199
|
+
merchant: merchantTerms.optional().describe('What the params do not say: merchant_name, cancel_url, and price/interval when line_items use a Price id.'),
|
|
1200
|
+
site: z.string().max(100).optional().describe('site_… whose plans judge it (as verify_task)'),
|
|
1201
|
+
plan: z.string().max(200).optional().describe('With site: plan name or price_…'),
|
|
810
1202
|
jurisdictions: jurisdictions.optional(),
|
|
811
1203
|
as_of: asOf.optional(),
|
|
812
1204
|
},
|
|
@@ -814,12 +1206,14 @@ const checkStripeCheckoutSession = {
|
|
|
814
1206
|
annotations: { ...ro, title: 'Check Stripe Checkout Session' },
|
|
815
1207
|
async run(args, { api }) {
|
|
816
1208
|
const r = await api.request('POST', '/v1/checks/stripe_checkout_session', { body: args });
|
|
817
|
-
const
|
|
818
|
-
|
|
1209
|
+
const fixing = !!r.correction?.patch?.length;
|
|
1210
|
+
const notes = checkNotes(r, 8, fixing && r.correction.verification?.passes ? { by: 'correction.corrected_params' } : undefined);
|
|
1211
|
+
if (fixing)
|
|
819
1212
|
notes.unshift(`Replace the params in your code with correction.corrected_params (${r.correction.patch.length} change(s); verified passes: ${r.correction.verification?.passes ?? 'n/a'}).`);
|
|
820
|
-
|
|
1213
|
+
// Agent audit round 2: on a pass the same Stripe-styling note was printed four times; it is printed once.
|
|
1214
|
+
if (fixing && r.correction?.verification?.note)
|
|
821
1215
|
notes.push(r.correction.verification.note);
|
|
822
|
-
const { correction, ...rest } = slimCheckResult(r);
|
|
1216
|
+
const { correction, ...rest } = withoutUnneededFix(slimCheckResult(r));
|
|
823
1217
|
return { headline: verdictHeadline('Stripe Checkout Session', r), notes, data: { correction, ...rest } };
|
|
824
1218
|
},
|
|
825
1219
|
};
|
|
@@ -827,32 +1221,35 @@ const checkEmail = {
|
|
|
827
1221
|
name: 'check_email',
|
|
828
1222
|
title: 'Check a subscription lifecycle email',
|
|
829
1223
|
description: [
|
|
830
|
-
'Checks an acknowledgment, trial reminder, renewal reminder, price-change, annual-terms or cancellation-confirmation email against the laws that require it, and returns a
|
|
1224
|
+
'Checks an acknowledgment, trial reminder, renewal reminder, price-change, annual-terms or cancellation-confirmation email against the laws that require it, and returns a template that passes the checks (subject, text, html with merge tags), verified by the rules engine, plus required_by (which laws make this email mandatory for this plan).',
|
|
831
1225
|
'Example: {"kind":"trial_reminder","subject":"Your trial ends soon","text":"…","terms":{"price":19.99,"interval":"month","trial_days":14,"cancel_url":"https://acme.example/account/cancel","merchant_name":"Acme"}}.',
|
|
832
1226
|
'For WHEN each email must be sent, use get_notice_schedule.',
|
|
833
1227
|
].join('\n'),
|
|
834
1228
|
input: {
|
|
835
|
-
kind:
|
|
1229
|
+
kind: emailKind.describe('Which email (trial_reminder = trial_ending).'),
|
|
836
1230
|
subject: z.string().min(1).max(1000).describe('Subject line.'),
|
|
837
1231
|
text: z.string().max(100_000).optional().describe('Plain-text body (give text, html or both).'),
|
|
838
1232
|
html: z.string().max(100_000).optional().describe('HTML body.'),
|
|
839
|
-
terms:
|
|
840
|
-
|
|
1233
|
+
terms: strictObject({
|
|
1234
|
+
...planTermsShape,
|
|
841
1235
|
previous_price: z.union([z.number().positive(), z.string().max(40)]).optional().describe('price_change: price before the change, major units.'),
|
|
842
1236
|
previous_amount: z.number().int().positive().optional(),
|
|
843
1237
|
support_contact: z.string().max(200).optional().describe('Support email / URL for the template.'),
|
|
844
|
-
})
|
|
845
|
-
.
|
|
1238
|
+
}, 'terms')
|
|
1239
|
+
.optional()
|
|
1240
|
+
.describe('price, interval, cancel_url (optional with site); price_change: NEW price'),
|
|
1241
|
+
site: z.string().max(100).optional().describe('site_… whose Stripe prices judge it (default: your Stripe site).'),
|
|
1242
|
+
plan: z.string().max(200).optional().describe('Plan name or price_… (default: the one named)'),
|
|
846
1243
|
jurisdictions: jurisdictions.optional(),
|
|
847
1244
|
as_of: asOf.optional(),
|
|
848
1245
|
},
|
|
849
1246
|
scopes: ['checks:read'],
|
|
850
1247
|
annotations: { ...ro, title: 'Check email' },
|
|
851
1248
|
async run(args, { api }) {
|
|
852
|
-
const r = await api.request('POST', '/v1/checks/email', { body: args });
|
|
853
|
-
const notes = checkNotes(r);
|
|
854
|
-
notes.unshift(r.required_by?.length ? `Legally required for this plan by: ${r.required_by
|
|
855
|
-
return { headline: verdictHeadline(`Email (${String(args.kind)})`, r), notes, data: slimCheckResult(r) };
|
|
1249
|
+
const r = await api.request('POST', '/v1/checks/email', { body: { ...args, kind: checkKind(args.kind) } });
|
|
1250
|
+
const notes = checkNotes(r, 8, r.template?.verified ? { by: 'template (subject + text or html; add your greeting and sign-off around it if you like)', except: r.template.remaining_checks ?? [] } : undefined);
|
|
1251
|
+
notes.unshift(r.required_by?.length ? `Legally required for this plan by: ${citationList(r.required_by, r.requirements)}.` : 'Not legally required for this plan (still good practice).');
|
|
1252
|
+
return { headline: verdictHeadline(`Email (${String(args.kind)}${r.plan ? `, plan ${untrusted(r.plan, 60)}` : ''})`, r), notes, data: slimCheckResult(r) };
|
|
856
1253
|
},
|
|
857
1254
|
};
|
|
858
1255
|
const checkPageHtml = {
|
|
@@ -867,7 +1264,10 @@ const checkPageHtml = {
|
|
|
867
1264
|
html: z.string().max(1_000_000).optional().describe('Rendered checkout page HTML. Give html or url.'),
|
|
868
1265
|
url: z.string().max(2048).optional().describe('Live public page URL to fetch instead (live, activated accounts).'),
|
|
869
1266
|
page_kind: z.enum(['checkout', 'pricing']).optional().describe('Default checkout.'),
|
|
870
|
-
terms: planTerms.optional().describe('The plan sold on the page
|
|
1267
|
+
terms: planTerms.optional().describe('The plan sold on the page. merchant_name / cancel_url alone are fine (no price compared).'),
|
|
1268
|
+
plans: z.array(strictObject(planTermsShape, 'plans[]')).max(20).optional().describe('Multi-plan pricing page: EVERY plan it sells (price, interval, trial_days each). Each plan card is judged against its own plan; plan_replacements gives each plan its own markup for its own button.'),
|
|
1269
|
+
checkout_type: z.enum(['own_page', 'stripe_checkout', 'payment_link']).optional().describe('Where shoppers pay (default: inferred).'),
|
|
1270
|
+
site: z.string().max(100).optional().describe("Your site_…: judge it as verify_task does (the site's Stripe-checked plans)."),
|
|
871
1271
|
jurisdictions: jurisdictions.optional(),
|
|
872
1272
|
as_of: asOf.optional(),
|
|
873
1273
|
},
|
|
@@ -878,25 +1278,74 @@ const checkPageHtml = {
|
|
|
878
1278
|
throw new ToolError('Pass html (the rendered page) or url.');
|
|
879
1279
|
const r = await api.request('POST', '/v1/checks/page', { body: args });
|
|
880
1280
|
const notes = checkNotes(r);
|
|
1281
|
+
// Round 17 (python-flask): on a Payment Link / Stripe Checkout page, replacement.html is the terms only.
|
|
1282
|
+
if (r.replacement?.consent_on && r.replacement.note && r.verdict !== 'pass')
|
|
1283
|
+
notes.push(`Replacement: ${r.replacement.note}`);
|
|
881
1284
|
if (r.extraction?.purchase_buttons) {
|
|
882
1285
|
const b = r.extraction.purchase_buttons;
|
|
883
1286
|
notes.unshift(b.length ? `Purchase buttons found: ${b.length} (first: ${untrusted(b[0], 60)}; texts in extraction.purchase_buttons).` : 'Purchase buttons found: none (send the rendered HTML).');
|
|
884
1287
|
}
|
|
885
|
-
return { headline: verdictHeadline('Page', r), notes, data: slimCheckResult(r) };
|
|
1288
|
+
return { headline: verdictHeadline('Page', r), notes, data: withoutUnneededFix(slimCheckResult(r)) };
|
|
886
1289
|
},
|
|
887
1290
|
};
|
|
1291
|
+
const DAY_S = 86_400;
|
|
1292
|
+
/**
|
|
1293
|
+
* Agent audit round 2: "2026-11-14: trial_ending (5 days before; US-UT)" said neither before what nor the allowed window,
|
|
1294
|
+
* which is what a cron job needs; the window was only in epoch seconds. The line now states both relative to the event.
|
|
1295
|
+
*/
|
|
1296
|
+
export function noticeScheduleLine(n) {
|
|
1297
|
+
const anchor = n.anchor_date ? Date.parse(`${n.anchor_date}T00:00:00Z`) / 1000 : undefined;
|
|
1298
|
+
const event = n.occasion ? n.occasion.replace(/\s*\(.*\)$/, '').toLowerCase() : 'the event';
|
|
1299
|
+
if (n.days_before === 0 || anchor === undefined || n.window_opens_at === undefined || n.window_closes_at === undefined) {
|
|
1300
|
+
return `${n.send_date}: ${n.kind} — ${n.days_before === 0 ? `at ${event} (same day)` : `${n.days_before} days before ${event}`} (${n.jurisdictions.join(', ')})`;
|
|
1301
|
+
}
|
|
1302
|
+
const most = Math.round((anchor - n.window_opens_at) / DAY_S);
|
|
1303
|
+
const least = Math.round((anchor - n.window_closes_at) / DAY_S);
|
|
1304
|
+
const window = least >= 0 && most >= least ? `; allowed ${least === most ? `exactly ${least}` : `${least}–${most}`} days before` : '';
|
|
1305
|
+
return `${n.send_date}: ${n.kind} — ${n.days_before} days before ${event} (${n.anchor_date})${window} (${n.jurisdictions.join(', ')})`;
|
|
1306
|
+
}
|
|
1307
|
+
/** One line per notice; a kind sent every charge shows its first date and how many follow. */
|
|
1308
|
+
export function noticeScheduleLines(notices) {
|
|
1309
|
+
const out = [];
|
|
1310
|
+
const seen = new Map();
|
|
1311
|
+
for (const n of notices)
|
|
1312
|
+
seen.set(n.kind, (seen.get(n.kind) ?? 0) + 1);
|
|
1313
|
+
const shown = new Set();
|
|
1314
|
+
for (const n of notices) {
|
|
1315
|
+
const count = seen.get(n.kind);
|
|
1316
|
+
if (count > 2) {
|
|
1317
|
+
if (shown.has(n.kind))
|
|
1318
|
+
continue;
|
|
1319
|
+
shown.add(n.kind);
|
|
1320
|
+
out.push(`${noticeScheduleLine(n)} — then the same before each of the next ${count - 1} charges (all in notices[])`);
|
|
1321
|
+
}
|
|
1322
|
+
else
|
|
1323
|
+
out.push(noticeScheduleLine(n));
|
|
1324
|
+
}
|
|
1325
|
+
return out.slice(0, 12);
|
|
1326
|
+
}
|
|
1327
|
+
/** Notice kinds of the schedule → the compliance plan's email task for it (same names as get_compliance_plan). */
|
|
1328
|
+
export const NOTICE_KIND_TASKS = {
|
|
1329
|
+
acknowledgment: { name: 'acknowledgment email', task: 'acknowledgment_email' },
|
|
1330
|
+
trial_ending: { name: 'trial-ending reminder', task: 'trial_reminder' },
|
|
1331
|
+
renewal_reminder: { name: 'renewal reminder', task: 'renewal_reminder' },
|
|
1332
|
+
annual_terms: { name: 'yearly terms reminder', task: 'annual_terms_notice' },
|
|
1333
|
+
charge_notice: { name: 'charge notice', task: 'charge_notice' },
|
|
1334
|
+
price_change: { name: 'price-change notice', task: 'price_change_notice' },
|
|
1335
|
+
};
|
|
888
1336
|
const getNoticeSchedule = {
|
|
889
1337
|
name: 'get_notice_schedule',
|
|
890
1338
|
title: 'Get the dates each required notice must be sent',
|
|
891
1339
|
description: [
|
|
892
|
-
'For a plan (price, interval, trial) and a sign-up date: every legally required notice (acknowledgment, trial-ending reminder, renewal reminder,
|
|
1340
|
+
'For a plan (price, interval, trial) and a sign-up date: every legally required notice (acknowledgment, trial-ending reminder, renewal reminder, yearly terms reminder, charge notice, price-change notice — the same emails as the compliance plan\'s email tasks) with the recommended send date, the allowed send window (earliest/latest), the jurisdictions it satisfies and citations; plus notice kinds NOT required and why.',
|
|
893
1341
|
'Use it to implement reminder emails / cron jobs correctly. Example: {"terms":{"price":99,"interval":"year","trial_days":30},"start_date":"2026-11-01"}.',
|
|
894
1342
|
].join('\n'),
|
|
895
1343
|
input: {
|
|
896
|
-
terms: planTerms.describe('The plan: price (or amount), interval, interval_count, trial_days.'),
|
|
1344
|
+
terms: planTerms.optional().describe('The plan: price (or amount), interval, interval_count, trial_days (or site + plan).'),
|
|
1345
|
+
site: z.string().max(100).optional().describe('site_… (its plans, jurisdictions)'),
|
|
1346
|
+
plan: z.string().max(200).optional().describe('With site: plan name or price_…'),
|
|
897
1347
|
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Sign-up date, YYYY-MM-DD (default today).'),
|
|
898
|
-
price_change: z
|
|
899
|
-
.object({ effective_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), new_price: z.number().positive().optional() })
|
|
1348
|
+
price_change: strictObject({ effective_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), new_price: z.number().positive().optional() }, 'price_change')
|
|
900
1349
|
.optional()
|
|
901
1350
|
.describe('A planned price change to schedule the notice for.'),
|
|
902
1351
|
cycles: z.number().int().min(1).max(24).optional().describe('How many renewals to plan (default one year).'),
|
|
@@ -908,11 +1357,19 @@ const getNoticeSchedule = {
|
|
|
908
1357
|
annotations: { ...ro, title: 'Notice schedule' },
|
|
909
1358
|
async run(args, { api }) {
|
|
910
1359
|
const r = await api.request('POST', '/v1/checks/notice_schedule', { body: args });
|
|
1360
|
+
const singleDay = [...new Set(r.notices.flatMap((n) => (n.timing_note ? [`${n.kind}: ${n.timing_note}`] : [])))].slice(0, 3);
|
|
1361
|
+
const kinds = [...new Set(r.notices.map((n) => n.kind))];
|
|
911
1362
|
return {
|
|
912
|
-
|
|
1363
|
+
// Polish round: one email per kind, named like the compliance plan's email tasks (the per-send dates follow).
|
|
1364
|
+
headline: kinds.length
|
|
1365
|
+
? `${kinds.length} required email${kinds.length === 1 ? '' : 's'} for ${r.plan ? `plan ${untrusted(r.plan, 60)}` : 'this plan'}: ${kinds.map((k) => NOTICE_KIND_TASKS[k] ? `${NOTICE_KIND_TASKS[k].name} (plan task ${NOTICE_KIND_TASKS[k].task})` : k).join(', ')}; ${r.notices.length} send${r.notices.length === 1 ? '' : 's'} in the period below.`
|
|
1366
|
+
: 'No required notices for this plan under these jurisdictions.',
|
|
913
1367
|
notes: [
|
|
914
|
-
...r.notices
|
|
1368
|
+
...noticeScheduleLines(r.notices),
|
|
1369
|
+
...singleDay,
|
|
1370
|
+
...(r.rounding_rule ? [`Timing rule: ${r.rounding_rule}`] : []),
|
|
915
1371
|
...(r.not_required ?? []).map((n) => `Not required: ${n.kind} — ${n.reason}`),
|
|
1372
|
+
...(r.warnings ?? []).filter((w) => typeof w?.message === 'string').slice(0, 3).map((w) => w.message),
|
|
916
1373
|
],
|
|
917
1374
|
data: r,
|
|
918
1375
|
};
|
|
@@ -930,7 +1387,7 @@ const SUGGESTION_SURFACE_NAMES = [
|
|
|
930
1387
|
];
|
|
931
1388
|
const suggestImprovements = {
|
|
932
1389
|
name: 'suggest_improvements',
|
|
933
|
-
title: 'Suggest
|
|
1390
|
+
title: 'Suggest conversion improvements that keep the checks passing',
|
|
934
1391
|
description: [
|
|
935
1392
|
'OPTIONAL, after compliance: concrete ways to make the checkout page, Stripe Checkout, lifecycle emails and the cancellation flow convert or retain better WITHOUT breaking automatic-renewal laws. Deterministic library; every after-example was run through Checkbox\'s own checks for the merchant\'s states before it is offered.',
|
|
936
1393
|
'Each suggestion gives apply.where + apply.content (the exact copy, Stripe JSON patch or flow change to make), apply.instructions, preconditions that must be true (never apply one whose precondition is false), and guardrails with catalog requirement ids (e.g. California / Colorado click-to-cancel next to any save offer, Minnesota ask-first). Never invent figures or add urgency, pre-checked boxes or obstacles.',
|
|
@@ -940,7 +1397,7 @@ const suggestImprovements = {
|
|
|
940
1397
|
input: {
|
|
941
1398
|
site: siteId.optional(),
|
|
942
1399
|
terms: planTerms.optional().describe('The plan sold (price or amount, interval, trial_days, merchant_name, cancel_url). Required with disclosure or emails.'),
|
|
943
|
-
other_plans: z.array(
|
|
1400
|
+
other_plans: z.array(strictObject({ ...planTermsShape, name: z.string().max(80).optional().describe('Plan name, e.g. "Lite" (or plan_name).') }, 'other_plans[]')).max(10).optional().describe('Other plans you sell (enables cheaper-plan / plan-choice suggestions).'),
|
|
944
1401
|
disclosure: z
|
|
945
1402
|
.object({
|
|
946
1403
|
text: z.string().min(1).max(5000).describe('Disclosure next to the purchase button, as rendered.'),
|
|
@@ -1001,7 +1458,10 @@ const suggestImprovements = {
|
|
|
1001
1458
|
r.suggestions.slice(0, 15).forEach((s, i) => {
|
|
1002
1459
|
notes.push([
|
|
1003
1460
|
`${i + 1}. [${s.surface} · ${s.match}] ${s.title} (${s.key})`,
|
|
1004
|
-
|
|
1461
|
+
// "From your copy" only for the merchant's own text; anything else is an example (never present it as theirs).
|
|
1462
|
+
s.before.source === 'your_copy' || (!s.before.source && s.match === 'detected')
|
|
1463
|
+
? ` before, from your copy (${s.before.label}): ${untrusted(s.before.text, 160)}`
|
|
1464
|
+
: ` before, example (${s.before.label}): ${JSON.stringify(s.before.text.slice(0, 160))}`,
|
|
1005
1465
|
` apply (${s.apply.type}) at ${s.apply.where}: ${JSON.stringify(s.apply.content.length > 600 ? `${s.apply.content.slice(0, 600)}…` : s.apply.content)}`,
|
|
1006
1466
|
` how: ${s.apply.instructions}`,
|
|
1007
1467
|
...(s.preconditions.length ? [` only if true: ${s.preconditions.join(' ')}`] : []),
|
|
@@ -1014,7 +1474,7 @@ const suggestImprovements = {
|
|
|
1014
1474
|
notes.push(`…and ${r.suggestions.length - 15} more in data.suggestions.`);
|
|
1015
1475
|
const held = r.surfaces.filter((s) => s.held_by.length);
|
|
1016
1476
|
if (held.length)
|
|
1017
|
-
notes.push(`Held (fix first with get_compliance_plan): ${held.map((s) => `${s.surface} (${s.held_label ? `${s.held_label
|
|
1477
|
+
notes.push(`Held (fix first with get_compliance_plan): ${held.map((s) => `${s.surface} (${s.held_label ? `${s.held_label}: ` : ''}${s.held_by.join(', ')})`).join('; ')}.`);
|
|
1018
1478
|
if (r.withheld.length)
|
|
1019
1479
|
notes.push(`Withheld: ${r.withheld.slice(0, 8).map((w) => `${w.key}: ${w.reason}`).join(' | ')}`);
|
|
1020
1480
|
return { headline: `Suggestions for ${where}: ${r.summary}`, notes, data: r };
|
|
@@ -1023,10 +1483,25 @@ const suggestImprovements = {
|
|
|
1023
1483
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
1024
1484
|
// Sites, findings, scans, law updates
|
|
1025
1485
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
1486
|
+
/** One site in list_sites: what an agent needs to pick it. */
|
|
1487
|
+
export function siteListItem(s) {
|
|
1488
|
+
const x = s;
|
|
1489
|
+
return {
|
|
1490
|
+
id: s.id,
|
|
1491
|
+
object: 'site',
|
|
1492
|
+
name: s.name ?? null,
|
|
1493
|
+
url: s.url,
|
|
1494
|
+
...(x.platform ? { platform: x.platform } : {}),
|
|
1495
|
+
...(x.checkout?.url ? { checkout_url: x.checkout.url } : {}),
|
|
1496
|
+
status: plainLabel(s.compliance?.label ?? 'Not checked yet'),
|
|
1497
|
+
domain_verified: x.monitoring?.held_for_domain_verification !== true,
|
|
1498
|
+
...(x.archived ? { archived: true } : {}),
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1026
1501
|
const listSites = {
|
|
1027
1502
|
name: 'list_sites',
|
|
1028
1503
|
title: 'List sites',
|
|
1029
|
-
description: 'The account\'s sites in the current mode (test keys see only test-mode sites), newest first, each with its compliance
|
|
1504
|
+
description: 'The account\'s sites in the current mode (test keys see only test-mode sites), newest first, each with its compliance status label. Use it to find a site id.',
|
|
1030
1505
|
input: { limit: listLimit, starting_after: startingAfter, archived: z.boolean().optional().describe('true lists only archived sites.') },
|
|
1031
1506
|
scopes: ['sites:read'],
|
|
1032
1507
|
annotations: { ...ro, title: 'List sites' },
|
|
@@ -1036,9 +1511,14 @@ const listSites = {
|
|
|
1036
1511
|
});
|
|
1037
1512
|
return {
|
|
1038
1513
|
headline: `${list.data.length}${list.has_more ? '+' : ''} site(s) in ${key.mode === 'unknown' ? 'this' : key.mode} mode.`,
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1514
|
+
// Audit P2: name + URL per site, and a Next line either way.
|
|
1515
|
+
notes: list.data.map((s) => `${s.id} ${s.name ? `${untrusted(s.name, 60)} ` : ''}${untrusted(s.url, 100)} — ${plainLabel(s.compliance?.label ?? 'Not checked yet')}`),
|
|
1516
|
+
// Agent audit round 2: each site carried install details, webhook URLs, retention and legal hold (~1.5 KB) when
|
|
1517
|
+
// an agent needs the id, URL and status; get_site_status / the API have the rest.
|
|
1518
|
+
data: { ...list, data: list.data.map(siteListItem) },
|
|
1519
|
+
next: list.data.length
|
|
1520
|
+
? { type: 'review_plan', message: list.data.length === 1 ? `Call get_compliance_plan with site "${list.data[0].id}" for what to change next.` : 'Call get_compliance_plan with the id of the site whose URL matches this project (listed above) for what to change next.', operation_id: 'retrieveCompliancePlan' }
|
|
1521
|
+
: { type: 'setup', message: 'No sites yet: call setup_checkbox with the site URL.', operation_id: 'createSetupSession' },
|
|
1042
1522
|
};
|
|
1043
1523
|
},
|
|
1044
1524
|
};
|
|
@@ -1090,10 +1570,14 @@ const updateSite = {
|
|
|
1090
1570
|
name: z.string().max(200).optional().describe('Merchant / brand name (used in generated disclosures).'),
|
|
1091
1571
|
checkout_url: z.string().max(2048).optional().describe('Page with the Subscribe / Buy button.'),
|
|
1092
1572
|
checkout_steps: z.array(z.record(z.string(), z.unknown())).max(20).optional().describe('Steps to reach the purchase button (clicks / fills), as in the API.'),
|
|
1093
|
-
test_account:
|
|
1094
|
-
|
|
1573
|
+
test_account: strictObject({
|
|
1574
|
+
login_url: z.string().max(2048),
|
|
1575
|
+
email: z.string().max(320),
|
|
1576
|
+
password: z.string().max(200),
|
|
1577
|
+
reactivate_url: z.string().max(2048).optional().describe('Test-only page that re-subscribes the test account.'),
|
|
1578
|
+
}, 'test_account')
|
|
1095
1579
|
.optional()
|
|
1096
|
-
.describe('A TEST subscriber account the scanner logs in with to try cancelling online.'),
|
|
1580
|
+
.describe('A TEST subscriber account the scanner logs in with to try cancelling online (echoed back without the password).'),
|
|
1097
1581
|
platform: z.enum(['stripe', 'shopify', 'recharge', 'chargebee', 'recurly', 'paddle', 'woocommerce', 'custom']).optional(),
|
|
1098
1582
|
jurisdictions: z.array(z.string().max(10)).max(60).optional(),
|
|
1099
1583
|
monitoring_interval_minutes: z.number().int().min(0).optional(),
|
|
@@ -1114,54 +1598,94 @@ const updateSite = {
|
|
|
1114
1598
|
};
|
|
1115
1599
|
},
|
|
1116
1600
|
};
|
|
1601
|
+
/** The site's Shopify connection in one line, naming the tool (or the human step) that comes next. */
|
|
1602
|
+
export function shopifyLines(sh, site) {
|
|
1603
|
+
if (!sh)
|
|
1604
|
+
return [];
|
|
1605
|
+
if (sh.connection === 'app') {
|
|
1606
|
+
return sh.terms_published
|
|
1607
|
+
? [`Shopify: ${sh.shop ?? 'The store'} is connected through the Checkbox app; renewal terms published to the app block on ${new Date(sh.terms_published.published * 1000).toISOString().slice(0, 10)}. If the storefront does not show them, the merchant still has to place the "Renewal terms + consent" block once: ${sh.theme_editor_url ?? 'theme editor'}. publish_shopify_terms republishes after plan changes.`]
|
|
1608
|
+
: [`Shopify: ${sh.shop ?? 'The store'} is connected through the Checkbox app; the renewal terms are not published yet: call publish_shopify_terms with site "${site}", then the merchant places the block once (${sh.theme_editor_url ?? 'theme editor'}).`];
|
|
1609
|
+
}
|
|
1610
|
+
if (sh.connection === 'own_app')
|
|
1611
|
+
return [`Shopify: ${sh.shop ?? 'The store'} is connected with its own custom-app token (read-only): use the standalone theme snippet from the plan's sign-up task, or have the merchant install the Checkbox Shopify app so publish_shopify_terms can update the block.`];
|
|
1612
|
+
return ['Shopify: the store is not connected. Only the merchant can connect it: install the Checkbox app in the Shopify admin, open it and click "Connect to Checkbox" (then publish_shopify_terms works).'];
|
|
1613
|
+
}
|
|
1117
1614
|
const getSiteStatus = {
|
|
1118
1615
|
name: 'get_site_status',
|
|
1119
1616
|
title: 'Get a site\'s compliance status',
|
|
1120
|
-
description: 'Where a site stands:
|
|
1617
|
+
description: 'Where a site stands: one status (Checks pass, N fixes needed, Not set up yet, Not checked yet, Your call) and the to-do list: the compliance plan\'s ordered open tasks, as on the dashboard. Use it for "am I compliant?"; get_task gives each change.',
|
|
1121
1618
|
input: { site: siteId.optional() },
|
|
1122
1619
|
scopes: ['sites:read'],
|
|
1123
1620
|
annotations: { ...ro, title: 'Site status' },
|
|
1124
1621
|
async run(args, { api }) {
|
|
1125
1622
|
const id = await resolveSite(api, args.site);
|
|
1126
|
-
const [site, evaluation,
|
|
1623
|
+
const [site, evaluation, plan] = await Promise.all([
|
|
1127
1624
|
api.request('GET', `/v1/sites/${enc(id)}`),
|
|
1128
1625
|
optional(api.request('GET', `/v1/sites/${enc(id)}/evaluation`)),
|
|
1129
|
-
optional(api.request('GET',
|
|
1626
|
+
optional(api.request('GET', `/v1/sites/${enc(id)}/plan`)),
|
|
1130
1627
|
]);
|
|
1131
1628
|
// Round-4 pilot (nextjs): "nothing left to do" while 13 critical/high checks were needs_review. Count them too.
|
|
1132
1629
|
const review = await optional(api.request('GET', '/v1/findings', { query: { site: id, status: 'needs_review', state: 'open', limit: 100 } }));
|
|
1133
|
-
const evaluated = '
|
|
1134
|
-
const top = 'data' in findings ? findings.data : [];
|
|
1630
|
+
const evaluated = 'id' in evaluation;
|
|
1135
1631
|
const toReview = 'data' in review ? review.data : [];
|
|
1136
1632
|
const seriousReview = toReview.filter((f) => f.severity === 'critical' || f.severity === 'high');
|
|
1137
|
-
const
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
];
|
|
1142
|
-
const { checks: _omit, ...evalLite } = (evaluated ? evaluation : {});
|
|
1633
|
+
const blocking = evaluated ? (evaluation.blockers ?? []).map((b) => b.check_id) : [];
|
|
1634
|
+
// One to-do list: the plan's ordered open tasks, never a list built from findings.
|
|
1635
|
+
const todo = 'tasks' in plan && Array.isArray(plan.tasks) ? planTodo(plan.tasks) : [];
|
|
1636
|
+
const { checks: _omit, score: _score, insurable: _insurable, ...evalLite } = (evaluated ? evaluation : {});
|
|
1143
1637
|
// Status labels: the roll-up decides the headline; pending live checks count as compliant and say how they complete.
|
|
1144
1638
|
const roll = site.compliance?.rollup;
|
|
1145
1639
|
const pendingLive = roll?.pending_live_checks ?? [];
|
|
1146
1640
|
const rollStatus = site.compliance?.status;
|
|
1147
1641
|
const label = site.compliance?.label;
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1642
|
+
const ro = unverifiedScanNote(site.unverified_scan);
|
|
1643
|
+
const notes = [
|
|
1644
|
+
...(todo.length ? [`To do (${todo.length}, from the compliance plan, in order):`, ...todo.map((t, i) => `${i + 1}. ${t.id} ${todoLine(t)}`)] : []),
|
|
1645
|
+
...(evaluated && label ? pendingLive.slice(0, 6).map((p) => `LIVE CHECK PENDING ${p.check_id}: ${p.what} — ${p.completes}`) : []),
|
|
1646
|
+
...(ro ? [ro] : []),
|
|
1647
|
+
...shopifyLines(site.shopify, site.id),
|
|
1648
|
+
];
|
|
1649
|
+
const { open_findings: _open, needs_review: _nr, ...complianceLite } = (site.compliance ?? {});
|
|
1151
1650
|
return {
|
|
1152
|
-
headline: evaluated
|
|
1153
|
-
? label
|
|
1154
|
-
? `${site.url}: ${label} (score ${evaluation.score}).`
|
|
1155
|
-
: `${site.url}: score ${evaluation.score}, ${site.compliance?.open_findings ?? top.length} open failing finding(s), ${toReview.length} to review.`
|
|
1156
|
-
: `${site.url}: not evaluated yet.`,
|
|
1651
|
+
headline: evaluated ? `${site.url}: ${plainLabel(label ?? 'Not checked yet')}.` : `${site.url}: Not checked yet.`,
|
|
1157
1652
|
notes,
|
|
1158
|
-
data: { site
|
|
1653
|
+
data: { site: site.compliance ? { ...site, compliance: complianceLite } : site, evaluation: evaluated ? evalLite : null, todo },
|
|
1159
1654
|
next: evaluated
|
|
1160
|
-
? rollStatus === 'needs_fix' || (
|
|
1161
|
-
? { type: 'fix', message:
|
|
1162
|
-
: rollStatus === '
|
|
1163
|
-
? { type: '
|
|
1164
|
-
:
|
|
1655
|
+
? todo.length && (rollStatus === 'needs_fix' || todo.some((t) => t.status === 'needs_fix' || t.status === 'not_set_up'))
|
|
1656
|
+
? { type: 'fix', message: `Start with the first task (${todo[0].id}: ${todo[0].title}); get_compliance_plan / get_task give the exact change.`, operation_id: 'retrieveCompliancePlan' }
|
|
1657
|
+
: rollStatus === 'needs_fix'
|
|
1658
|
+
? { type: 'fix', message: 'Call get_compliance_plan for the ordered changes.', operation_id: 'retrieveCompliancePlan' }
|
|
1659
|
+
: rollStatus === 'your_call' || todo.some((t) => t.status === 'your_call')
|
|
1660
|
+
? { type: 'review', message: 'Some items need a decision from the human: call get_compliance_plan for the questions (each has a recommended answer).', operation_id: 'retrieveCompliancePlan' }
|
|
1661
|
+
: rollStatus === 'compliant' && !todo.length
|
|
1662
|
+
? // Round 15 (pilot 14, change-guard: "Checks pass · 3 live checks pending" next to "the site is not shown
|
|
1663
|
+
// as passing yet"): one status, one sentence. When the roll-up says Checks pass, next_action says the
|
|
1664
|
+
// same; the live checks are named as what completes it, never as a reason it does not pass.
|
|
1665
|
+
pendingLive.length
|
|
1666
|
+
? {
|
|
1667
|
+
type: 'complete_live_checks',
|
|
1668
|
+
message: `${plainLabel(label ?? 'Checks pass')}: nothing needs a code change. ${pendingLive.length} live check(s) complete on their own (${pendingLive
|
|
1669
|
+
.slice(0, 3)
|
|
1670
|
+
.map((p) => p.what)
|
|
1671
|
+
.join('; ')}); get_compliance_plan says how each one completes (a proof run, BCC real sends, a test account).`,
|
|
1672
|
+
operation_id: 'retrieveCompliancePlan',
|
|
1673
|
+
}
|
|
1674
|
+
: { type: 'none', message: `${plainLabel(label ?? 'Checks pass')}: every check that applies passes; nothing left to do. Keep monitoring on.` }
|
|
1675
|
+
: blocking.length || pendingLive.length || seriousReview.length || todo.length
|
|
1676
|
+
? // Normal round 4 (nextjs): "nothing left to do — Compliant" next to 2 blocking checks. Never call a site
|
|
1677
|
+
// compliant while a blocking check, a pending live check or an open plan task remains.
|
|
1678
|
+
{
|
|
1679
|
+
type: 'complete_live_checks',
|
|
1680
|
+
message: `Nothing needs a code change now, but the site is not shown as passing yet (${plainLabel(label ?? 'Not checked yet')}): ${[
|
|
1681
|
+
todo.length ? `${todo.length} task(s) on the plan (${todo.slice(0, 3).map((t) => t.title).join('; ')})` : '',
|
|
1682
|
+
blocking.length ? `blocking check(s) ${blocking.slice(0, 4).join(', ')}` : '',
|
|
1683
|
+
pendingLive.length ? `${pendingLive.length} live check(s) pending` : '',
|
|
1684
|
+
seriousReview.length && !todo.length ? `${seriousReview.length} check(s) to review` : '',
|
|
1685
|
+
].filter(Boolean).join(', ')}. Call get_compliance_plan for how each one completes (a proof run, BCC real sends, a test account).`,
|
|
1686
|
+
operation_id: 'retrieveCompliancePlan',
|
|
1687
|
+
}
|
|
1688
|
+
: { type: 'none', message: 'Every check that applies has passed; nothing left to do. Keep monitoring on.' }
|
|
1165
1689
|
: { type: 'run_first_check', message: 'Call verify_site (or run_scan) for the first verdict.', operation_id: 'verifySite' },
|
|
1166
1690
|
};
|
|
1167
1691
|
},
|
|
@@ -1169,10 +1693,10 @@ const getSiteStatus = {
|
|
|
1169
1693
|
const listFindings = {
|
|
1170
1694
|
name: 'list_findings',
|
|
1171
1695
|
title: 'List compliance findings',
|
|
1172
|
-
description: 'Findings (one per legal check per site) ordered by urgency: failing first, then by severity, each with citations. Filter by site, status (fail, needs_review, pass, not_applicable) and state (open, acknowledged, resolved, accepted_risk). Default: open
|
|
1696
|
+
description: 'Findings (one per legal check per site) ordered by urgency: failing first, then by severity, each with citations. Filter by site, status (fail, needs_review, pass, not_applicable) and state (open, acknowledged, resolved, accepted_risk). Default: every open finding of every status. Use get_finding for the fix of one.',
|
|
1173
1697
|
input: {
|
|
1174
1698
|
site: z.string().max(100).optional().describe('Only this site (site_…).'),
|
|
1175
|
-
status: z.enum(['pass', 'fail', 'needs_review', 'not_applicable']).optional().describe('Default
|
|
1699
|
+
status: z.enum(['pass', 'fail', 'needs_review', 'not_applicable']).optional().describe('Default: all statuses.'),
|
|
1176
1700
|
state: z.enum(['open', 'acknowledged', 'resolved', 'accepted_risk']).optional().describe('Default open.'),
|
|
1177
1701
|
limit: listLimit,
|
|
1178
1702
|
starting_after: startingAfter,
|
|
@@ -1183,7 +1707,8 @@ const listFindings = {
|
|
|
1183
1707
|
const list = await api.request('GET', '/v1/findings', {
|
|
1184
1708
|
query: {
|
|
1185
1709
|
site: args.site,
|
|
1186
|
-
|
|
1710
|
+
// Normal round 3: with no filter, every open finding (it returned "0 finding(s)" while 13 were active).
|
|
1711
|
+
status: args.status,
|
|
1187
1712
|
state: args.state ?? 'open',
|
|
1188
1713
|
limit: args.limit,
|
|
1189
1714
|
starting_after: args.starting_after,
|
|
@@ -1222,6 +1747,36 @@ const getFinding = {
|
|
|
1222
1747
|
};
|
|
1223
1748
|
},
|
|
1224
1749
|
};
|
|
1750
|
+
const publishShopifyTerms = {
|
|
1751
|
+
name: 'publish_shopify_terms',
|
|
1752
|
+
title: 'Publish renewal terms to the Shopify app block (write)',
|
|
1753
|
+
description: [
|
|
1754
|
+
'WRITE (admin key). Shopify store linked through the Checkbox Shopify app (get_site_status: shopify.connection "app"): publishes the engine-verified renewal terms + consent label for the site\'s real plan to the Checkbox app block. Read-only on the store except that app-owned metafield (no theme/product/checkout edits); safe to repeat.',
|
|
1755
|
+
'The merchant still places the "Renewal terms + consent" block once: give them theme_editor_url from the result. Then verify_site.',
|
|
1756
|
+
'shopify_not_connected: only the merchant can install the app in Shopify and click "Connect to Checkbox". terms_incomplete: follow next_action.',
|
|
1757
|
+
].join('\n'),
|
|
1758
|
+
input: {
|
|
1759
|
+
site: requiredSite,
|
|
1760
|
+
cancel_url: z.string().max(2048).optional().describe('Cancellation URL to state (default: detected).'),
|
|
1761
|
+
plan: z.string().max(200).optional().describe('Plan / price id (default: detected).'),
|
|
1762
|
+
},
|
|
1763
|
+
scopes: ['fixes:write'],
|
|
1764
|
+
annotations: { ...write(true, true), title: 'Publish Shopify terms' },
|
|
1765
|
+
async run(args, { api }) {
|
|
1766
|
+
const site = String(args.site);
|
|
1767
|
+
const r = await api.request('POST', `/v1/sites/${enc(site)}/fixes/shopify/publish`, { body: pick(args, ['cancel_url', 'plan']) });
|
|
1768
|
+
return {
|
|
1769
|
+
headline: `Published the verified renewal terms to the Checkbox app block on ${r.shop} (${r.id}).`,
|
|
1770
|
+
notes: [
|
|
1771
|
+
`Disclosure: ${untrusted(r.disclosure, 600)}`,
|
|
1772
|
+
`Consent checkbox: ${untrusted(r.consent_label, 200)}`,
|
|
1773
|
+
`Only the merchant can place the block (once): ask them to open ${r.theme_editor_url} , check the "Renewal terms + consent" block sits directly above the add-to-cart button, and click Save. Later publishes update it in place.`,
|
|
1774
|
+
],
|
|
1775
|
+
data: r,
|
|
1776
|
+
next: r.next_action ?? { type: 'place_theme_block', message: `After the merchant saved the block in the theme editor, call verify_site for ${r.site}.`, operation_id: 'verifySite' },
|
|
1777
|
+
};
|
|
1778
|
+
},
|
|
1779
|
+
};
|
|
1225
1780
|
const runScan = {
|
|
1226
1781
|
name: 'run_scan',
|
|
1227
1782
|
title: 'Run a synthetic-shopper scan (write)',
|
|
@@ -1242,15 +1797,26 @@ const runScan = {
|
|
|
1242
1797
|
const getScan = {
|
|
1243
1798
|
name: 'get_scan',
|
|
1244
1799
|
title: 'Get a scan',
|
|
1245
|
-
description: 'A scan\'s status (queued → running → completed | failed), warnings, and once completed the evaluation it produced
|
|
1800
|
+
description: 'A scan\'s status (queued → running → completed | failed), warnings, and once completed the evaluation it produced (the site status and to-do list update from it).',
|
|
1246
1801
|
input: { scan: z.string().max(100).describe('Scan id (scan_…).') },
|
|
1247
1802
|
scopes: ['scans:read'],
|
|
1248
1803
|
annotations: { ...ro, title: 'Scan' },
|
|
1249
1804
|
async run(args, { api }) {
|
|
1250
1805
|
const scan = await api.request('GET', `/v1/scans/${enc(String(args.scan))}`);
|
|
1251
1806
|
const done = scan.status === 'completed';
|
|
1807
|
+
// Audit P1: a queued scan of a site held for domain verification does not run until the domain is verified.
|
|
1808
|
+
if (scan.status === 'queued' && scan.site) {
|
|
1809
|
+
const site = await optional(api.request('GET', `/v1/sites/${enc(scan.site)}`));
|
|
1810
|
+
if ('monitoring' in site && site.monitoring?.held_for_domain_verification) {
|
|
1811
|
+
return {
|
|
1812
|
+
headline: `Scan ${scan.id}: queued (waiting for domain verification). It does not run until the site's domain is verified.`,
|
|
1813
|
+
data: scan,
|
|
1814
|
+
next: { type: 'verify_domain', message: `Do the plan's verify_domain task first (get_compliance_plan for site ${scan.site}); polling this scan changes nothing until then.`, operation_id: 'retrieveCompliancePlan' },
|
|
1815
|
+
};
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1252
1818
|
return {
|
|
1253
|
-
headline: `Scan ${scan.id}: ${statusLine(scan.status)}${done ?
|
|
1819
|
+
headline: `Scan ${scan.id}: ${statusLine(scan.status)}${done && scan.compliance?.label ? ` — ${plainLabel(scan.compliance.label)}` : ''}${done ? '; the site status and to-do list are updated (get_site_status)' : ''}${scan.error ? ` — error: ${untrusted(scan.error, 160)}` : ''}.`,
|
|
1254
1820
|
data: scan,
|
|
1255
1821
|
next: done
|
|
1256
1822
|
? { type: 'review', message: 'Call get_compliance_plan for what to change.', operation_id: 'retrieveCompliancePlan' }
|
|
@@ -1263,32 +1829,58 @@ const getScan = {
|
|
|
1263
1829
|
const sendFeedback = {
|
|
1264
1830
|
name: 'send_feedback',
|
|
1265
1831
|
title: 'Send feedback to the Checkbox team',
|
|
1266
|
-
description: '
|
|
1832
|
+
description: 'Bug, idea or question: what you tried, happened, expected. type review: only after the human said yes and saw the text; about Checkbox, not the site: no site names, URLs, quotes, secrets, customer data or claims the site broke a law. score 0-10 (would recommend); ≤5 phrases in liked, disliked, wrong_verdicts ("check.id: why wrong"); stack e.g. "Next.js + Stripe".',
|
|
1267
1833
|
input: {
|
|
1268
|
-
type: z.enum(['bug', 'idea', 'question']).optional()
|
|
1269
|
-
message: z.string().
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1834
|
+
type: z.enum(['bug', 'idea', 'question', 'review']).optional(),
|
|
1835
|
+
message: z.string().optional(),
|
|
1836
|
+
score: z.number().int().min(0).max(10).optional(),
|
|
1837
|
+
liked: z.array(z.string()).optional(),
|
|
1838
|
+
disliked: z.array(z.string()).optional(),
|
|
1839
|
+
wrong_verdicts: z.array(z.string()).optional(),
|
|
1840
|
+
would_keep_using: z.boolean().optional(),
|
|
1841
|
+
stack: z.string().optional(),
|
|
1842
|
+
site: z.string().optional(),
|
|
1843
|
+
request_id: z.string().optional(),
|
|
1844
|
+
tool: z.string().optional(),
|
|
1273
1845
|
},
|
|
1274
1846
|
scopes: ['account:read'],
|
|
1275
1847
|
annotations: { ...write(false, false), title: 'Send feedback' },
|
|
1276
1848
|
async run(args, ctx) {
|
|
1277
1849
|
const context = {};
|
|
1278
|
-
|
|
1850
|
+
// Reviews are about Checkbox, not the merchant's site: no site or request id is attached to them.
|
|
1851
|
+
for (const k of args.type === 'review' ? ['tool'] : ['site', 'request_id', 'tool'])
|
|
1279
1852
|
if (typeof args[k] === 'string' && args[k])
|
|
1280
1853
|
context[k] = String(args[k]);
|
|
1281
1854
|
const agent = ctx.key.appName ?? ctx.clientName;
|
|
1282
1855
|
if (agent)
|
|
1283
1856
|
context.agent = String(agent).slice(0, 100);
|
|
1284
|
-
|
|
1857
|
+
if (args.type !== 'review' && !(typeof args.message === 'string' && args.message.trim()))
|
|
1858
|
+
throw new ToolError('Write a message (what you tried, what happened, what you expected).');
|
|
1859
|
+
if (args.type === 'review' && typeof args.score !== 'number')
|
|
1860
|
+
throw new ToolError('A review needs score (0-10). Ask the human; never guess it.');
|
|
1861
|
+
const body = { ...pick(args, ['type', 'message', 'score', 'liked', 'disliked', 'wrong_verdicts', 'would_keep_using', 'stack']), ...(Object.keys(context).length ? { context } : {}) };
|
|
1862
|
+
const fb = await ctx.api.request('POST', '/v1/feedback', { body });
|
|
1285
1863
|
return {
|
|
1286
|
-
headline:
|
|
1864
|
+
headline: `${args.type === 'review' ? 'Review' : 'Feedback'} ${fb.id} sent to the Checkbox team (${fb.status}).${fb.redacted ? ' Some text looked like a secret, card number or email address and was replaced with [redacted].' : ''}`,
|
|
1287
1865
|
data: fb,
|
|
1288
1866
|
next: { type: 'none', message: 'Nothing else to do; the team replies by email to the account owner when needed.' },
|
|
1289
1867
|
};
|
|
1290
1868
|
},
|
|
1291
1869
|
};
|
|
1870
|
+
const LAW_CHANGES_SHOWN = 10;
|
|
1871
|
+
/**
|
|
1872
|
+
* Agent audit round 2: list_law_updates with limit 3 printed 62 KB (one catalog update carried 195 requirement changes,
|
|
1873
|
+
* each with its full summary). Each update keeps its first changes in short form, with the total.
|
|
1874
|
+
*/
|
|
1875
|
+
export function lawUpdateItem(u) {
|
|
1876
|
+
const changes = Array.isArray(u.changes) ? u.changes : [];
|
|
1877
|
+
const { changes: _c, ...rest } = u;
|
|
1878
|
+
return {
|
|
1879
|
+
...rest,
|
|
1880
|
+
changes: changes.slice(0, LAW_CHANGES_SHOWN).map((c) => pick(c, ['requirement_id', 'change', 'citation', 'title', 'effective_date'])),
|
|
1881
|
+
...(changes.length > LAW_CHANGES_SHOWN ? { changes_total: changes.length } : {}),
|
|
1882
|
+
};
|
|
1883
|
+
}
|
|
1292
1884
|
const listLawUpdates = {
|
|
1293
1885
|
name: 'list_law_updates',
|
|
1294
1886
|
title: 'List law updates and their impact',
|
|
@@ -1303,17 +1895,21 @@ const listLawUpdates = {
|
|
|
1303
1895
|
return {
|
|
1304
1896
|
headline: `${list.data.length}${list.has_more ? '+' : ''} law update(s).`,
|
|
1305
1897
|
notes: list.data.map((u) => `${u.date} ${u.id} (${(u.jurisdictions ?? []).join(', ')}): ${u.summary}${u.impacts?.length ? ` — affects ${u.impacts.length} site(s)` : ''}`),
|
|
1306
|
-
data: list,
|
|
1898
|
+
data: { ...list, data: list.data.map(lawUpdateItem) },
|
|
1307
1899
|
};
|
|
1308
1900
|
},
|
|
1309
1901
|
};
|
|
1310
1902
|
function toDisclosureTerms(t) {
|
|
1311
|
-
const out = pick(t, ['merchant_name', 'amount', 'currency', 'interval', 'interval_count', 'trial_days', 'cancel_url', 'cancel_timing']);
|
|
1903
|
+
const out = pick(t, ['merchant_name', 'amount', 'currency', 'interval', 'interval_count', 'trial_days', 'intro_percent_off', 'intro_periods', 'cancel_url', 'cancel_timing']);
|
|
1904
|
+
if (out.merchant_name === undefined && typeof t.brand_name === 'string')
|
|
1905
|
+
out.merchant_name = t.brand_name;
|
|
1312
1906
|
if (out.amount === undefined && t.price !== undefined) {
|
|
1313
1907
|
const n = typeof t.price === 'number' ? t.price : Number(String(t.price).replace(/[^0-9.]/g, ''));
|
|
1314
1908
|
if (Number.isFinite(n) && n > 0)
|
|
1315
1909
|
out.amount = Math.round(n * 100);
|
|
1316
1910
|
}
|
|
1911
|
+
if (typeof t.intro_price === 'number' && t.intro_price >= 0)
|
|
1912
|
+
out.intro_amount = Math.round(t.intro_price * 100);
|
|
1317
1913
|
return out;
|
|
1318
1914
|
}
|
|
1319
1915
|
function reactSnippet(d) {
|
|
@@ -1334,7 +1930,7 @@ function reactSnippet(d) {
|
|
|
1334
1930
|
const elementsInstallNotes = [
|
|
1335
1931
|
'Put script_tag in <head>; put element_html inside the sign-up form directly ABOVE the pay button. It keeps the button disabled until the shopper ticks the (unchecked, required) box.',
|
|
1336
1932
|
'Your server receives checkbox_consent_id (cons_…) with the form: store it with the subscription (e.g. Stripe metadata.checkbox_consent).',
|
|
1337
|
-
'Do not restyle it below body size or hide it; guard rails restore
|
|
1933
|
+
'Do not restyle it below body size or hide it; guard rails restore the required styles and report what they found.',
|
|
1338
1934
|
];
|
|
1339
1935
|
const elementsTermsShape = {
|
|
1340
1936
|
merchant_name: planTermsShape.merchant_name,
|
|
@@ -1349,29 +1945,30 @@ const elementsTermsShape = {
|
|
|
1349
1945
|
};
|
|
1350
1946
|
const getCompliantDisclosure = {
|
|
1351
1947
|
name: 'get_compliant_disclosure',
|
|
1352
|
-
title: 'Generate a
|
|
1948
|
+
title: 'Generate a disclosure that passes the checks for given terms',
|
|
1353
1949
|
description: [
|
|
1354
1950
|
'Generates the locked, engine-verified automatic-renewal disclosure for a plan: disclosure_text, consent_label (for an unchecked, required checkbox), button_text and drop-in html. The same wording Checkbox Elements and Fixes use.',
|
|
1355
|
-
'Give the terms: price (major units) or amount (minor), interval, trial_days, cancel_url, merchant_name. Example: {"merchant_name":"Acme","price":19.99,"interval":"month","trial_days":14,"cancel_url":"https://acme.example/account/cancel"}.',
|
|
1951
|
+
'Give the terms: price (major units) or amount (minor), interval, trial_days, cancel_url, merchant_name, promo (intro_price or intro_percent_off, intro_periods). Example: {"merchant_name":"Acme","price":19.99,"interval":"month","trial_days":14,"cancel_url":"https://acme.example/account/cancel"}.',
|
|
1356
1952
|
'With site (and a key allowed to create disclosures), it also returns the Checkbox Elements snippet (script tag + <checkbox-disclosure> element + React usage) that records each consent as evidence.',
|
|
1357
1953
|
].join('\n'),
|
|
1358
|
-
|
|
1954
|
+
// Round 15 (P4): a promo (intro price) is stated with how long it lasts.
|
|
1955
|
+
input: { ...elementsTermsShape, brand_name: planTermsShape.brand_name, intro_price: planTermsShape.intro_price, intro_percent_off: planTermsShape.intro_percent_off, intro_periods: planTermsShape.intro_periods, interval: z.enum(['day', 'week', 'month', 'year']).describe('Billing interval.'), site: z.string().max(100).optional().describe('Site id (site_…) to also get the Elements snippet for.'), jurisdictions: jurisdictions.optional() },
|
|
1359
1956
|
scopes: ['checks:read'],
|
|
1360
|
-
annotations: { ...ro, readOnlyHint: false, title: '
|
|
1957
|
+
annotations: { ...ro, readOnlyHint: false, title: 'Disclosure that passes the checks' },
|
|
1361
1958
|
async run(args, { api, key }) {
|
|
1362
1959
|
if (typeof args.site === 'string' && args.site && keyAllows(key, 'consents:write')) {
|
|
1363
1960
|
const d = await api.request('POST', '/v1/disclosures', { body: { site: args.site, terms: toDisclosureTerms(args) } });
|
|
1364
1961
|
return {
|
|
1365
|
-
headline: `
|
|
1962
|
+
headline: `Disclosure ${d.id}${d.verification?.passes === undefined ? '' : d.verification.passes ? ' (passes the sign-up checks on your checkout)' : ' (see verification: not every check passes yet)'}.`,
|
|
1366
1963
|
notes: [`Disclosure: ${d.text}`, `Consent label: ${d.consent_label}`, ...elementsInstallNotes],
|
|
1367
1964
|
data: { ...d, react: reactSnippet(d) },
|
|
1368
1965
|
};
|
|
1369
1966
|
}
|
|
1370
1967
|
// No site: generate from the terms with the stateless check (the replacement does not depend on the text checked).
|
|
1371
|
-
const body = { text: '(no disclosure yet)', ...pick(args, ['merchant_name', 'plan_name', 'price', 'amount', 'currency', 'interval', 'interval_count', 'trial_days', 'cancel_url', 'cancel_timing', 'jurisdictions']) };
|
|
1968
|
+
const body = { text: '(no disclosure yet)', ...pick(args, ['merchant_name', 'brand_name', 'plan_name', 'price', 'amount', 'currency', 'interval', 'interval_count', 'trial_days', 'intro_price', 'intro_percent_off', 'intro_periods', 'cancel_url', 'cancel_timing', 'jurisdictions']) };
|
|
1372
1969
|
const r = await api.request('POST', '/v1/checks/disclosure', { body });
|
|
1373
1970
|
return {
|
|
1374
|
-
headline: `
|
|
1971
|
+
headline: `Suggested disclosure generated (${r.replacement.verified ? 'passes the checks, verified by the rules engine' : 'not fully verified by the rules engine'}).`,
|
|
1375
1972
|
notes: [
|
|
1376
1973
|
`Disclosure: ${r.replacement.disclosure_text}`,
|
|
1377
1974
|
`Consent label: ${r.replacement.consent_label}`,
|
|
@@ -1388,7 +1985,7 @@ const getElementsSnippet = {
|
|
|
1388
1985
|
title: 'Get the Checkbox Elements snippet for a site',
|
|
1389
1986
|
description: [
|
|
1390
1987
|
'Returns paste-ready Checkbox Elements markup for a site: the pinned <script> tag (with SRI) for <head>, the <checkbox-disclosure> element for directly above the pay button, the publishable key, and a React usage example.',
|
|
1391
|
-
'The element renders the locked
|
|
1988
|
+
'The element renders the locked disclosure (it passes the checks) with an unchecked, required consent checkbox, gates the pay button, and records each consent (cons_…) in the evidence vault. Terms come from the site\'s billing connection unless you pass them.',
|
|
1392
1989
|
'This creates a disclosure record (write, safe to repeat). Consents themselves are recorded by the browser, never by agents.',
|
|
1393
1990
|
].join('\n'),
|
|
1394
1991
|
input: { site: requiredSite, plan: z.string().max(200).optional().describe('Billing price / plan id (price_…) when the site has several.'), ...elementsTermsShape },
|
|
@@ -1493,7 +2090,7 @@ const listDueNotices = {
|
|
|
1493
2090
|
annotations: { ...ro, title: 'Due notices' },
|
|
1494
2091
|
async run(args, { api }) {
|
|
1495
2092
|
const list = await api.request('GET', '/v1/notices/due', {
|
|
1496
|
-
query: pick(args, ['site', 'status', 'kind', 'subscription', 'limit', 'starting_after']),
|
|
2093
|
+
query: { ...pick(args, ['site', 'status', 'kind', 'subscription', 'limit', 'starting_after']), ...(typeof args.kind === 'string' ? { kind: args.kind.split(',').map((k) => noticeKind(k.trim())).join(',') } : {}) },
|
|
1497
2094
|
});
|
|
1498
2095
|
const overdue = list.data.filter((d) => d.status === 'overdue').length;
|
|
1499
2096
|
return {
|
|
@@ -1538,7 +2135,7 @@ const getNoticeTemplates = {
|
|
|
1538
2135
|
site: z.string().max(100).optional().describe('Site (site_…): adds BCC addresses; required with check.'),
|
|
1539
2136
|
check: z
|
|
1540
2137
|
.object({
|
|
1541
|
-
kind:
|
|
2138
|
+
kind: emailKind.describe('Notice kind.'),
|
|
1542
2139
|
subject: z.string().max(1000).describe('Subject (merge fields allowed).'),
|
|
1543
2140
|
text: z.string().max(100_000).optional().describe('Plain-text body.'),
|
|
1544
2141
|
html: z.string().max(100_000).optional().describe('HTML body.'),
|
|
@@ -1554,7 +2151,7 @@ const getNoticeTemplates = {
|
|
|
1554
2151
|
if (!args.site)
|
|
1555
2152
|
throw new Error('site is required to check a template (its plan and jurisdictions scope the check).');
|
|
1556
2153
|
const r = await api.request('POST', '/v1/notices/templates/check', {
|
|
1557
|
-
body: { site: args.site, ...check },
|
|
2154
|
+
body: { site: args.site, ...check, kind: noticeKind(check.kind) },
|
|
1558
2155
|
});
|
|
1559
2156
|
return {
|
|
1560
2157
|
headline: `Template check: ${r.verdict.toUpperCase()}.`,
|
|
@@ -1577,8 +2174,9 @@ const callApi = {
|
|
|
1577
2174
|
name: 'call_api',
|
|
1578
2175
|
title: 'Call any Checkbox API endpoint',
|
|
1579
2176
|
description: [
|
|
1580
|
-
'For a next_action whose operation has no dedicated tool (e.g.
|
|
1581
|
-
'Examples: {"method":"
|
|
2177
|
+
'For a next_action whose operation has no dedicated tool (e.g. createStripeCheckoutConfig, updateTask, updateNoticeSettings, listConsents). Calls the Checkbox /v1 API with this server\'s connection; paths and bodies are in https://app.checkbox.site/v1/openapi.json.',
|
|
2178
|
+
'Examples: {"method":"GET","path":"/v1/account"} ; {"method":"POST","path":"/v1/sites/site_123/domain_verification/check"}.',
|
|
2179
|
+
'Never send API keys or Stripe keys through it: keys are added by the human in the Checkbox dashboard, never pasted into the chat.',
|
|
1582
2180
|
'GET is read-only. POST/DELETE change data: only do what the user or a next_action asked for. DELETE archives or removes objects — confirm with the user first.',
|
|
1583
2181
|
].join('\n'),
|
|
1584
2182
|
input: {
|
|
@@ -1603,10 +2201,74 @@ const callApi = {
|
|
|
1603
2201
|
},
|
|
1604
2202
|
};
|
|
1605
2203
|
/** Every tool, in the order clients list them. */
|
|
2204
|
+
/**
|
|
2205
|
+
* Round 12 (pilot 11, nextjs + agent users): setup_checkbox dropped unknown fields silently; the homepage prompt says
|
|
2206
|
+
* "brand", the schema says merchant_name, so the site was named "localhost" with no warning. Common spellings of the
|
|
2207
|
+
* brand are read as merchant_name; any other unknown field is listed back as ignored.
|
|
2208
|
+
*/
|
|
2209
|
+
export const SETUP_FIELD_ALIASES = Object.freeze({ brand: 'merchant_name', brand_name: 'merchant_name', business_name: 'merchant_name', company_name: 'merchant_name', platform: 'billing_platform' });
|
|
2210
|
+
const BILLING_PLATFORMS = new Set(['stripe', 'shopify', 'recharge', 'chargebee', 'recurly', 'paddle', 'woocommerce', 'custom']);
|
|
2211
|
+
export function normalizeSetupArgs(raw, known) {
|
|
2212
|
+
const args = {};
|
|
2213
|
+
const aliased = [];
|
|
2214
|
+
const ignored = [];
|
|
2215
|
+
for (const [k, v] of Object.entries(raw))
|
|
2216
|
+
if (known.has(k))
|
|
2217
|
+
args[k] = v;
|
|
2218
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
2219
|
+
if (known.has(k))
|
|
2220
|
+
continue;
|
|
2221
|
+
const to = SETUP_FIELD_ALIASES[k];
|
|
2222
|
+
const ok = typeof v === 'string' && !!v.trim() && (to !== 'billing_platform' || BILLING_PLATFORMS.has(v.trim().toLowerCase()));
|
|
2223
|
+
if (to && known.has(to) && ok && args[to] === undefined) {
|
|
2224
|
+
args[to] = to === 'billing_platform' ? v.trim().toLowerCase() : v.trim().slice(0, 100);
|
|
2225
|
+
aliased.push(`${k} (read as ${to})`);
|
|
2226
|
+
}
|
|
2227
|
+
else
|
|
2228
|
+
ignored.push(k);
|
|
2229
|
+
}
|
|
2230
|
+
return { args, aliased, ignored };
|
|
2231
|
+
}
|
|
2232
|
+
/** Messages of the API's `warnings` ({ code, message }) on a setup result (or its session). */
|
|
2233
|
+
export function apiWarningMessages(data) {
|
|
2234
|
+
if (!data || typeof data !== 'object')
|
|
2235
|
+
return [];
|
|
2236
|
+
const d = data;
|
|
2237
|
+
const list = Array.isArray(d.warnings) ? d.warnings : Array.isArray(d.session?.warnings) ? d.session.warnings : [];
|
|
2238
|
+
return list.flatMap((w) => (w && typeof w === 'object' && typeof w.message === 'string' ? [`Warning: ${w.message}`] : []));
|
|
2239
|
+
}
|
|
2240
|
+
function acceptingSetupAliases(def) {
|
|
2241
|
+
const known = new Set(Object.keys(def.input));
|
|
2242
|
+
return {
|
|
2243
|
+
...def,
|
|
2244
|
+
passthrough: true,
|
|
2245
|
+
async run(raw, ctx) {
|
|
2246
|
+
const { args, aliased, ignored } = normalizeSetupArgs(raw, known);
|
|
2247
|
+
// Round 18 (item 19, pilot 17): update_setup refuses unknown top-level fields, as it refuses unknown nested ones
|
|
2248
|
+
// (an ignored field read as "saved" to the agent).
|
|
2249
|
+
if (ignored.length && def.name === 'update_setup') {
|
|
2250
|
+
throw new ToolError(unknownArgumentsMessage(def.name, ignored, [...known]), { type: 'fix_request', message: 'Rename or remove those fields and call update_setup again.' });
|
|
2251
|
+
}
|
|
2252
|
+
const out = await def.run(args, ctx);
|
|
2253
|
+
// Pilot round 13: the API's input warnings (plan amounts that look like dollars, or differ from the Stripe Price)
|
|
2254
|
+
// are shown, and kept next to the field warnings below.
|
|
2255
|
+
const apiWarnings = apiWarningMessages(out.data);
|
|
2256
|
+
if (!aliased.length && !ignored.length && !apiWarnings.length)
|
|
2257
|
+
return out;
|
|
2258
|
+
const warnings = [
|
|
2259
|
+
...apiWarnings,
|
|
2260
|
+
...(aliased.length ? [`Read ${aliased.join(', ')}.`] : []),
|
|
2261
|
+
...(ignored.length ? [`Ignored unknown field${ignored.length === 1 ? '' : 's'}: ${ignored.join(', ')}. ${def.name} accepts: ${[...known].filter((k) => k !== 'device_code').join(', ')}.`] : []),
|
|
2262
|
+
];
|
|
2263
|
+
const data = out.data && typeof out.data === 'object' && !Array.isArray(out.data) ? { ...out.data, warnings } : out.data;
|
|
2264
|
+
return { ...out, notes: [...warnings, ...(out.notes ?? [])], data };
|
|
2265
|
+
},
|
|
2266
|
+
};
|
|
2267
|
+
}
|
|
1606
2268
|
export const TOOLS = [
|
|
1607
|
-
setupCheckbox,
|
|
2269
|
+
acceptingSetupAliases(setupCheckbox),
|
|
1608
2270
|
getSetupStatus,
|
|
1609
|
-
updateSetup,
|
|
2271
|
+
acceptingSetupAliases(updateSetup),
|
|
1610
2272
|
completeSetup,
|
|
1611
2273
|
getCompliancePlan,
|
|
1612
2274
|
getTask,
|
|
@@ -1628,6 +2290,7 @@ export const TOOLS = [
|
|
|
1628
2290
|
getSiteStatus,
|
|
1629
2291
|
listFindings,
|
|
1630
2292
|
getFinding,
|
|
2293
|
+
publishShopifyTerms,
|
|
1631
2294
|
runScan,
|
|
1632
2295
|
getScan,
|
|
1633
2296
|
listLawUpdates,
|
|
@@ -1648,9 +2311,9 @@ export const TOOL_OPERATIONS = {
|
|
|
1648
2311
|
update_setup: ['updateSetupSession'],
|
|
1649
2312
|
update_site: ['updateSite'],
|
|
1650
2313
|
complete_setup: ['completeSetupSession'],
|
|
1651
|
-
get_compliance_plan: ['retrieveCompliancePlan'],
|
|
2314
|
+
get_compliance_plan: ['retrieveCompliancePlan', 'claimReviewPrompt'],
|
|
1652
2315
|
get_task: ['retrieveTask'],
|
|
1653
|
-
verify_task: ['verifyTask'],
|
|
2316
|
+
verify_task: ['verifyTask', 'claimReviewPrompt'],
|
|
1654
2317
|
answer_task: ['answerTask'],
|
|
1655
2318
|
verify_site: ['verifySite'],
|
|
1656
2319
|
get_verification: ['retrieveVerification'],
|
|
@@ -1667,6 +2330,7 @@ export const TOOL_OPERATIONS = {
|
|
|
1667
2330
|
get_site_status: ['retrieveSite', 'retrieveLatestEvaluation', 'listFindings'],
|
|
1668
2331
|
list_findings: ['listFindings'],
|
|
1669
2332
|
get_finding: ['retrieveFinding', 'retrieveFindingFix'],
|
|
2333
|
+
publish_shopify_terms: ['publishShopifyTerms'],
|
|
1670
2334
|
run_scan: ['createScan'],
|
|
1671
2335
|
get_scan: ['retrieveScan'],
|
|
1672
2336
|
list_law_updates: ['listLawUpdates'],
|