@checkbox-site/mcp 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/api.d.ts +7 -0
- package/dist/api.js +11 -1
- package/dist/format.d.ts +26 -1
- package/dist/format.js +202 -18
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/key-file.d.ts +21 -0
- package/dist/key-file.js +118 -0
- package/dist/resources.js +16 -13
- package/dist/server.d.ts +29 -3
- package/dist/server.js +98 -10
- package/dist/stdio.d.ts +2 -0
- package/dist/stdio.js +11 -1
- package/dist/todo.d.ts +25 -0
- package/dist/todo.js +35 -0
- package/dist/tools.d.ts +163 -3
- package/dist/tools.js +922 -199
- 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 +10 -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.'),
|
|
66
|
-
support_email: z.string().max(320).optional().describe('Support address subscribers
|
|
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.'),
|
|
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.'),
|
|
108
|
+
support_email: z.string().max(320).optional().describe('Support address for subscribers.'),
|
|
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
|
-
.enum(['stripe', 'shopify', 'recharge', 'chargebee', 'recurly', 'paddle', 'woocommerce', 'custom'])
|
|
111
|
+
.enum(['stripe', 'shopify', 'recharge', 'chargebee', 'recurly', 'paddle', 'lemon_squeezy', 'woocommerce', 'custom'])
|
|
70
112
|
.optional()
|
|
71
|
-
.describe('Billing platform
|
|
113
|
+
.describe('Billing platform.'),
|
|
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, amount, currency, interval, trial_days, 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
|
|
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).',
|
|
305
514
|
'',
|
|
306
|
-
'
|
|
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.',
|
|
309
|
-
'',
|
|
310
|
-
'With an API key: starts a setup session for the project (no human step) and returns the setup checklist with next_action.',
|
|
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 the stdio server saves the key to the git-ignored .env.local, showing a hint; the remote one returns it once: save it to .env.local, never commit it or ask for a pasted 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,
|
|
@@ -337,16 +542,30 @@ const setupCheckbox = {
|
|
|
337
542
|
};
|
|
338
543
|
}
|
|
339
544
|
const notes = [];
|
|
545
|
+
const saved = ctx.saveKeys ? await ctx.saveKeys(res).catch((err) => ({ ok: false, error: err instanceof Error ? err.message : String(err) })) : null;
|
|
340
546
|
if (ctx.adoptKeys) {
|
|
341
547
|
await ctx.adoptKeys(res);
|
|
342
548
|
notes.push('This MCP server now uses the new test-mode key for the rest of the session (more tools are now available).');
|
|
343
|
-
|
|
549
|
+
if (saved?.ok) {
|
|
550
|
+
notes.push(`The secret key was saved to ${saved.file} in this project${saved.gitignored ? ' (git-ignored)' : ' (add it to .gitignore: this folder is not a git repository)'} and is not shown here: never print, copy or commit it. ${saved.note ?? ''}This server reads it from there on its next start.`.replace(/ +/g, ' '));
|
|
551
|
+
}
|
|
552
|
+
else if (saved) {
|
|
553
|
+
notes.push(`The key could not be saved in this project (${saved.error.slice(0, 160)}), and it is not shown here. This server uses it until it stops; for a lasting key, the human creates one in the Checkbox dashboard (Agent & API) and sets CHECKBOX_API_KEY in the MCP server config.`);
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
notes.push('Save CHECKBOX_SECRET_KEY to .env.local (and set CHECKBOX_API_KEY in the MCP server config) so future sessions keep working.');
|
|
557
|
+
}
|
|
344
558
|
}
|
|
345
559
|
else {
|
|
346
560
|
notes.push('This remote MCP connection is stateless and keeps no key: save CHECKBOX_SECRET_KEY to .env.local and pass it on the connection (Authorization: Bearer) for the next calls:');
|
|
347
561
|
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
562
|
}
|
|
349
|
-
|
|
563
|
+
if (!saved)
|
|
564
|
+
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).');
|
|
565
|
+
else
|
|
566
|
+
notes.push('The publishable key (pk_) is safe in browser code (e.g. NEXT_PUBLIC_CHECKBOX_PUBLISHABLE_KEY).');
|
|
567
|
+
// Round 16 (pilot 15, sveltekit / change-guard): two public keys in one response, unexplained.
|
|
568
|
+
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
569
|
// In the same call, with the new key: apply the facts given (they are not lost between the two calls), run the
|
|
351
570
|
// first checks, and return the setup checklist's next step — exactly what `checkbox init` does.
|
|
352
571
|
// Normal-round pilot (sveltekit): the first checks failed with "No API key provided" and the facts given with the
|
|
@@ -354,26 +573,54 @@ const setupCheckbox = {
|
|
|
354
573
|
const keyed = ctx.apiForKey?.(res.secret_key) ?? (ctx.adoptKeys ? ctx.api : undefined);
|
|
355
574
|
let next;
|
|
356
575
|
let firstCheck;
|
|
576
|
+
let site = res.site ?? null;
|
|
577
|
+
let inputWarnings = [];
|
|
578
|
+
let snippetKey;
|
|
357
579
|
if (keyed && res.setup_session) {
|
|
358
580
|
try {
|
|
359
581
|
const facts = { ...(PENDING_SETUP_FACTS.get(args.device_code) ?? {}), ...pick(args, Object.keys(setupFactsShape)) };
|
|
360
582
|
PENDING_SETUP_FACTS.delete(args.device_code);
|
|
361
|
-
|
|
362
|
-
|
|
583
|
+
// Pilot round 13 (change-guard): `agent` given with the device_code call (or known only now: a stateless
|
|
584
|
+
// remote connection has no client name on the first call) was never stored, so the session said agent null.
|
|
585
|
+
const agentName = args.agent ?? ctx.key.appName ?? ctx.clientName;
|
|
586
|
+
if (agentName)
|
|
587
|
+
facts.client = { name: 'checkbox-mcp', version: ctx.serverVersion, agent: String(agentName).slice(0, 100) };
|
|
588
|
+
if (Object.keys(facts).length) {
|
|
589
|
+
const upd = await keyed.request('POST', `/v1/setup_sessions/${res.setup_session}`, { body: facts });
|
|
590
|
+
if (Array.isArray(upd?.warnings))
|
|
591
|
+
inputWarnings = upd.warnings;
|
|
592
|
+
}
|
|
363
593
|
const v = await keyed.request('POST', `/v1/setup_sessions/${res.setup_session}/verify`, { body: {} });
|
|
364
594
|
next = v.setup_session?.next_action;
|
|
365
|
-
|
|
595
|
+
snippetKey = v.setup_session?.install?.snippet_key;
|
|
596
|
+
// Pilot round 13 (change-guard): the site created by these facts was missing from the result (site: null next
|
|
597
|
+
// to "site: done"), and the JSON next_action was the device token's, not the checklist's.
|
|
598
|
+
if (typeof v.setup_session?.site === 'string')
|
|
599
|
+
site = v.setup_session.site;
|
|
600
|
+
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
601
|
notes.push(firstCheckNote(next));
|
|
602
|
+
const pl = sitePlansNote(v.setup_session?.site_plans);
|
|
603
|
+
if (pl)
|
|
604
|
+
notes.push(pl);
|
|
367
605
|
}
|
|
368
606
|
catch (err) {
|
|
369
607
|
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
608
|
}
|
|
371
609
|
}
|
|
372
610
|
return {
|
|
373
|
-
headline: `Approved. Checkbox account ready${
|
|
611
|
+
headline: `Approved. Checkbox account ready${site ? ` with test-mode site ${site}` : ''}; setup session ${res.setup_session ?? '(none)'}.`,
|
|
374
612
|
notes,
|
|
375
613
|
...(next ? { next } : {}),
|
|
376
|
-
data: {
|
|
614
|
+
data: {
|
|
615
|
+
...(saved ? withoutSecrets(res) : res),
|
|
616
|
+
site,
|
|
617
|
+
...(next ? { next_action: next } : {}),
|
|
618
|
+
...(saved?.ok ? { key_file: saved.file } : {}),
|
|
619
|
+
env: { CHECKBOX_SECRET_KEY: saved ? `${hintOf(res.secret_key)} (${saved.ok ? `saved in ${saved.file}` : 'not shown'})` : res.secret_key, ...(res.publishable_key ? { CHECKBOX_PUBLISHABLE_KEY: res.publishable_key } : {}) },
|
|
620
|
+
public_keys: labelledPublicKeys(snippetKey ?? res.install?.snippet_key, res.publishable_key),
|
|
621
|
+
...(firstCheck ? { first_check: firstCheck } : {}),
|
|
622
|
+
...(inputWarnings.length ? { warnings: inputWarnings } : {}),
|
|
623
|
+
},
|
|
377
624
|
};
|
|
378
625
|
}
|
|
379
626
|
if (!api.hasKey) {
|
|
@@ -412,13 +659,42 @@ const setupCheckbox = {
|
|
|
412
659
|
const notes = session.review_url ? [`Human review page: ${session.review_url}`] : [];
|
|
413
660
|
if (session.next_action?.type === 'verify_domain')
|
|
414
661
|
notes.push(firstCheckNote(session.next_action));
|
|
662
|
+
const plansLine = sitePlansNote(session.site_plans);
|
|
663
|
+
if (plansLine)
|
|
664
|
+
notes.push(plansLine);
|
|
415
665
|
return {
|
|
416
666
|
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
667
|
notes,
|
|
418
|
-
data:
|
|
668
|
+
data: (() => {
|
|
669
|
+
const keys = labelledPublicKeys(session.install?.snippet_key, session.publishable_key);
|
|
670
|
+
return keys.length ? { ...withoutLegacyScore(session), public_keys: keys } : withoutLegacyScore(session);
|
|
671
|
+
})(),
|
|
419
672
|
};
|
|
420
673
|
},
|
|
421
674
|
};
|
|
675
|
+
/** "ck_test_…ab12": recognisable, never usable. */
|
|
676
|
+
function hintOf(key) {
|
|
677
|
+
if (!key)
|
|
678
|
+
return null;
|
|
679
|
+
const m = /^([a-z]{2}_(?:live|test)_)/.exec(key);
|
|
680
|
+
return `${m?.[1] ?? key.slice(0, 3)}…${key.slice(-4)}`;
|
|
681
|
+
}
|
|
682
|
+
/** The device token without its secret keys (hints only): what the stdio server returns once it saved them. */
|
|
683
|
+
function withoutSecrets(res) {
|
|
684
|
+
return { ...res, secret_key: hintOf(res.secret_key), ...(res.live_secret_key ? { live_secret_key: hintOf(res.live_secret_key) } : {}) };
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Round 20 (pilot 19 item 16, sveltekit / change-guard: a pk_ site key and a pk_test_ key side by side, unlabelled): each
|
|
688
|
+
* public key says what it is for.
|
|
689
|
+
*/
|
|
690
|
+
export function labelledPublicKeys(snippetKey, publishableKey) {
|
|
691
|
+
const out = [];
|
|
692
|
+
if (typeof snippetKey === 'string' && snippetKey)
|
|
693
|
+
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).' });
|
|
694
|
+
if (typeof publishableKey === 'string' && publishableKey)
|
|
695
|
+
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.' });
|
|
696
|
+
return out;
|
|
697
|
+
}
|
|
422
698
|
const getSetupStatus = {
|
|
423
699
|
name: 'get_setup_status',
|
|
424
700
|
title: 'Get the setup checklist (optionally re-check the site)',
|
|
@@ -439,14 +715,30 @@ const getSetupStatus = {
|
|
|
439
715
|
if (!keyAllows(key, 'setup_sessions:write'))
|
|
440
716
|
throw new ToolError('verify=true needs a key with setup_sessions write access; call again without verify.');
|
|
441
717
|
const res = await api.request('POST', `/v1/setup_sessions/${enc(id)}/verify`, { body: {} });
|
|
442
|
-
|
|
718
|
+
const notes = [];
|
|
719
|
+
if (res.domain_verified)
|
|
720
|
+
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})`}.`);
|
|
721
|
+
if (res.unverified_scan?.queued)
|
|
722
|
+
notes.push('First read-only scan queued (unverified: no checkout is submitted until you verify the domain).');
|
|
723
|
+
else if (res.unverified_scan?.reason)
|
|
724
|
+
notes.push(`No new read-only scan: ${untrusted(res.unverified_scan.reason, 200)}.`);
|
|
725
|
+
const ro = unverifiedScanNote(res.setup_session?.unverified_scan);
|
|
726
|
+
if (ro && !res.unverified_scan?.queued)
|
|
727
|
+
notes.push(ro);
|
|
728
|
+
const pl = sitePlansNote(res.setup_session?.site_plans);
|
|
729
|
+
if (pl)
|
|
730
|
+
notes.push(pl);
|
|
731
|
+
return { headline: `Re-checked setup session ${id}.`, ...(notes.length ? { notes } : {}), data: withoutLegacyScore(res), next: nextActionOf(res) ?? nextActionOf(res.session) };
|
|
443
732
|
}
|
|
444
733
|
const session = await api.request('GET', `/v1/setup_sessions/${enc(id)}`);
|
|
445
734
|
const open = (session.steps ?? []).filter((s) => s.status && !['done', 'skipped', 'not_applicable'].includes(s.status));
|
|
735
|
+
const ro = unverifiedScanNote(session.unverified_scan);
|
|
736
|
+
const plansLine = sitePlansNote(session.site_plans);
|
|
446
737
|
return {
|
|
447
738
|
headline: `Setup session ${session.id}: ${session.status}; ${open.length} checklist step(s) open.`,
|
|
448
|
-
|
|
449
|
-
|
|
739
|
+
// The policies step says what an unverified preview read (or that the pages wait for domain verification).
|
|
740
|
+
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] : [])],
|
|
741
|
+
data: withoutLegacyScore(session),
|
|
450
742
|
};
|
|
451
743
|
},
|
|
452
744
|
};
|
|
@@ -472,9 +764,24 @@ const updateSetup = {
|
|
|
472
764
|
const id = await resolveSetupSession(api, args.setup_session);
|
|
473
765
|
const body = pick(args, [...Object.keys(setupFactsShape), 'snippet_installed_in', 'env_written', 'live_site', 'refetch_policies']);
|
|
474
766
|
const session = await api.request('POST', `/v1/setup_sessions/${enc(id)}`, { body });
|
|
475
|
-
|
|
767
|
+
// Round 20 (pilot 19 item 6): a plans change re-evaluates the site; say where the new plan's tasks show up.
|
|
768
|
+
const notes = Array.isArray(args.plans) && args.plans.length
|
|
769
|
+
? ['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.']
|
|
770
|
+
: [];
|
|
771
|
+
return { headline: `Updated setup session ${session.id}.`, notes, data: withoutLegacyScore(session) };
|
|
476
772
|
},
|
|
477
773
|
};
|
|
774
|
+
/** Round 13 (pilot 12, codex: "sent" while nothing new arrived): the summary email's real delivery status, with when. */
|
|
775
|
+
export function summaryEmailLine(status, d) {
|
|
776
|
+
const when = d?.at ? ` at ${new Date(d.at * 1000).toISOString().slice(0, 16).replace('T', ' ')} UTC` : '';
|
|
777
|
+
if (status === 'sent')
|
|
778
|
+
return `sent${d?.to ? ` to ${d.to}` : ''} (the mail server accepted it${when}; it is sent once per setup)`;
|
|
779
|
+
if (status === 'failed')
|
|
780
|
+
return `failed${d?.error ? ` (${d.error})` : ''}; call complete_setup again to retry`;
|
|
781
|
+
if (status === 'not_configured')
|
|
782
|
+
return 'not sent: this deployment has no email delivery';
|
|
783
|
+
return 'not sent';
|
|
784
|
+
}
|
|
478
785
|
const completeSetup = {
|
|
479
786
|
name: 'complete_setup',
|
|
480
787
|
title: 'Finish setup and email the human a summary',
|
|
@@ -486,15 +793,32 @@ const completeSetup = {
|
|
|
486
793
|
const id = await resolveSetupSession(api, args.setup_session);
|
|
487
794
|
const session = await api.request('POST', `/v1/setup_sessions/${enc(id)}/complete`, { body: {} });
|
|
488
795
|
return {
|
|
489
|
-
headline: `Setup ${session.id} completed; summary email: ${session.owner_email
|
|
796
|
+
headline: `Setup ${session.id} completed; summary email: ${summaryEmailLine(session.owner_email, session.owner_email_detail)}.`,
|
|
490
797
|
notes: session.review_url ? [`Human review page: ${session.review_url}`] : [],
|
|
491
|
-
data: session,
|
|
798
|
+
data: withoutLegacyScore(session),
|
|
492
799
|
};
|
|
493
800
|
},
|
|
494
801
|
};
|
|
802
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
803
|
+
// Compliance plan loop
|
|
804
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
805
|
+
/**
|
|
806
|
+
* Round 21 (pilot 20 item 8, lawyer): the plan status in words ("Checks pass"), never the bare machine value `compliant`
|
|
807
|
+
* an agent would repeat to a merchant. Older APIs without status_label: the same mapping here.
|
|
808
|
+
*/
|
|
809
|
+
const PLAN_STATUS_WORDS = { action_required: 'Fixes needed', verifying: 'Checking your changes', compliant: 'Checks pass', blocked: 'Your call' };
|
|
810
|
+
export function planStatusWords(status, label) {
|
|
811
|
+
return label || PLAN_STATUS_WORDS[status] || status;
|
|
812
|
+
}
|
|
813
|
+
/** "Old task (covered by: Open task)": the open task sharing a check with a superseded one. Pure, exported for tests. */
|
|
814
|
+
export function coveredLine(t, open) {
|
|
815
|
+
const ids = t.check_ids ?? [];
|
|
816
|
+
const by = open.find((o) => o.key !== t.key && (o.check_ids ?? []).some((c) => ids.includes(c)));
|
|
817
|
+
return by ? `${t.title} (covered by: ${by.title})` : `${t.title} (covered by another open task)`;
|
|
818
|
+
}
|
|
495
819
|
/** The plan with each task reduced to what an agent needs to choose the next one (the full task: get_task). */
|
|
496
820
|
export function slimPlan(plan) {
|
|
497
|
-
const clip =
|
|
821
|
+
const clip = clipWords;
|
|
498
822
|
const tasks = (plan.tasks ?? []).map((t) => {
|
|
499
823
|
const full = t;
|
|
500
824
|
const change = (full.change ?? {});
|
|
@@ -518,15 +842,28 @@ export function slimPlan(plan) {
|
|
|
518
842
|
});
|
|
519
843
|
return { ...plan, tasks: tasks };
|
|
520
844
|
}
|
|
845
|
+
/**
|
|
846
|
+
* One-time review hint: when a site's plan is compliant, the API hands out the prompt once per site
|
|
847
|
+
* (claimReviewPrompt). Never fails the tool: older servers or a missing scope simply mean no hint.
|
|
848
|
+
*/
|
|
849
|
+
async function reviewHint(api, site, trigger) {
|
|
850
|
+
try {
|
|
851
|
+
const p = await api.request('POST', `/v1/sites/${enc(site)}/review_prompt`, { body: { trigger } });
|
|
852
|
+
return p.show && p.message ? [`One-time: ${p.message}`] : [];
|
|
853
|
+
}
|
|
854
|
+
catch {
|
|
855
|
+
return [];
|
|
856
|
+
}
|
|
857
|
+
}
|
|
521
858
|
const getCompliancePlan = {
|
|
522
859
|
name: 'get_compliance_plan',
|
|
523
860
|
title: 'Get the compliance plan (ordered tasks)',
|
|
524
861
|
description: [
|
|
525
|
-
'The main tool after setup. Returns the ordered tasks that make the site
|
|
862
|
+
'The main tool after setup. Returns the ordered tasks that make the site pass the automatic-renewal checks, recomputed from the latest observations.',
|
|
526
863
|
'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
|
|
864
|
+
'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
865
|
'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
|
|
866
|
+
'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
867
|
].join('\n'),
|
|
531
868
|
input: { site: siteId.optional() },
|
|
532
869
|
scopes: ['compliance_plans:read'],
|
|
@@ -537,17 +874,39 @@ const getCompliancePlan = {
|
|
|
537
874
|
const open = (plan.tasks ?? []).filter((t) => !['done', 'wont_fix'].includes(t.status));
|
|
538
875
|
const required = open.filter((t) => (t.tier ?? 'required') === 'required');
|
|
539
876
|
const optional = open.filter((t) => (t.tier ?? 'required') !== 'required');
|
|
877
|
+
// One to-do list: the same ordered open tasks (titles, order, counts) as the dashboard, report, emails and CLI.
|
|
878
|
+
const todo = planTodo((plan.tasks ?? []));
|
|
879
|
+
const rest = required.filter((t) => !todo.some((x) => x.id === t.id));
|
|
880
|
+
const { score: _score, insurable: _insurable, ...shown } = plan;
|
|
540
881
|
return {
|
|
541
|
-
|
|
882
|
+
// Audit P2: one Next line (the plan's next_action below); the summary's own "Next: …" is not repeated.
|
|
883
|
+
headline: `Plan for ${site}: ${plainLabel(plan.compliance?.label ?? 'Not checked yet')} (plan status: ${planStatusWords(plan.status, plan.status_label)}).`,
|
|
542
884
|
notes: [
|
|
543
|
-
...
|
|
544
|
-
...(
|
|
545
|
-
...(
|
|
885
|
+
...(todo.length ? [`To do (${todo.length}, in order):`] : []),
|
|
886
|
+
...todo.map((t, i) => `${i + 1}. ${t.id} ${todoLine(t)}`),
|
|
887
|
+
...rest.map((t) => `${t.id} [${t.status}${t.priority ? `, ${t.priority}` : ''}${t.change?.type ? `, ${t.change.type}` : ''}] ${t.title}`),
|
|
888
|
+
// Round 14: a task closed because it no longer applies is "Not applicable", never "done". Round 16 (pilot 15,
|
|
889
|
+
// codex): a task closed by a passing verification is "Done (verified)", never "Not applicable".
|
|
890
|
+
...(() => {
|
|
891
|
+
const closed = (plan.tasks ?? []);
|
|
892
|
+
const verified = closed.filter((t) => t.status === 'done' && t.closed_reason === 'verified' && t.compliance_status !== 'not_applicable');
|
|
893
|
+
// Round 20 (pilot 19 item 9): a task merged into another open task is "covered by" it, never "not applicable".
|
|
894
|
+
const covered = closed.filter((t) => t.closed_reason === 'superseded');
|
|
895
|
+
const na = closed.filter((t) => t.compliance_status === 'not_applicable' && t.closed_reason !== 'verified' && t.closed_reason !== 'superseded');
|
|
896
|
+
return [
|
|
897
|
+
...(verified.length ? [`Done (verified): ${verified.map((t) => t.title).join('; ')}`] : []),
|
|
898
|
+
...(covered.length ? [`Merged into another task: ${covered.map((t) => coveredLine(t, open)).join('; ')}`] : []),
|
|
899
|
+
...(na.length ? [`Not applicable (closed without a change, nothing observed passing): ${na.map((t) => t.title).join('; ')}`] : []),
|
|
900
|
+
];
|
|
901
|
+
})(),
|
|
902
|
+
...(optional.length ? [`Optional suggestions (${plan.protection_level ?? 'balanced'} protection; never block the checks from passing): ${optional.map((t) => t.title).join('; ')}`] : []),
|
|
903
|
+
...(plan.optional_improvements ? ['Optional (never blocking, not part of compliance): conversion suggestions for this site with `suggest_improvements`.'] : []),
|
|
546
904
|
'Each task below is a summary; call get_task with its id for the full change (diff, copy or params) before applying it.',
|
|
905
|
+
...(plan.status === 'compliant' ? await reviewHint(api, site, 'plan_compliant') : []),
|
|
547
906
|
],
|
|
548
907
|
// Normal-round pilot (sveltekit): the full plan (53K characters) was cut mid-JSON by the client. The plan here is a
|
|
549
908
|
// compact summary of every task; get_task returns one task in full.
|
|
550
|
-
data: slimPlan(
|
|
909
|
+
data: slimPlan(shown),
|
|
551
910
|
};
|
|
552
911
|
},
|
|
553
912
|
};
|
|
@@ -560,9 +919,23 @@ const getTask = {
|
|
|
560
919
|
annotations: { ...ro, title: 'Task' },
|
|
561
920
|
async run(args, { api }) {
|
|
562
921
|
const task = await api.request('GET', `/v1/tasks/${enc(String(args.task))}`);
|
|
563
|
-
|
|
922
|
+
// Audit P3: a task citing every state's law listed dozens of citations; the first 8 and a count are enough here.
|
|
923
|
+
const cites = task.requirement?.citations;
|
|
924
|
+
const shown = Array.isArray(cites) && cites.length > 8 ? { ...task, requirement: { ...task.requirement, citations: cites.slice(0, 8), citations_more: cites.length - 8 } } : task;
|
|
925
|
+
const word = task.compliance_status === 'not_applicable' ? 'not applicable' : task.status;
|
|
926
|
+
return { headline: `Task ${task.id} [${word}]: ${task.title}`, data: shown };
|
|
564
927
|
},
|
|
565
928
|
};
|
|
929
|
+
/** Audit: the pre-check's per-check results, one compact line each (agents only saw the overall verdict). */
|
|
930
|
+
export function precheckLines(pre, max = 12) {
|
|
931
|
+
const checks = pre?.checks ?? [];
|
|
932
|
+
if (!checks.length)
|
|
933
|
+
return [];
|
|
934
|
+
const order = { fail: 0, needs_review: 1, missing_data: 1, pass: 2, not_applicable: 3 };
|
|
935
|
+
const sorted = [...checks].sort((a, b) => (order[a.status] ?? 1) - (order[b.status] ?? 1));
|
|
936
|
+
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)}` : ''}`);
|
|
937
|
+
return [`Pre-check (${pre?.verdict ?? 'n/a'}), per check:`, ...lines, ...(checks.length > max ? [`and ${checks.length - max} more (data.precheck.checks)`] : [])];
|
|
938
|
+
}
|
|
566
939
|
const verifyTask = {
|
|
567
940
|
name: 'verify_task',
|
|
568
941
|
title: 'Verify a task after applying its change',
|
|
@@ -578,29 +951,56 @@ const verifyTask = {
|
|
|
578
951
|
task: z.string().max(100).describe('Task id (task_…).'),
|
|
579
952
|
html: z.string().max(950_000).optional().describe('Page tasks: the rendered page HTML.'),
|
|
580
953
|
stripe_checkout_session: z.record(z.string(), z.unknown()).optional().describe('Stripe tasks: the params your code passes to stripe.checkout.sessions.create.'),
|
|
954
|
+
stripe_checkout_sessions: z.array(z.record(z.string(), z.unknown())).max(10).optional().describe('Stripe tasks, one params object per plan.'),
|
|
581
955
|
email: z
|
|
582
956
|
.object({
|
|
583
|
-
kind:
|
|
957
|
+
kind: emailKind.optional(),
|
|
584
958
|
subject: z.string().max(1000),
|
|
585
959
|
text: z.string().max(100_000).optional(),
|
|
586
960
|
html: z.string().max(100_000).optional(),
|
|
587
961
|
})
|
|
588
962
|
.optional()
|
|
589
|
-
.describe('Email tasks: the new template.'),
|
|
963
|
+
.describe('Email tasks: the new template (passes when it matches one plan fully).'),
|
|
964
|
+
plan: z.string().min(1).max(200).optional().describe('Email tasks: only this plan (name).'),
|
|
965
|
+
price_id: z.string().min(1).max(200).optional().describe('Email tasks: only this plan (Price id).'),
|
|
590
966
|
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
967
|
wait,
|
|
592
968
|
},
|
|
593
969
|
scopes: ['compliance_plans:write'],
|
|
594
970
|
annotations: { ...write(true, true), title: 'Verify task' },
|
|
595
971
|
async run(args, { api }) {
|
|
596
|
-
const body = pick(args, ['html', 'stripe_checkout_session', 'email', 'recheck']);
|
|
972
|
+
const body = pick(args, ['html', 'stripe_checkout_session', 'stripe_checkout_sessions', 'email', 'plan', 'price_id', 'recheck']);
|
|
973
|
+
if (body.email && typeof body.email === 'object' && body.email.kind)
|
|
974
|
+
body.email = { ...body.email, kind: checkKind(body.email.kind) };
|
|
597
975
|
const v = await api.request('POST', `/v1/tasks/${enc(String(args.task))}/verify`, {
|
|
598
976
|
query: { wait: args.wait ?? 25 },
|
|
599
977
|
body,
|
|
600
978
|
});
|
|
979
|
+
const hint = v.plan_status === 'compliant' && v.site ? await reviewHint(api, v.site, 'last_task_verified') : [];
|
|
601
980
|
return {
|
|
602
|
-
headline: `Verification ${v.id} of ${String(args.task)}: ${statusLine(v.status)}${v.plan_status ? `; plan is now ${v.plan_status}` : ''}
|
|
603
|
-
notes:
|
|
981
|
+
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)}.` : ''}`,
|
|
982
|
+
notes: [
|
|
983
|
+
...precheckLines(v.precheck),
|
|
984
|
+
...((v.status === 'failed' || v.status === 'not_checked') && v.next_action?.type === 'verify_domain'
|
|
985
|
+
? ['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.']
|
|
986
|
+
: v.next_action?.type === 'retry_later'
|
|
987
|
+
? ['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.']
|
|
988
|
+
: v.next_action?.type === 'live_check_pending' && v.status === 'pending'
|
|
989
|
+
? // Round 14 (pilot 13): Checkbox Notices sends this email; never "add the BCC".
|
|
990
|
+
[v.next_action.description ?? 'Checkbox Notices sends this email: nothing to add to your code. Continue with the next task.']
|
|
991
|
+
: v.next_action?.type === 'bcc_then_continue'
|
|
992
|
+
? ['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).']
|
|
993
|
+
: v.status === 'failed'
|
|
994
|
+
? ['The change did not satisfy the check: read diff (expected vs observed), fix the code, and call verify_task again.']
|
|
995
|
+
: v.status === 'not_checked'
|
|
996
|
+
? ['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.']
|
|
997
|
+
: v.status === 'pending_live_check'
|
|
998
|
+
? ['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).']
|
|
999
|
+
: v.status === 'pending'
|
|
1000
|
+
? ['Waiting for a confirming observation; call get_verification later (or continue with other tasks).']
|
|
1001
|
+
: []),
|
|
1002
|
+
...hint,
|
|
1003
|
+
],
|
|
604
1004
|
data: v,
|
|
605
1005
|
};
|
|
606
1006
|
},
|
|
@@ -608,7 +1008,7 @@ const verifyTask = {
|
|
|
608
1008
|
const answerTask = {
|
|
609
1009
|
name: 'answer_task',
|
|
610
1010
|
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
|
|
1011
|
+
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
1012
|
input: {
|
|
613
1013
|
task: z.string().max(100).describe('Task id (task_…) with status manual.'),
|
|
614
1014
|
answer: z.string().max(40).describe('One of the task.question.options, as the human chose.'),
|
|
@@ -618,25 +1018,31 @@ const answerTask = {
|
|
|
618
1018
|
annotations: { ...write(false), title: 'Answer task' },
|
|
619
1019
|
async run(args, { api }) {
|
|
620
1020
|
const task = await api.request('POST', `/v1/tasks/${enc(String(args.task))}/answer`, { body: pick(args, ['answer', 'note']) });
|
|
1021
|
+
// Round 21 (pilot 20 item 16): "done" on a task Checkbox already closed succeeds without recording anything.
|
|
1022
|
+
if (task.status === 'done' && task.next_action?.type === 'retrieve_plan')
|
|
1023
|
+
return { headline: `${task.id} was already done (Checkbox closed it itself): nothing to answer, nothing recorded.`, data: task };
|
|
621
1024
|
return { headline: `Recorded the answer on ${task.id}; task is now ${task.status}.`, data: task };
|
|
622
1025
|
},
|
|
623
1026
|
};
|
|
624
1027
|
const verifySite = {
|
|
625
1028
|
name: 'verify_site',
|
|
626
1029
|
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.',
|
|
1030
|
+
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
1031
|
input: { site: siteId.optional(), wait },
|
|
629
1032
|
scopes: ['compliance_plans:write'],
|
|
630
1033
|
annotations: { ...write(true, true), title: 'Verify site' },
|
|
631
1034
|
async run(args, { api }) {
|
|
632
1035
|
const site = await resolveSite(api, args.site);
|
|
633
1036
|
const v = await api.request('POST', `/v1/sites/${enc(site)}/verify`, { query: { wait: args.wait ?? 25 }, body: {} });
|
|
1037
|
+
// Pilot 11: say what was re-read from the billing platform (the note's first sentence), so a stale answer is visible.
|
|
1038
|
+
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
1039
|
// A whole-site check that finds open checks produced the to-do list; it is not a failed change.
|
|
635
|
-
const
|
|
1040
|
+
const base = v.next_action?.type === 'verify_domain'
|
|
636
1041
|
? `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
1042
|
: v.status === 'failed'
|
|
638
|
-
? `${v.next_action?.type === 'first_check_complete' ? 'First check complete' : 'Site check complete'} (${v.id}):
|
|
1043
|
+
? `${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
1044
|
: `Site check ${v.id}: ${statusLine(v.status)}${v.plan_status ? `; plan is ${v.plan_status}` : ''}.`;
|
|
1045
|
+
const headline = reread ? `${base} ${reread}` : base;
|
|
640
1046
|
return { headline, data: v };
|
|
641
1047
|
},
|
|
642
1048
|
};
|
|
@@ -644,12 +1050,13 @@ const getVerification = {
|
|
|
644
1050
|
name: 'get_verification',
|
|
645
1051
|
title: 'Get a verification result',
|
|
646
1052
|
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('
|
|
1053
|
+
input: { verification: z.string().max(100).describe('ver_…, or task_… (its newest)'), wait: wait },
|
|
648
1054
|
scopes: ['compliance_plans:read'],
|
|
649
1055
|
annotations: { ...ro, title: 'Verification' },
|
|
650
1056
|
async run(args, { api }) {
|
|
651
1057
|
const v = await api.request('GET', `/v1/verifications/${enc(String(args.verification))}`, { query: { wait: args.wait ?? 0 } });
|
|
652
|
-
|
|
1058
|
+
const notes = precheckLines(v.precheck);
|
|
1059
|
+
return { headline: `Verification ${v.id}: ${statusLine(v.status)}.`, ...(notes.length ? { notes } : {}), data: v };
|
|
653
1060
|
},
|
|
654
1061
|
};
|
|
655
1062
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
@@ -657,7 +1064,7 @@ const getVerification = {
|
|
|
657
1064
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
658
1065
|
/** createTestRun body from the tool's arguments (top-level shortcuts merged into `stripe`). Exported for tests. */
|
|
659
1066
|
export function startTestRunBody(args) {
|
|
660
|
-
const body = pick(args, ['mode', 'type', 'advance_time', 'cancel', 'email_wait_seconds', 'region', 'stripe', 'generic']);
|
|
1067
|
+
const body = pick(args, ['mode', 'type', 'advance_time', 'cancel', 'email_wait_seconds', 'region', 'plan', 'stripe', 'generic']);
|
|
661
1068
|
const shortcuts = pick(args, ['checkout_url', 'test_account', 'checkout_steps', 'cancel_url', 'cancel_steps']);
|
|
662
1069
|
if (Object.keys(shortcuts).length && body.type !== 'generic') {
|
|
663
1070
|
body.stripe = { ...shortcuts, ...(body.stripe ?? {}) };
|
|
@@ -666,13 +1073,41 @@ export function startTestRunBody(args) {
|
|
|
666
1073
|
}
|
|
667
1074
|
return body;
|
|
668
1075
|
}
|
|
1076
|
+
/**
|
|
1077
|
+
* Round 22 (founder decision 2): the agent stores the site's ONE Stripe TEST-mode restricted key itself
|
|
1078
|
+
* (createTestCredential). Live keys and sk_test_ secret keys are refused by the API with what to do instead.
|
|
1079
|
+
*/
|
|
1080
|
+
const storeStripeTestKey = {
|
|
1081
|
+
name: 'store_stripe_test_key',
|
|
1082
|
+
title: 'Store the Stripe TEST restricted key (rk_test_)',
|
|
1083
|
+
description: 'Stores the site\'s ONE Stripe key in TEST mode: a restricted key (rk_test_…) from the project\'s env. Checked with Stripe; on a test-mode site it also connects Stripe (checks, fixes after the human approves, test runs). The human sees it on the dashboard and can revoke it. Live and sk_test_ keys are refused (the error says what to do). Never ask the human to paste a key into the chat.',
|
|
1084
|
+
input: {
|
|
1085
|
+
site: siteId.optional(),
|
|
1086
|
+
secret_key: z.string().min(10).max(255).describe('rk_test_… key. Stored encrypted; never returned.'),
|
|
1087
|
+
confirm_account_mismatch: z.boolean().optional().describe('Human said yes'),
|
|
1088
|
+
type: z.enum(['stripe_test', 'stripe']).optional().describe('Optional: "stripe_test" (the REST body\'s type; "stripe" is accepted too). Always stored as a Stripe TEST key.'),
|
|
1089
|
+
},
|
|
1090
|
+
scopes: ['test_runs:write'],
|
|
1091
|
+
annotations: { ...write(true, true), title: 'Store Stripe test key' },
|
|
1092
|
+
async run(args, { api }) {
|
|
1093
|
+
const site = await resolveSite(api, args.site);
|
|
1094
|
+
const cred = await api.request('POST', `/v1/sites/${enc(site)}/test_credentials`, {
|
|
1095
|
+
body: { type: 'stripe_test', secret_key: String(args.secret_key ?? ''), ...(args.confirm_account_mismatch === true ? { confirm_account_mismatch: true } : {}) },
|
|
1096
|
+
});
|
|
1097
|
+
return {
|
|
1098
|
+
headline: `Stripe test key ${cred.key_hint} stored for ${site} (checked with Stripe). The human sees it on the dashboard and can revoke it.`,
|
|
1099
|
+
data: cred,
|
|
1100
|
+
next: cred.next_action ?? { type: 'continue_setup', message: 'Call get_setup_status for the next step (start_test_run once a checkout config is registered).', operation_id: 'retrieveSetupSession' },
|
|
1101
|
+
};
|
|
1102
|
+
},
|
|
1103
|
+
};
|
|
669
1104
|
const startTestRun = {
|
|
670
1105
|
name: 'start_test_run',
|
|
671
1106
|
title: 'Run a synthetic test subscription (test mode)',
|
|
672
1107
|
description: [
|
|
673
1108
|
'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
|
-
'
|
|
1109
|
+
'Needs a Stripe TEST-mode restricted key (rk_test_…) stored for the site: store it yourself with store_stripe_test_key (one key for checks, fixes and test runs; live keys stay with the human in the dashboard). Never ask the human to paste a key into the chat. If missing, the error\'s next_action says so. For non-Stripe sites use type "generic".',
|
|
1110
|
+
'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
1111
|
'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
1112
|
].join('\n'),
|
|
678
1113
|
input: {
|
|
@@ -683,6 +1118,7 @@ const startTestRun = {
|
|
|
683
1118
|
cancel: z.boolean().optional().describe('Cancel online at the end (default true).'),
|
|
684
1119
|
email_wait_seconds: z.number().int().min(15).max(900).optional().describe('How long to wait for each email (default 120).'),
|
|
685
1120
|
region: z.string().max(10).optional().describe('State the synthetic shopper visits from, e.g. "US-CA".'),
|
|
1121
|
+
plan: z.string().max(200).optional().describe('Plan to run: a registered checkout config name or a price_… its line_items use.'),
|
|
686
1122
|
stripe: z
|
|
687
1123
|
.object({
|
|
688
1124
|
checkout_config: z.string().max(100).optional().describe('Registered checkout config (scc_…) to mirror; default the newest, else the latest live Checkout Session; "none" skips registered configs.'),
|
|
@@ -727,8 +1163,12 @@ const startTestRun = {
|
|
|
727
1163
|
const getTestRun = {
|
|
728
1164
|
name: 'get_test_run',
|
|
729
1165
|
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: {
|
|
1166
|
+
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.',
|
|
1167
|
+
input: {
|
|
1168
|
+
test_run: z.string().max(100).optional().describe('Test run id (run_…). Default: the newest run of the site.'),
|
|
1169
|
+
site: siteId.optional(),
|
|
1170
|
+
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.'),
|
|
1171
|
+
},
|
|
732
1172
|
scopes: ['test_runs:read'],
|
|
733
1173
|
annotations: { ...ro, title: 'Test run' },
|
|
734
1174
|
async run(args, { api }) {
|
|
@@ -740,10 +1180,14 @@ const getTestRun = {
|
|
|
740
1180
|
throw new ToolError(`Site ${site} has no test run yet.`, { type: 'start_test_run', message: 'Call start_test_run.', operation_id: 'createTestRun' });
|
|
741
1181
|
id = list.data[0].id;
|
|
742
1182
|
}
|
|
743
|
-
const
|
|
1183
|
+
const wait = typeof args.wait === 'number' && args.wait > 0 ? Math.min(30, Math.floor(args.wait)) : undefined;
|
|
1184
|
+
const run = await api.request('GET', `/v1/test_runs/${enc(id)}`, wait ? { query: { wait } } : undefined);
|
|
1185
|
+
const w = run.waiting_for;
|
|
1186
|
+
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(', ') : '';
|
|
1187
|
+
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
1188
|
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,
|
|
1189
|
+
headline: `Test run ${run.id}: ${run.status_label ?? statusLine(run.status)}${run.phase && run.status !== 'succeeded' && run.status !== 'failed' ? ` (phase ${run.phase})` : ''}.`,
|
|
1190
|
+
notes: [...(waiting ? [waiting] : []), ...(run.steps ?? []).map((s) => `${s.status ?? '?'}: ${s.title ?? s.key}${s.code ? ` (${s.code})` : ''}`)].slice(0, 13),
|
|
747
1191
|
data: run,
|
|
748
1192
|
};
|
|
749
1193
|
},
|
|
@@ -755,18 +1199,19 @@ const checkDisclosure = {
|
|
|
755
1199
|
name: 'check_disclosure',
|
|
756
1200
|
title: 'Check checkout disclosure text',
|
|
757
1201
|
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
|
|
1202
|
+
'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
1203
|
'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,
|
|
1204
|
+
'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
1205
|
].join('\n'),
|
|
762
1206
|
input: {
|
|
763
1207
|
text: z.string().min(1).max(5000).describe('The disclosure shown next to the purchase button, exactly as rendered.'),
|
|
764
1208
|
...planTermsShape,
|
|
765
|
-
interval: intervalStr,
|
|
1209
|
+
interval: intervalStr.optional().describe('Billing interval: day, week, month or year (optional with site).'),
|
|
766
1210
|
button_text: z.string().max(200).optional().describe('Purchase button label, e.g. "Start free trial".'),
|
|
767
1211
|
order_summary: z.string().max(1000).optional().describe('Other text next to the button (order summary), if any.'),
|
|
768
|
-
|
|
769
|
-
|
|
1212
|
+
site: z.string().max(100).optional().describe('site_… whose plans judge it (as verify_task)'),
|
|
1213
|
+
plan: z.string().max(200).optional().describe('With site: plan name or price_…'),
|
|
1214
|
+
placement: strictObject({
|
|
770
1215
|
above_button: z.boolean().optional(),
|
|
771
1216
|
same_page_as_payment: z.boolean().optional(),
|
|
772
1217
|
font_size_px: z.number().min(4).max(96).optional(),
|
|
@@ -774,16 +1219,15 @@ const checkDisclosure = {
|
|
|
774
1219
|
bold: z.boolean().optional(),
|
|
775
1220
|
boxed: z.boolean().optional(),
|
|
776
1221
|
body_font_size_px: z.number().min(4).max(96).optional(),
|
|
777
|
-
})
|
|
1222
|
+
}, 'placement')
|
|
778
1223
|
.optional()
|
|
779
1224
|
.describe('Where and how the disclosure is shown (defaults: above the button, same page, 14px, high contrast).'),
|
|
780
|
-
consent:
|
|
781
|
-
.object({
|
|
1225
|
+
consent: strictObject({
|
|
782
1226
|
checkbox: z.boolean(),
|
|
783
1227
|
prechecked: z.boolean().optional(),
|
|
784
1228
|
label: z.string().max(1000).optional(),
|
|
785
1229
|
required: z.boolean().optional(),
|
|
786
|
-
})
|
|
1230
|
+
}, 'consent')
|
|
787
1231
|
.optional()
|
|
788
1232
|
.describe('The consent checkbox, if the page has one. Omit when there is none.'),
|
|
789
1233
|
jurisdictions: jurisdictions.optional(),
|
|
@@ -793,7 +1237,8 @@ const checkDisclosure = {
|
|
|
793
1237
|
annotations: { ...ro, title: 'Check disclosure' },
|
|
794
1238
|
async run(args, { api }) {
|
|
795
1239
|
const r = await api.request('POST', '/v1/checks/disclosure', { body: args });
|
|
796
|
-
|
|
1240
|
+
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;
|
|
1241
|
+
return { headline: verdictHeadline('Disclosure', r), notes: checkNotes(r, 8, covered), data: withoutUnneededFix(slimCheckResult(r)) };
|
|
797
1242
|
},
|
|
798
1243
|
};
|
|
799
1244
|
const checkStripeCheckoutSession = {
|
|
@@ -801,12 +1246,14 @@ const checkStripeCheckoutSession = {
|
|
|
801
1246
|
title: 'Check Stripe Checkout Session params (returns corrected params)',
|
|
802
1247
|
description: [
|
|
803
1248
|
'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:
|
|
1249
|
+
'With a Price id (price_…) in line_items, pass site (its plans are used) or merchant { price, interval, trial_days, merchant_name, cancel_url }.',
|
|
1250
|
+
'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
1251
|
].join('\n'),
|
|
807
1252
|
input: {
|
|
808
1253
|
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:
|
|
1254
|
+
merchant: merchantTerms.optional().describe('What the params do not say: merchant_name, cancel_url, and price/interval when line_items use a Price id.'),
|
|
1255
|
+
site: z.string().max(100).optional().describe('site_… whose plans judge it (as verify_task)'),
|
|
1256
|
+
plan: z.string().max(200).optional().describe('With site: plan name or price_…'),
|
|
810
1257
|
jurisdictions: jurisdictions.optional(),
|
|
811
1258
|
as_of: asOf.optional(),
|
|
812
1259
|
},
|
|
@@ -814,12 +1261,14 @@ const checkStripeCheckoutSession = {
|
|
|
814
1261
|
annotations: { ...ro, title: 'Check Stripe Checkout Session' },
|
|
815
1262
|
async run(args, { api }) {
|
|
816
1263
|
const r = await api.request('POST', '/v1/checks/stripe_checkout_session', { body: args });
|
|
817
|
-
const
|
|
818
|
-
|
|
1264
|
+
const fixing = !!r.correction?.patch?.length;
|
|
1265
|
+
const notes = checkNotes(r, 8, fixing && r.correction.verification?.passes ? { by: 'correction.corrected_params' } : undefined);
|
|
1266
|
+
if (fixing)
|
|
819
1267
|
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
|
-
|
|
1268
|
+
// Agent audit round 2: on a pass the same Stripe-styling note was printed four times; it is printed once.
|
|
1269
|
+
if (fixing && r.correction?.verification?.note)
|
|
821
1270
|
notes.push(r.correction.verification.note);
|
|
822
|
-
const { correction, ...rest } = slimCheckResult(r);
|
|
1271
|
+
const { correction, ...rest } = withoutUnneededFix(slimCheckResult(r));
|
|
823
1272
|
return { headline: verdictHeadline('Stripe Checkout Session', r), notes, data: { correction, ...rest } };
|
|
824
1273
|
},
|
|
825
1274
|
};
|
|
@@ -827,32 +1276,35 @@ const checkEmail = {
|
|
|
827
1276
|
name: 'check_email',
|
|
828
1277
|
title: 'Check a subscription lifecycle email',
|
|
829
1278
|
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
|
|
1279
|
+
'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
1280
|
'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
1281
|
'For WHEN each email must be sent, use get_notice_schedule.',
|
|
833
1282
|
].join('\n'),
|
|
834
1283
|
input: {
|
|
835
|
-
kind:
|
|
1284
|
+
kind: emailKind.describe('Which email (trial_reminder = trial_ending).'),
|
|
836
1285
|
subject: z.string().min(1).max(1000).describe('Subject line.'),
|
|
837
1286
|
text: z.string().max(100_000).optional().describe('Plain-text body (give text, html or both).'),
|
|
838
1287
|
html: z.string().max(100_000).optional().describe('HTML body.'),
|
|
839
|
-
terms:
|
|
840
|
-
|
|
1288
|
+
terms: strictObject({
|
|
1289
|
+
...planTermsShape,
|
|
841
1290
|
previous_price: z.union([z.number().positive(), z.string().max(40)]).optional().describe('price_change: price before the change, major units.'),
|
|
842
1291
|
previous_amount: z.number().int().positive().optional(),
|
|
843
1292
|
support_contact: z.string().max(200).optional().describe('Support email / URL for the template.'),
|
|
844
|
-
})
|
|
845
|
-
.
|
|
1293
|
+
}, 'terms')
|
|
1294
|
+
.optional()
|
|
1295
|
+
.describe('price, interval, cancel_url (optional with site); price_change: NEW price'),
|
|
1296
|
+
site: z.string().max(100).optional().describe('site_… whose Stripe prices judge it (default: your Stripe site).'),
|
|
1297
|
+
plan: z.string().max(200).optional().describe('Plan name or price_… (default: the one named)'),
|
|
846
1298
|
jurisdictions: jurisdictions.optional(),
|
|
847
1299
|
as_of: asOf.optional(),
|
|
848
1300
|
},
|
|
849
1301
|
scopes: ['checks:read'],
|
|
850
1302
|
annotations: { ...ro, title: 'Check email' },
|
|
851
1303
|
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) };
|
|
1304
|
+
const r = await api.request('POST', '/v1/checks/email', { body: { ...args, kind: checkKind(args.kind) } });
|
|
1305
|
+
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);
|
|
1306
|
+
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).');
|
|
1307
|
+
return { headline: verdictHeadline(`Email (${String(args.kind)}${r.plan ? `, plan ${untrusted(r.plan, 60)}` : ''})`, r), notes, data: slimCheckResult(r) };
|
|
856
1308
|
},
|
|
857
1309
|
};
|
|
858
1310
|
const checkPageHtml = {
|
|
@@ -867,7 +1319,10 @@ const checkPageHtml = {
|
|
|
867
1319
|
html: z.string().max(1_000_000).optional().describe('Rendered checkout page HTML. Give html or url.'),
|
|
868
1320
|
url: z.string().max(2048).optional().describe('Live public page URL to fetch instead (live, activated accounts).'),
|
|
869
1321
|
page_kind: z.enum(['checkout', 'pricing']).optional().describe('Default checkout.'),
|
|
870
|
-
terms: planTerms.optional().describe('The plan sold on the page
|
|
1322
|
+
terms: planTerms.optional().describe('The plan sold on the page. merchant_name / cancel_url alone are fine (no price compared).'),
|
|
1323
|
+
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.'),
|
|
1324
|
+
checkout_type: z.enum(['own_page', 'stripe_checkout', 'payment_link']).optional().describe('Where shoppers pay (default: inferred).'),
|
|
1325
|
+
site: z.string().max(100).optional().describe("Your site_…: judge it as verify_task does (the site's Stripe-checked plans)."),
|
|
871
1326
|
jurisdictions: jurisdictions.optional(),
|
|
872
1327
|
as_of: asOf.optional(),
|
|
873
1328
|
},
|
|
@@ -878,25 +1333,74 @@ const checkPageHtml = {
|
|
|
878
1333
|
throw new ToolError('Pass html (the rendered page) or url.');
|
|
879
1334
|
const r = await api.request('POST', '/v1/checks/page', { body: args });
|
|
880
1335
|
const notes = checkNotes(r);
|
|
1336
|
+
// Round 17 (python-flask): on a Payment Link / Stripe Checkout page, replacement.html is the terms only.
|
|
1337
|
+
if (r.replacement?.consent_on && r.replacement.note && r.verdict !== 'pass')
|
|
1338
|
+
notes.push(`Replacement: ${r.replacement.note}`);
|
|
881
1339
|
if (r.extraction?.purchase_buttons) {
|
|
882
1340
|
const b = r.extraction.purchase_buttons;
|
|
883
1341
|
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
1342
|
}
|
|
885
|
-
return { headline: verdictHeadline('Page', r), notes, data: slimCheckResult(r) };
|
|
1343
|
+
return { headline: verdictHeadline('Page', r), notes, data: withoutUnneededFix(slimCheckResult(r)) };
|
|
886
1344
|
},
|
|
887
1345
|
};
|
|
1346
|
+
const DAY_S = 86_400;
|
|
1347
|
+
/**
|
|
1348
|
+
* Agent audit round 2: "2026-11-14: trial_ending (5 days before; US-UT)" said neither before what nor the allowed window,
|
|
1349
|
+
* which is what a cron job needs; the window was only in epoch seconds. The line now states both relative to the event.
|
|
1350
|
+
*/
|
|
1351
|
+
export function noticeScheduleLine(n) {
|
|
1352
|
+
const anchor = n.anchor_date ? Date.parse(`${n.anchor_date}T00:00:00Z`) / 1000 : undefined;
|
|
1353
|
+
const event = n.occasion ? n.occasion.replace(/\s*\(.*\)$/, '').toLowerCase() : 'the event';
|
|
1354
|
+
if (n.days_before === 0 || anchor === undefined || n.window_opens_at === undefined || n.window_closes_at === undefined) {
|
|
1355
|
+
return `${n.send_date}: ${n.kind} — ${n.days_before === 0 ? `at ${event} (same day)` : `${n.days_before} days before ${event}`} (${n.jurisdictions.join(', ')})`;
|
|
1356
|
+
}
|
|
1357
|
+
const most = Math.round((anchor - n.window_opens_at) / DAY_S);
|
|
1358
|
+
const least = Math.round((anchor - n.window_closes_at) / DAY_S);
|
|
1359
|
+
const window = least >= 0 && most >= least ? `; allowed ${least === most ? `exactly ${least}` : `${least}–${most}`} days before` : '';
|
|
1360
|
+
return `${n.send_date}: ${n.kind} — ${n.days_before} days before ${event} (${n.anchor_date})${window} (${n.jurisdictions.join(', ')})`;
|
|
1361
|
+
}
|
|
1362
|
+
/** One line per notice; a kind sent every charge shows its first date and how many follow. */
|
|
1363
|
+
export function noticeScheduleLines(notices) {
|
|
1364
|
+
const out = [];
|
|
1365
|
+
const seen = new Map();
|
|
1366
|
+
for (const n of notices)
|
|
1367
|
+
seen.set(n.kind, (seen.get(n.kind) ?? 0) + 1);
|
|
1368
|
+
const shown = new Set();
|
|
1369
|
+
for (const n of notices) {
|
|
1370
|
+
const count = seen.get(n.kind);
|
|
1371
|
+
if (count > 2) {
|
|
1372
|
+
if (shown.has(n.kind))
|
|
1373
|
+
continue;
|
|
1374
|
+
shown.add(n.kind);
|
|
1375
|
+
out.push(`${noticeScheduleLine(n)} — then the same before each of the next ${count - 1} charges (all in notices[])`);
|
|
1376
|
+
}
|
|
1377
|
+
else
|
|
1378
|
+
out.push(noticeScheduleLine(n));
|
|
1379
|
+
}
|
|
1380
|
+
return out.slice(0, 12);
|
|
1381
|
+
}
|
|
1382
|
+
/** Notice kinds of the schedule → the compliance plan's email task for it (same names as get_compliance_plan). */
|
|
1383
|
+
export const NOTICE_KIND_TASKS = {
|
|
1384
|
+
acknowledgment: { name: 'acknowledgment email', task: 'acknowledgment_email' },
|
|
1385
|
+
trial_ending: { name: 'trial-ending reminder', task: 'trial_reminder' },
|
|
1386
|
+
renewal_reminder: { name: 'renewal reminder', task: 'renewal_reminder' },
|
|
1387
|
+
annual_terms: { name: 'yearly terms reminder', task: 'annual_terms_notice' },
|
|
1388
|
+
charge_notice: { name: 'charge notice', task: 'charge_notice' },
|
|
1389
|
+
price_change: { name: 'price-change notice', task: 'price_change_notice' },
|
|
1390
|
+
};
|
|
888
1391
|
const getNoticeSchedule = {
|
|
889
1392
|
name: 'get_notice_schedule',
|
|
890
1393
|
title: 'Get the dates each required notice must be sent',
|
|
891
1394
|
description: [
|
|
892
|
-
'For a plan (price, interval, trial) and a sign-up date: every legally required notice (acknowledgment, trial-ending reminder, renewal reminder,
|
|
1395
|
+
'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
1396
|
'Use it to implement reminder emails / cron jobs correctly. Example: {"terms":{"price":99,"interval":"year","trial_days":30},"start_date":"2026-11-01"}.',
|
|
894
1397
|
].join('\n'),
|
|
895
1398
|
input: {
|
|
896
|
-
terms: planTerms.describe('The plan: price (or amount), interval, interval_count, trial_days.'),
|
|
1399
|
+
terms: planTerms.optional().describe('The plan: price (or amount), interval, interval_count, trial_days (or site + plan).'),
|
|
1400
|
+
site: z.string().max(100).optional().describe('site_… (its plans, jurisdictions)'),
|
|
1401
|
+
plan: z.string().max(200).optional().describe('With site: plan name or price_…'),
|
|
897
1402
|
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() })
|
|
1403
|
+
price_change: strictObject({ effective_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), new_price: z.number().positive().optional() }, 'price_change')
|
|
900
1404
|
.optional()
|
|
901
1405
|
.describe('A planned price change to schedule the notice for.'),
|
|
902
1406
|
cycles: z.number().int().min(1).max(24).optional().describe('How many renewals to plan (default one year).'),
|
|
@@ -908,11 +1412,19 @@ const getNoticeSchedule = {
|
|
|
908
1412
|
annotations: { ...ro, title: 'Notice schedule' },
|
|
909
1413
|
async run(args, { api }) {
|
|
910
1414
|
const r = await api.request('POST', '/v1/checks/notice_schedule', { body: args });
|
|
1415
|
+
const singleDay = [...new Set(r.notices.flatMap((n) => (n.timing_note ? [`${n.kind}: ${n.timing_note}`] : [])))].slice(0, 3);
|
|
1416
|
+
const kinds = [...new Set(r.notices.map((n) => n.kind))];
|
|
911
1417
|
return {
|
|
912
|
-
|
|
1418
|
+
// Polish round: one email per kind, named like the compliance plan's email tasks (the per-send dates follow).
|
|
1419
|
+
headline: kinds.length
|
|
1420
|
+
? `${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.`
|
|
1421
|
+
: 'No required notices for this plan under these jurisdictions.',
|
|
913
1422
|
notes: [
|
|
914
|
-
...r.notices
|
|
1423
|
+
...noticeScheduleLines(r.notices),
|
|
1424
|
+
...singleDay,
|
|
1425
|
+
...(r.rounding_rule ? [`Timing rule: ${r.rounding_rule}`] : []),
|
|
915
1426
|
...(r.not_required ?? []).map((n) => `Not required: ${n.kind} — ${n.reason}`),
|
|
1427
|
+
...(r.warnings ?? []).filter((w) => typeof w?.message === 'string').slice(0, 3).map((w) => w.message),
|
|
916
1428
|
],
|
|
917
1429
|
data: r,
|
|
918
1430
|
};
|
|
@@ -930,7 +1442,7 @@ const SUGGESTION_SURFACE_NAMES = [
|
|
|
930
1442
|
];
|
|
931
1443
|
const suggestImprovements = {
|
|
932
1444
|
name: 'suggest_improvements',
|
|
933
|
-
title: 'Suggest
|
|
1445
|
+
title: 'Suggest conversion improvements that keep the checks passing',
|
|
934
1446
|
description: [
|
|
935
1447
|
'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
1448
|
'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 +1452,7 @@ const suggestImprovements = {
|
|
|
940
1452
|
input: {
|
|
941
1453
|
site: siteId.optional(),
|
|
942
1454
|
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(
|
|
1455
|
+
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
1456
|
disclosure: z
|
|
945
1457
|
.object({
|
|
946
1458
|
text: z.string().min(1).max(5000).describe('Disclosure next to the purchase button, as rendered.'),
|
|
@@ -1001,7 +1513,10 @@ const suggestImprovements = {
|
|
|
1001
1513
|
r.suggestions.slice(0, 15).forEach((s, i) => {
|
|
1002
1514
|
notes.push([
|
|
1003
1515
|
`${i + 1}. [${s.surface} · ${s.match}] ${s.title} (${s.key})`,
|
|
1004
|
-
|
|
1516
|
+
// "From your copy" only for the merchant's own text; anything else is an example (never present it as theirs).
|
|
1517
|
+
s.before.source === 'your_copy' || (!s.before.source && s.match === 'detected')
|
|
1518
|
+
? ` before, from your copy (${s.before.label}): ${untrusted(s.before.text, 160)}`
|
|
1519
|
+
: ` before, example (${s.before.label}): ${JSON.stringify(s.before.text.slice(0, 160))}`,
|
|
1005
1520
|
` 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
1521
|
` how: ${s.apply.instructions}`,
|
|
1007
1522
|
...(s.preconditions.length ? [` only if true: ${s.preconditions.join(' ')}`] : []),
|
|
@@ -1014,7 +1529,7 @@ const suggestImprovements = {
|
|
|
1014
1529
|
notes.push(`…and ${r.suggestions.length - 15} more in data.suggestions.`);
|
|
1015
1530
|
const held = r.surfaces.filter((s) => s.held_by.length);
|
|
1016
1531
|
if (held.length)
|
|
1017
|
-
notes.push(`Held (fix first with get_compliance_plan): ${held.map((s) => `${s.surface} (${s.held_label ? `${s.held_label
|
|
1532
|
+
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
1533
|
if (r.withheld.length)
|
|
1019
1534
|
notes.push(`Withheld: ${r.withheld.slice(0, 8).map((w) => `${w.key}: ${w.reason}`).join(' | ')}`);
|
|
1020
1535
|
return { headline: `Suggestions for ${where}: ${r.summary}`, notes, data: r };
|
|
@@ -1023,10 +1538,25 @@ const suggestImprovements = {
|
|
|
1023
1538
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
1024
1539
|
// Sites, findings, scans, law updates
|
|
1025
1540
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
1541
|
+
/** One site in list_sites: what an agent needs to pick it. */
|
|
1542
|
+
export function siteListItem(s) {
|
|
1543
|
+
const x = s;
|
|
1544
|
+
return {
|
|
1545
|
+
id: s.id,
|
|
1546
|
+
object: 'site',
|
|
1547
|
+
name: s.name ?? null,
|
|
1548
|
+
url: s.url,
|
|
1549
|
+
...(x.platform ? { platform: x.platform } : {}),
|
|
1550
|
+
...(x.checkout?.url ? { checkout_url: x.checkout.url } : {}),
|
|
1551
|
+
status: plainLabel(s.compliance?.label ?? 'Not checked yet'),
|
|
1552
|
+
domain_verified: x.monitoring?.held_for_domain_verification !== true,
|
|
1553
|
+
...(x.archived ? { archived: true } : {}),
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1026
1556
|
const listSites = {
|
|
1027
1557
|
name: 'list_sites',
|
|
1028
1558
|
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
|
|
1559
|
+
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
1560
|
input: { limit: listLimit, starting_after: startingAfter, archived: z.boolean().optional().describe('true lists only archived sites.') },
|
|
1031
1561
|
scopes: ['sites:read'],
|
|
1032
1562
|
annotations: { ...ro, title: 'List sites' },
|
|
@@ -1036,9 +1566,14 @@ const listSites = {
|
|
|
1036
1566
|
});
|
|
1037
1567
|
return {
|
|
1038
1568
|
headline: `${list.data.length}${list.has_more ? '+' : ''} site(s) in ${key.mode === 'unknown' ? 'this' : key.mode} mode.`,
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1569
|
+
// Audit P2: name + URL per site, and a Next line either way.
|
|
1570
|
+
notes: list.data.map((s) => `${s.id} ${s.name ? `${untrusted(s.name, 60)} ` : ''}${untrusted(s.url, 100)} — ${plainLabel(s.compliance?.label ?? 'Not checked yet')}`),
|
|
1571
|
+
// Agent audit round 2: each site carried install details, webhook URLs, retention and legal hold (~1.5 KB) when
|
|
1572
|
+
// an agent needs the id, URL and status; get_site_status / the API have the rest.
|
|
1573
|
+
data: { ...list, data: list.data.map(siteListItem) },
|
|
1574
|
+
next: list.data.length
|
|
1575
|
+
? { 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' }
|
|
1576
|
+
: { type: 'setup', message: 'No sites yet: call setup_checkbox with the site URL.', operation_id: 'createSetupSession' },
|
|
1042
1577
|
};
|
|
1043
1578
|
},
|
|
1044
1579
|
};
|
|
@@ -1090,10 +1625,14 @@ const updateSite = {
|
|
|
1090
1625
|
name: z.string().max(200).optional().describe('Merchant / brand name (used in generated disclosures).'),
|
|
1091
1626
|
checkout_url: z.string().max(2048).optional().describe('Page with the Subscribe / Buy button.'),
|
|
1092
1627
|
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
|
-
|
|
1628
|
+
test_account: strictObject({
|
|
1629
|
+
login_url: z.string().max(2048),
|
|
1630
|
+
email: z.string().max(320),
|
|
1631
|
+
password: z.string().max(200),
|
|
1632
|
+
reactivate_url: z.string().max(2048).optional().describe('Test-only page that re-subscribes the test account.'),
|
|
1633
|
+
}, 'test_account')
|
|
1095
1634
|
.optional()
|
|
1096
|
-
.describe('A TEST subscriber account the scanner logs in with to try cancelling online.'),
|
|
1635
|
+
.describe('A TEST subscriber account the scanner logs in with to try cancelling online (echoed back without the password).'),
|
|
1097
1636
|
platform: z.enum(['stripe', 'shopify', 'recharge', 'chargebee', 'recurly', 'paddle', 'woocommerce', 'custom']).optional(),
|
|
1098
1637
|
jurisdictions: z.array(z.string().max(10)).max(60).optional(),
|
|
1099
1638
|
monitoring_interval_minutes: z.number().int().min(0).optional(),
|
|
@@ -1114,54 +1653,95 @@ const updateSite = {
|
|
|
1114
1653
|
};
|
|
1115
1654
|
},
|
|
1116
1655
|
};
|
|
1656
|
+
/** The site's Shopify connection in one line, naming the tool (or the human step) that comes next. */
|
|
1657
|
+
export function shopifyLines(sh, site) {
|
|
1658
|
+
if (!sh)
|
|
1659
|
+
return [];
|
|
1660
|
+
if (sh.connection === 'app') {
|
|
1661
|
+
return sh.terms_published
|
|
1662
|
+
? [`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.`]
|
|
1663
|
+
: [`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'}).`];
|
|
1664
|
+
}
|
|
1665
|
+
if (sh.connection === 'own_app')
|
|
1666
|
+
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.`];
|
|
1667
|
+
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).'];
|
|
1668
|
+
}
|
|
1117
1669
|
const getSiteStatus = {
|
|
1118
1670
|
name: 'get_site_status',
|
|
1119
1671
|
title: 'Get a site\'s compliance status',
|
|
1120
|
-
description: 'Where a site stands:
|
|
1672
|
+
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
1673
|
input: { site: siteId.optional() },
|
|
1122
1674
|
scopes: ['sites:read'],
|
|
1123
1675
|
annotations: { ...ro, title: 'Site status' },
|
|
1124
1676
|
async run(args, { api }) {
|
|
1125
1677
|
const id = await resolveSite(api, args.site);
|
|
1126
|
-
const [site, evaluation,
|
|
1678
|
+
const [site, evaluation, plan] = await Promise.all([
|
|
1127
1679
|
api.request('GET', `/v1/sites/${enc(id)}`),
|
|
1128
1680
|
optional(api.request('GET', `/v1/sites/${enc(id)}/evaluation`)),
|
|
1129
|
-
optional(api.request('GET',
|
|
1681
|
+
optional(api.request('GET', `/v1/sites/${enc(id)}/plan`)),
|
|
1130
1682
|
]);
|
|
1131
1683
|
// Round-4 pilot (nextjs): "nothing left to do" while 13 critical/high checks were needs_review. Count them too.
|
|
1132
1684
|
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 : [];
|
|
1685
|
+
const evaluated = 'id' in evaluation;
|
|
1135
1686
|
const toReview = 'data' in review ? review.data : [];
|
|
1136
1687
|
const seriousReview = toReview.filter((f) => f.severity === 'critical' || f.severity === 'high');
|
|
1137
|
-
const
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
];
|
|
1142
|
-
const { checks: _omit, ...evalLite } = (evaluated ? evaluation : {});
|
|
1688
|
+
const blocking = evaluated ? (evaluation.blockers ?? []).map((b) => b.check_id) : [];
|
|
1689
|
+
// One to-do list: the plan's ordered open tasks, never a list built from findings.
|
|
1690
|
+
const todo = 'tasks' in plan && Array.isArray(plan.tasks) ? planTodo(plan.tasks) : [];
|
|
1691
|
+
const { checks: _omit, score: _score, insurable: _insurable, ...evalLite } = (evaluated ? evaluation : {});
|
|
1143
1692
|
// Status labels: the roll-up decides the headline; pending live checks count as compliant and say how they complete.
|
|
1144
1693
|
const roll = site.compliance?.rollup;
|
|
1145
1694
|
const pendingLive = roll?.pending_live_checks ?? [];
|
|
1146
1695
|
const rollStatus = site.compliance?.status;
|
|
1147
1696
|
const label = site.compliance?.label;
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1697
|
+
const ro = unverifiedScanNote(site.unverified_scan);
|
|
1698
|
+
const notes = [
|
|
1699
|
+
...(todo.length ? [`To do (${todo.length}, from the compliance plan, in order):`, ...todo.map((t, i) => `${i + 1}. ${t.id} ${todoLine(t)}`)] : []),
|
|
1700
|
+
// Round 25 (pilot 24 item 22: "4 live checks pending" listed 3): every pending live check the label counts.
|
|
1701
|
+
...(evaluated && label ? pendingLive.map((p) => `LIVE CHECK PENDING ${p.check_id}: ${p.what} — ${p.completes}`) : []),
|
|
1702
|
+
...(ro ? [ro] : []),
|
|
1703
|
+
...shopifyLines(site.shopify, site.id),
|
|
1704
|
+
];
|
|
1705
|
+
const { open_findings: _open, needs_review: _nr, ...complianceLite } = (site.compliance ?? {});
|
|
1151
1706
|
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.`,
|
|
1707
|
+
headline: evaluated ? `${site.url}: ${plainLabel(label ?? 'Not checked yet')}.` : `${site.url}: Not checked yet.`,
|
|
1157
1708
|
notes,
|
|
1158
|
-
data: { site
|
|
1709
|
+
data: { site: site.compliance ? { ...site, compliance: complianceLite } : site, evaluation: evaluated ? evalLite : null, todo },
|
|
1159
1710
|
next: evaluated
|
|
1160
|
-
? rollStatus === 'needs_fix' || (
|
|
1161
|
-
? { type: 'fix', message:
|
|
1162
|
-
: rollStatus === '
|
|
1163
|
-
? { type: '
|
|
1164
|
-
:
|
|
1711
|
+
? todo.length && (rollStatus === 'needs_fix' || todo.some((t) => t.status === 'needs_fix' || t.status === 'not_set_up'))
|
|
1712
|
+
? { 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' }
|
|
1713
|
+
: rollStatus === 'needs_fix'
|
|
1714
|
+
? { type: 'fix', message: 'Call get_compliance_plan for the ordered changes.', operation_id: 'retrieveCompliancePlan' }
|
|
1715
|
+
: rollStatus === 'your_call' || todo.some((t) => t.status === 'your_call')
|
|
1716
|
+
? { 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' }
|
|
1717
|
+
: rollStatus === 'compliant' && !todo.length
|
|
1718
|
+
? // Round 15 (pilot 14, change-guard: "Checks pass · 3 live checks pending" next to "the site is not shown
|
|
1719
|
+
// as passing yet"): one status, one sentence. When the roll-up says Checks pass, next_action says the
|
|
1720
|
+
// same; the live checks are named as what completes it, never as a reason it does not pass.
|
|
1721
|
+
pendingLive.length
|
|
1722
|
+
? {
|
|
1723
|
+
type: 'complete_live_checks',
|
|
1724
|
+
message: `${plainLabel(label ?? 'Checks pass')}: nothing needs a code change. ${pendingLive.length} live check(s) complete on their own (${pendingLive
|
|
1725
|
+
.slice(0, 3)
|
|
1726
|
+
.map((p) => p.what)
|
|
1727
|
+
.join('; ')}); get_compliance_plan says how each one completes (a proof run, BCC real sends, a test account).`,
|
|
1728
|
+
operation_id: 'retrieveCompliancePlan',
|
|
1729
|
+
}
|
|
1730
|
+
: { type: 'none', message: `${plainLabel(label ?? 'Checks pass')}: every check that applies passes; nothing left to do. Keep monitoring on.` }
|
|
1731
|
+
: blocking.length || pendingLive.length || seriousReview.length || todo.length
|
|
1732
|
+
? // Normal round 4 (nextjs): "nothing left to do — Compliant" next to 2 blocking checks. Never call a site
|
|
1733
|
+
// compliant while a blocking check, a pending live check or an open plan task remains.
|
|
1734
|
+
{
|
|
1735
|
+
type: 'complete_live_checks',
|
|
1736
|
+
message: `Nothing needs a code change now, but the site is not shown as passing yet (${plainLabel(label ?? 'Not checked yet')}): ${[
|
|
1737
|
+
todo.length ? `${todo.length} task(s) on the plan (${todo.slice(0, 3).map((t) => t.title).join('; ')})` : '',
|
|
1738
|
+
blocking.length ? `blocking check(s) ${blocking.slice(0, 4).join(', ')}` : '',
|
|
1739
|
+
pendingLive.length ? `${pendingLive.length} live check(s) pending` : '',
|
|
1740
|
+
seriousReview.length && !todo.length ? `${seriousReview.length} check(s) to review` : '',
|
|
1741
|
+
].filter(Boolean).join(', ')}. Call get_compliance_plan for how each one completes (a proof run, BCC real sends, a test account).`,
|
|
1742
|
+
operation_id: 'retrieveCompliancePlan',
|
|
1743
|
+
}
|
|
1744
|
+
: { type: 'none', message: 'Every check that applies has passed; nothing left to do. Keep monitoring on.' }
|
|
1165
1745
|
: { type: 'run_first_check', message: 'Call verify_site (or run_scan) for the first verdict.', operation_id: 'verifySite' },
|
|
1166
1746
|
};
|
|
1167
1747
|
},
|
|
@@ -1169,10 +1749,10 @@ const getSiteStatus = {
|
|
|
1169
1749
|
const listFindings = {
|
|
1170
1750
|
name: 'list_findings',
|
|
1171
1751
|
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
|
|
1752
|
+
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
1753
|
input: {
|
|
1174
1754
|
site: z.string().max(100).optional().describe('Only this site (site_…).'),
|
|
1175
|
-
status: z.enum(['pass', 'fail', 'needs_review', 'not_applicable']).optional().describe('Default
|
|
1755
|
+
status: z.enum(['pass', 'fail', 'needs_review', 'not_applicable']).optional().describe('Default: all statuses.'),
|
|
1176
1756
|
state: z.enum(['open', 'acknowledged', 'resolved', 'accepted_risk']).optional().describe('Default open.'),
|
|
1177
1757
|
limit: listLimit,
|
|
1178
1758
|
starting_after: startingAfter,
|
|
@@ -1183,7 +1763,8 @@ const listFindings = {
|
|
|
1183
1763
|
const list = await api.request('GET', '/v1/findings', {
|
|
1184
1764
|
query: {
|
|
1185
1765
|
site: args.site,
|
|
1186
|
-
|
|
1766
|
+
// Normal round 3: with no filter, every open finding (it returned "0 finding(s)" while 13 were active).
|
|
1767
|
+
status: args.status,
|
|
1187
1768
|
state: args.state ?? 'open',
|
|
1188
1769
|
limit: args.limit,
|
|
1189
1770
|
starting_after: args.starting_after,
|
|
@@ -1222,6 +1803,36 @@ const getFinding = {
|
|
|
1222
1803
|
};
|
|
1223
1804
|
},
|
|
1224
1805
|
};
|
|
1806
|
+
const publishShopifyTerms = {
|
|
1807
|
+
name: 'publish_shopify_terms',
|
|
1808
|
+
title: 'Publish renewal terms to the Shopify app block (write)',
|
|
1809
|
+
description: [
|
|
1810
|
+
'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.',
|
|
1811
|
+
'The merchant still places the "Renewal terms + consent" block once: give them theme_editor_url from the result. Then verify_site.',
|
|
1812
|
+
'shopify_not_connected: only the merchant can install the app in Shopify and click "Connect to Checkbox". terms_incomplete: follow next_action.',
|
|
1813
|
+
].join('\n'),
|
|
1814
|
+
input: {
|
|
1815
|
+
site: requiredSite,
|
|
1816
|
+
cancel_url: z.string().max(2048).optional().describe('Cancellation URL to state (default: detected).'),
|
|
1817
|
+
plan: z.string().max(200).optional().describe('Plan / price id (default: detected).'),
|
|
1818
|
+
},
|
|
1819
|
+
scopes: ['fixes:write'],
|
|
1820
|
+
annotations: { ...write(true, true), title: 'Publish Shopify terms' },
|
|
1821
|
+
async run(args, { api }) {
|
|
1822
|
+
const site = String(args.site);
|
|
1823
|
+
const r = await api.request('POST', `/v1/sites/${enc(site)}/fixes/shopify/publish`, { body: pick(args, ['cancel_url', 'plan']) });
|
|
1824
|
+
return {
|
|
1825
|
+
headline: `Published the verified renewal terms to the Checkbox app block on ${r.shop} (${r.id}).`,
|
|
1826
|
+
notes: [
|
|
1827
|
+
`Disclosure: ${untrusted(r.disclosure, 600)}`,
|
|
1828
|
+
`Consent checkbox: ${untrusted(r.consent_label, 200)}`,
|
|
1829
|
+
`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.`,
|
|
1830
|
+
],
|
|
1831
|
+
data: r,
|
|
1832
|
+
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' },
|
|
1833
|
+
};
|
|
1834
|
+
},
|
|
1835
|
+
};
|
|
1225
1836
|
const runScan = {
|
|
1226
1837
|
name: 'run_scan',
|
|
1227
1838
|
title: 'Run a synthetic-shopper scan (write)',
|
|
@@ -1242,15 +1853,26 @@ const runScan = {
|
|
|
1242
1853
|
const getScan = {
|
|
1243
1854
|
name: 'get_scan',
|
|
1244
1855
|
title: 'Get a scan',
|
|
1245
|
-
description: 'A scan\'s status (queued → running → completed | failed), warnings, and once completed the evaluation it produced
|
|
1856
|
+
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
1857
|
input: { scan: z.string().max(100).describe('Scan id (scan_…).') },
|
|
1247
1858
|
scopes: ['scans:read'],
|
|
1248
1859
|
annotations: { ...ro, title: 'Scan' },
|
|
1249
1860
|
async run(args, { api }) {
|
|
1250
1861
|
const scan = await api.request('GET', `/v1/scans/${enc(String(args.scan))}`);
|
|
1251
1862
|
const done = scan.status === 'completed';
|
|
1863
|
+
// Audit P1: a queued scan of a site held for domain verification does not run until the domain is verified.
|
|
1864
|
+
if (scan.status === 'queued' && scan.site) {
|
|
1865
|
+
const site = await optional(api.request('GET', `/v1/sites/${enc(scan.site)}`));
|
|
1866
|
+
if ('monitoring' in site && site.monitoring?.held_for_domain_verification) {
|
|
1867
|
+
return {
|
|
1868
|
+
headline: `Scan ${scan.id}: queued (waiting for domain verification). It does not run until the site's domain is verified.`,
|
|
1869
|
+
data: scan,
|
|
1870
|
+
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' },
|
|
1871
|
+
};
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1252
1874
|
return {
|
|
1253
|
-
headline: `Scan ${scan.id}: ${statusLine(scan.status)}${done ?
|
|
1875
|
+
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
1876
|
data: scan,
|
|
1255
1877
|
next: done
|
|
1256
1878
|
? { type: 'review', message: 'Call get_compliance_plan for what to change.', operation_id: 'retrieveCompliancePlan' }
|
|
@@ -1263,32 +1885,58 @@ const getScan = {
|
|
|
1263
1885
|
const sendFeedback = {
|
|
1264
1886
|
name: 'send_feedback',
|
|
1265
1887
|
title: 'Send feedback to the Checkbox team',
|
|
1266
|
-
description: '
|
|
1888
|
+
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
1889
|
input: {
|
|
1268
|
-
type: z.enum(['bug', 'idea', 'question']).optional()
|
|
1269
|
-
message: z.string().
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1890
|
+
type: z.enum(['bug', 'idea', 'question', 'review']).optional(),
|
|
1891
|
+
message: z.string().optional(),
|
|
1892
|
+
score: z.number().int().min(0).max(10).optional(),
|
|
1893
|
+
liked: z.array(z.string()).optional(),
|
|
1894
|
+
disliked: z.array(z.string()).optional(),
|
|
1895
|
+
wrong_verdicts: z.array(z.string()).optional(),
|
|
1896
|
+
would_keep_using: z.boolean().optional(),
|
|
1897
|
+
stack: z.string().optional(),
|
|
1898
|
+
site: z.string().optional(),
|
|
1899
|
+
request_id: z.string().optional(),
|
|
1900
|
+
tool: z.string().optional(),
|
|
1273
1901
|
},
|
|
1274
1902
|
scopes: ['account:read'],
|
|
1275
1903
|
annotations: { ...write(false, false), title: 'Send feedback' },
|
|
1276
1904
|
async run(args, ctx) {
|
|
1277
1905
|
const context = {};
|
|
1278
|
-
|
|
1906
|
+
// Reviews are about Checkbox, not the merchant's site: no site or request id is attached to them.
|
|
1907
|
+
for (const k of args.type === 'review' ? ['tool'] : ['site', 'request_id', 'tool'])
|
|
1279
1908
|
if (typeof args[k] === 'string' && args[k])
|
|
1280
1909
|
context[k] = String(args[k]);
|
|
1281
1910
|
const agent = ctx.key.appName ?? ctx.clientName;
|
|
1282
1911
|
if (agent)
|
|
1283
1912
|
context.agent = String(agent).slice(0, 100);
|
|
1284
|
-
|
|
1913
|
+
if (args.type !== 'review' && !(typeof args.message === 'string' && args.message.trim()))
|
|
1914
|
+
throw new ToolError('Write a message (what you tried, what happened, what you expected).');
|
|
1915
|
+
if (args.type === 'review' && typeof args.score !== 'number')
|
|
1916
|
+
throw new ToolError('A review needs score (0-10). Ask the human; never guess it.');
|
|
1917
|
+
const body = { ...pick(args, ['type', 'message', 'score', 'liked', 'disliked', 'wrong_verdicts', 'would_keep_using', 'stack']), ...(Object.keys(context).length ? { context } : {}) };
|
|
1918
|
+
const fb = await ctx.api.request('POST', '/v1/feedback', { body });
|
|
1285
1919
|
return {
|
|
1286
|
-
headline:
|
|
1920
|
+
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
1921
|
data: fb,
|
|
1288
1922
|
next: { type: 'none', message: 'Nothing else to do; the team replies by email to the account owner when needed.' },
|
|
1289
1923
|
};
|
|
1290
1924
|
},
|
|
1291
1925
|
};
|
|
1926
|
+
const LAW_CHANGES_SHOWN = 10;
|
|
1927
|
+
/**
|
|
1928
|
+
* Agent audit round 2: list_law_updates with limit 3 printed 62 KB (one catalog update carried 195 requirement changes,
|
|
1929
|
+
* each with its full summary). Each update keeps its first changes in short form, with the total.
|
|
1930
|
+
*/
|
|
1931
|
+
export function lawUpdateItem(u) {
|
|
1932
|
+
const changes = Array.isArray(u.changes) ? u.changes : [];
|
|
1933
|
+
const { changes: _c, ...rest } = u;
|
|
1934
|
+
return {
|
|
1935
|
+
...rest,
|
|
1936
|
+
changes: changes.slice(0, LAW_CHANGES_SHOWN).map((c) => pick(c, ['requirement_id', 'change', 'citation', 'title', 'effective_date'])),
|
|
1937
|
+
...(changes.length > LAW_CHANGES_SHOWN ? { changes_total: changes.length } : {}),
|
|
1938
|
+
};
|
|
1939
|
+
}
|
|
1292
1940
|
const listLawUpdates = {
|
|
1293
1941
|
name: 'list_law_updates',
|
|
1294
1942
|
title: 'List law updates and their impact',
|
|
@@ -1303,17 +1951,21 @@ const listLawUpdates = {
|
|
|
1303
1951
|
return {
|
|
1304
1952
|
headline: `${list.data.length}${list.has_more ? '+' : ''} law update(s).`,
|
|
1305
1953
|
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,
|
|
1954
|
+
data: { ...list, data: list.data.map(lawUpdateItem) },
|
|
1307
1955
|
};
|
|
1308
1956
|
},
|
|
1309
1957
|
};
|
|
1310
1958
|
function toDisclosureTerms(t) {
|
|
1311
|
-
const out = pick(t, ['merchant_name', 'amount', 'currency', 'interval', 'interval_count', 'trial_days', 'cancel_url', 'cancel_timing']);
|
|
1959
|
+
const out = pick(t, ['merchant_name', 'amount', 'currency', 'interval', 'interval_count', 'trial_days', 'intro_percent_off', 'intro_periods', 'cancel_url', 'cancel_timing']);
|
|
1960
|
+
if (out.merchant_name === undefined && typeof t.brand_name === 'string')
|
|
1961
|
+
out.merchant_name = t.brand_name;
|
|
1312
1962
|
if (out.amount === undefined && t.price !== undefined) {
|
|
1313
1963
|
const n = typeof t.price === 'number' ? t.price : Number(String(t.price).replace(/[^0-9.]/g, ''));
|
|
1314
1964
|
if (Number.isFinite(n) && n > 0)
|
|
1315
1965
|
out.amount = Math.round(n * 100);
|
|
1316
1966
|
}
|
|
1967
|
+
if (typeof t.intro_price === 'number' && t.intro_price >= 0)
|
|
1968
|
+
out.intro_amount = Math.round(t.intro_price * 100);
|
|
1317
1969
|
return out;
|
|
1318
1970
|
}
|
|
1319
1971
|
function reactSnippet(d) {
|
|
@@ -1334,7 +1986,7 @@ function reactSnippet(d) {
|
|
|
1334
1986
|
const elementsInstallNotes = [
|
|
1335
1987
|
'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
1988
|
'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
|
|
1989
|
+
'Do not restyle it below body size or hide it; guard rails restore the required styles and report what they found.',
|
|
1338
1990
|
];
|
|
1339
1991
|
const elementsTermsShape = {
|
|
1340
1992
|
merchant_name: planTermsShape.merchant_name,
|
|
@@ -1349,31 +2001,33 @@ const elementsTermsShape = {
|
|
|
1349
2001
|
};
|
|
1350
2002
|
const getCompliantDisclosure = {
|
|
1351
2003
|
name: 'get_compliant_disclosure',
|
|
1352
|
-
title: 'Generate a
|
|
2004
|
+
title: 'Generate a disclosure that passes the checks for given terms',
|
|
1353
2005
|
description: [
|
|
1354
2006
|
'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"}.',
|
|
2007
|
+
'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
2008
|
'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
2009
|
].join('\n'),
|
|
1358
|
-
|
|
2010
|
+
// Round 15 (P4): a promo (intro price) is stated with how long it lasts.
|
|
2011
|
+
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
2012
|
scopes: ['checks:read'],
|
|
1360
|
-
annotations: { ...ro, readOnlyHint: false, title: '
|
|
2013
|
+
annotations: { ...ro, readOnlyHint: false, title: 'Disclosure that passes the checks' },
|
|
1361
2014
|
async run(args, { api, key }) {
|
|
1362
2015
|
if (typeof args.site === 'string' && args.site && keyAllows(key, 'consents:write')) {
|
|
1363
2016
|
const d = await api.request('POST', '/v1/disclosures', { body: { site: args.site, terms: toDisclosureTerms(args) } });
|
|
1364
2017
|
return {
|
|
1365
|
-
headline: `
|
|
2018
|
+
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
2019
|
notes: [`Disclosure: ${d.text}`, `Consent label: ${d.consent_label}`, ...elementsInstallNotes],
|
|
1367
2020
|
data: { ...d, react: reactSnippet(d) },
|
|
1368
2021
|
};
|
|
1369
2022
|
}
|
|
1370
2023
|
// 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']) };
|
|
2024
|
+
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
2025
|
const r = await api.request('POST', '/v1/checks/disclosure', { body });
|
|
1373
2026
|
return {
|
|
1374
|
-
headline: `
|
|
2027
|
+
headline: `Suggested disclosure generated (${r.replacement.verified ? 'passes the checks, verified by the rules engine' : 'not fully verified by the rules engine'}).`,
|
|
1375
2028
|
notes: [
|
|
1376
|
-
|
|
2029
|
+
// Round 23 (pilot 22 item 30): the short line first when the engine passes it; the full text as the fuller version.
|
|
2030
|
+
...(r.replacement.short_disclosure_text ? [`Disclosure: ${r.replacement.short_disclosure_text}`, `Fuller version (also passes): ${r.replacement.disclosure_text}`] : [`Disclosure: ${r.replacement.disclosure_text}`]),
|
|
1377
2031
|
`Consent label: ${r.replacement.consent_label}`,
|
|
1378
2032
|
...(r.replacement.placeholders?.length ? [`Fill these placeholders: ${r.replacement.placeholders.join(', ')}.`] : []),
|
|
1379
2033
|
'Place html directly above the purchase button; never pre-check the box. For consent records as evidence, pass site to get the Elements snippet instead.',
|
|
@@ -1388,7 +2042,7 @@ const getElementsSnippet = {
|
|
|
1388
2042
|
title: 'Get the Checkbox Elements snippet for a site',
|
|
1389
2043
|
description: [
|
|
1390
2044
|
'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
|
|
2045
|
+
'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
2046
|
'This creates a disclosure record (write, safe to repeat). Consents themselves are recorded by the browser, never by agents.',
|
|
1393
2047
|
].join('\n'),
|
|
1394
2048
|
input: { site: requiredSite, plan: z.string().max(200).optional().describe('Billing price / plan id (price_…) when the site has several.'), ...elementsTermsShape },
|
|
@@ -1493,7 +2147,7 @@ const listDueNotices = {
|
|
|
1493
2147
|
annotations: { ...ro, title: 'Due notices' },
|
|
1494
2148
|
async run(args, { api }) {
|
|
1495
2149
|
const list = await api.request('GET', '/v1/notices/due', {
|
|
1496
|
-
query: pick(args, ['site', 'status', 'kind', 'subscription', 'limit', 'starting_after']),
|
|
2150
|
+
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
2151
|
});
|
|
1498
2152
|
const overdue = list.data.filter((d) => d.status === 'overdue').length;
|
|
1499
2153
|
return {
|
|
@@ -1538,7 +2192,7 @@ const getNoticeTemplates = {
|
|
|
1538
2192
|
site: z.string().max(100).optional().describe('Site (site_…): adds BCC addresses; required with check.'),
|
|
1539
2193
|
check: z
|
|
1540
2194
|
.object({
|
|
1541
|
-
kind:
|
|
2195
|
+
kind: emailKind.describe('Notice kind.'),
|
|
1542
2196
|
subject: z.string().max(1000).describe('Subject (merge fields allowed).'),
|
|
1543
2197
|
text: z.string().max(100_000).optional().describe('Plain-text body.'),
|
|
1544
2198
|
html: z.string().max(100_000).optional().describe('HTML body.'),
|
|
@@ -1554,7 +2208,7 @@ const getNoticeTemplates = {
|
|
|
1554
2208
|
if (!args.site)
|
|
1555
2209
|
throw new Error('site is required to check a template (its plan and jurisdictions scope the check).');
|
|
1556
2210
|
const r = await api.request('POST', '/v1/notices/templates/check', {
|
|
1557
|
-
body: { site: args.site, ...check },
|
|
2211
|
+
body: { site: args.site, ...check, kind: noticeKind(check.kind) },
|
|
1558
2212
|
});
|
|
1559
2213
|
return {
|
|
1560
2214
|
headline: `Template check: ${r.verdict.toUpperCase()}.`,
|
|
@@ -1577,8 +2231,9 @@ const callApi = {
|
|
|
1577
2231
|
name: 'call_api',
|
|
1578
2232
|
title: 'Call any Checkbox API endpoint',
|
|
1579
2233
|
description: [
|
|
1580
|
-
'For a next_action whose operation has no dedicated tool (e.g.
|
|
1581
|
-
'Examples: {"method":"
|
|
2234
|
+
'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.',
|
|
2235
|
+
'Examples: {"method":"GET","path":"/v1/account"} ; {"method":"POST","path":"/v1/sites/site_123/domain_verification/check"}.',
|
|
2236
|
+
'Never send API keys or Stripe keys through it, except a Stripe TEST restricted key via store_stripe_test_key (not call_api). Live keys are added by the human in the Checkbox dashboard, never pasted into the chat.',
|
|
1582
2237
|
'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
2238
|
].join('\n'),
|
|
1584
2239
|
input: {
|
|
@@ -1603,10 +2258,74 @@ const callApi = {
|
|
|
1603
2258
|
},
|
|
1604
2259
|
};
|
|
1605
2260
|
/** Every tool, in the order clients list them. */
|
|
2261
|
+
/**
|
|
2262
|
+
* Round 12 (pilot 11, nextjs + agent users): setup_checkbox dropped unknown fields silently; the homepage prompt says
|
|
2263
|
+
* "brand", the schema says merchant_name, so the site was named "localhost" with no warning. Common spellings of the
|
|
2264
|
+
* brand are read as merchant_name; any other unknown field is listed back as ignored.
|
|
2265
|
+
*/
|
|
2266
|
+
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' });
|
|
2267
|
+
const BILLING_PLATFORMS = new Set(['stripe', 'shopify', 'recharge', 'chargebee', 'recurly', 'paddle', 'lemon_squeezy', 'woocommerce', 'custom']);
|
|
2268
|
+
export function normalizeSetupArgs(raw, known) {
|
|
2269
|
+
const args = {};
|
|
2270
|
+
const aliased = [];
|
|
2271
|
+
const ignored = [];
|
|
2272
|
+
for (const [k, v] of Object.entries(raw))
|
|
2273
|
+
if (known.has(k))
|
|
2274
|
+
args[k] = v;
|
|
2275
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
2276
|
+
if (known.has(k))
|
|
2277
|
+
continue;
|
|
2278
|
+
const to = SETUP_FIELD_ALIASES[k];
|
|
2279
|
+
const ok = typeof v === 'string' && !!v.trim() && (to !== 'billing_platform' || BILLING_PLATFORMS.has(v.trim().toLowerCase()));
|
|
2280
|
+
if (to && known.has(to) && ok && args[to] === undefined) {
|
|
2281
|
+
args[to] = to === 'billing_platform' ? v.trim().toLowerCase() : v.trim().slice(0, 100);
|
|
2282
|
+
aliased.push(`${k} (read as ${to})`);
|
|
2283
|
+
}
|
|
2284
|
+
else
|
|
2285
|
+
ignored.push(k);
|
|
2286
|
+
}
|
|
2287
|
+
return { args, aliased, ignored };
|
|
2288
|
+
}
|
|
2289
|
+
/** Messages of the API's `warnings` ({ code, message }) on a setup result (or its session). */
|
|
2290
|
+
export function apiWarningMessages(data) {
|
|
2291
|
+
if (!data || typeof data !== 'object')
|
|
2292
|
+
return [];
|
|
2293
|
+
const d = data;
|
|
2294
|
+
const list = Array.isArray(d.warnings) ? d.warnings : Array.isArray(d.session?.warnings) ? d.session.warnings : [];
|
|
2295
|
+
return list.flatMap((w) => (w && typeof w === 'object' && typeof w.message === 'string' ? [`Warning: ${w.message}`] : []));
|
|
2296
|
+
}
|
|
2297
|
+
function acceptingSetupAliases(def) {
|
|
2298
|
+
const known = new Set(Object.keys(def.input));
|
|
2299
|
+
return {
|
|
2300
|
+
...def,
|
|
2301
|
+
passthrough: true,
|
|
2302
|
+
async run(raw, ctx) {
|
|
2303
|
+
const { args, aliased, ignored } = normalizeSetupArgs(raw, known);
|
|
2304
|
+
// Round 18 (item 19, pilot 17): update_setup refuses unknown top-level fields, as it refuses unknown nested ones
|
|
2305
|
+
// (an ignored field read as "saved" to the agent).
|
|
2306
|
+
if (ignored.length && def.name === 'update_setup') {
|
|
2307
|
+
throw new ToolError(unknownArgumentsMessage(def.name, ignored, [...known]), { type: 'fix_request', message: 'Rename or remove those fields and call update_setup again.' });
|
|
2308
|
+
}
|
|
2309
|
+
const out = await def.run(args, ctx);
|
|
2310
|
+
// Pilot round 13: the API's input warnings (plan amounts that look like dollars, or differ from the Stripe Price)
|
|
2311
|
+
// are shown, and kept next to the field warnings below.
|
|
2312
|
+
const apiWarnings = apiWarningMessages(out.data);
|
|
2313
|
+
if (!aliased.length && !ignored.length && !apiWarnings.length)
|
|
2314
|
+
return out;
|
|
2315
|
+
const warnings = [
|
|
2316
|
+
...apiWarnings,
|
|
2317
|
+
...(aliased.length ? [`Read ${aliased.join(', ')}.`] : []),
|
|
2318
|
+
...(ignored.length ? [`Ignored unknown field${ignored.length === 1 ? '' : 's'}: ${ignored.join(', ')}. ${def.name} accepts: ${[...known].filter((k) => k !== 'device_code').join(', ')}.`] : []),
|
|
2319
|
+
];
|
|
2320
|
+
const data = out.data && typeof out.data === 'object' && !Array.isArray(out.data) ? { ...out.data, warnings } : out.data;
|
|
2321
|
+
return { ...out, notes: [...warnings, ...(out.notes ?? [])], data };
|
|
2322
|
+
},
|
|
2323
|
+
};
|
|
2324
|
+
}
|
|
1606
2325
|
export const TOOLS = [
|
|
1607
|
-
setupCheckbox,
|
|
2326
|
+
acceptingSetupAliases(setupCheckbox),
|
|
1608
2327
|
getSetupStatus,
|
|
1609
|
-
updateSetup,
|
|
2328
|
+
acceptingSetupAliases(updateSetup),
|
|
1610
2329
|
completeSetup,
|
|
1611
2330
|
getCompliancePlan,
|
|
1612
2331
|
getTask,
|
|
@@ -1614,6 +2333,7 @@ export const TOOLS = [
|
|
|
1614
2333
|
answerTask,
|
|
1615
2334
|
verifySite,
|
|
1616
2335
|
getVerification,
|
|
2336
|
+
storeStripeTestKey,
|
|
1617
2337
|
startTestRun,
|
|
1618
2338
|
getTestRun,
|
|
1619
2339
|
checkDisclosure,
|
|
@@ -1628,6 +2348,7 @@ export const TOOLS = [
|
|
|
1628
2348
|
getSiteStatus,
|
|
1629
2349
|
listFindings,
|
|
1630
2350
|
getFinding,
|
|
2351
|
+
publishShopifyTerms,
|
|
1631
2352
|
runScan,
|
|
1632
2353
|
getScan,
|
|
1633
2354
|
listLawUpdates,
|
|
@@ -1648,12 +2369,13 @@ export const TOOL_OPERATIONS = {
|
|
|
1648
2369
|
update_setup: ['updateSetupSession'],
|
|
1649
2370
|
update_site: ['updateSite'],
|
|
1650
2371
|
complete_setup: ['completeSetupSession'],
|
|
1651
|
-
get_compliance_plan: ['retrieveCompliancePlan'],
|
|
2372
|
+
get_compliance_plan: ['retrieveCompliancePlan', 'claimReviewPrompt'],
|
|
1652
2373
|
get_task: ['retrieveTask'],
|
|
1653
|
-
verify_task: ['verifyTask'],
|
|
2374
|
+
verify_task: ['verifyTask', 'claimReviewPrompt'],
|
|
1654
2375
|
answer_task: ['answerTask'],
|
|
1655
2376
|
verify_site: ['verifySite'],
|
|
1656
2377
|
get_verification: ['retrieveVerification'],
|
|
2378
|
+
store_stripe_test_key: ['createTestCredential'],
|
|
1657
2379
|
start_test_run: ['createTestRun'],
|
|
1658
2380
|
get_test_run: ['listTestRuns', 'retrieveTestRun'],
|
|
1659
2381
|
check_disclosure: ['checkDisclosure'],
|
|
@@ -1667,6 +2389,7 @@ export const TOOL_OPERATIONS = {
|
|
|
1667
2389
|
get_site_status: ['retrieveSite', 'retrieveLatestEvaluation', 'listFindings'],
|
|
1668
2390
|
list_findings: ['listFindings'],
|
|
1669
2391
|
get_finding: ['retrieveFinding', 'retrieveFindingFix'],
|
|
2392
|
+
publish_shopify_terms: ['publishShopifyTerms'],
|
|
1670
2393
|
run_scan: ['createScan'],
|
|
1671
2394
|
get_scan: ['retrieveScan'],
|
|
1672
2395
|
list_law_updates: ['listLawUpdates'],
|