@venlyfinance/settlement-mcp 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/frontend.js CHANGED
@@ -1,12 +1,35 @@
1
1
  import { z } from "zod";
2
2
  export const REGISTRY_URL_TEMPLATE = "https://raw.githubusercontent.com/Venly/venly-settlement-sdk/main/ui/r/{name}.json";
3
3
  const JOURNEYS = {
4
+ auth: `# Auth (sign-in, 2FA, sign-up)
5
+ Shell: outside the app shell - a centred card column.
6
+ Registry items: venly-tokens; block: auth (SignInForm, TwoFactorForm, SignUpForm).
7
+ Binding: an AuthAdapter YOU implement - the Venly APIs authenticate machines
8
+ (client credentials), never people, so end-user auth is your identity layer
9
+ (OAuth/OIDC, Better Auth, Auth0, Clerk, Keycloak). createMockAuthAdapter ships
10
+ for demos: deterministic 2FA code 000000, expireSession() driver.
11
+ States that must exist: signed out, bad credentials (ONE combined message -
12
+ no user enumeration), 2FA challenge with wrong-code path, session expired
13
+ (session() returns null - redirect, no other signal), duplicate sign-up email.
14
+ Rules that must hold: credential errors never confirm which half was wrong;
15
+ the code field is six slots with paste distribution and full keyboard support;
16
+ the mock never claims an email was sent.`,
17
+ team: `# Team
18
+ Shell: in-shell content column.
19
+ Registry items: venly-tokens, data-table, status-pill; block: team (TeamTable, InviteDialog).
20
+ Binding: a TeamAdapter over your auth provider (createMockTeamAdapter for demos).
21
+ States that must exist: ACTIVE/INVITED/DISABLED members on first paint, invite
22
+ created (display-only link in mock - never a fake sent-email claim), role
23
+ change persisting, self-actions blocked with the reason.
24
+ Rules that must hold: member status is word + glyph; role controls live in the
25
+ row; you cannot change your own role or disable yourself - the control is
26
+ disabled AND explains why.`,
4
27
  "home-balances": `# Home / balances
5
28
  Shell: left nav rail + thin top bar; full-width content.
6
- Registry items: venly-tokens, balance-card, data-table, status-pill.
7
- Hooks: useAccounts, useVirtualBankAccounts; balances rendered per account/currency.
8
- States that must exist: loading, zero accounts (first-run guidance), balances with reserved buckets.
9
- Rules that must hold: available is the emphasised figure and the only one above the rule; reserved is demoted by position and scale, never colour; unspendable buckets carry the padlock; never assume stablecoin parity - render the quoted rate.`,
29
+ Registry items: venly-tokens, balance-card, data-table, status-pill; block: balances (BalancesBlock, BalanceMiniature).
30
+ Hooks: useAccounts, useWallets; balances rendered per asset across the account's wallets.
31
+ States that must exist: loading, zero balances (first-run guidance), reserved buckets, entirely reserved (available 0 rendered honestly - the acct-escrow seed exercises it), balance load error degrading locally with a retry.
32
+ Rules that must hold: available is the emphasised figure and the only one above the rule; reserved is demoted by position and scale, never colour, and carries the still-yours qualifier; unspendable buckets carry the padlock; masking covers every figure including the chrome miniature; arithmetic mismatches are surfaced, never corrected; never assume stablecoin parity - render the quoted rate.`,
10
33
  receive: `# Receive
11
34
  Shell: content column - a warning callout, the field card, an advisory below.
12
35
  Registry items: venly-tokens, field-list; block: receive.
@@ -27,10 +50,16 @@ States that must exist: loading, empty ledger, rows with pending/failed pills, o
27
50
  Rules that must hold: a row click opens the panel, never navigates; no scrim - the source row stays tinted; settled rows stay quiet (colour is a budget; pills only where action or failure lives); the panel's hero is the amount; the failure reason rides the terminal timeline node.`,
28
51
  "onboarding-status": `# Onboarding / verification status
29
52
  Shell: full page, form clamped ~600px; a status home once submitted.
30
- Registry items: venly-tokens, timeline, status-pill, field-list.
31
- Hooks: useParties, useCreateParty; verification status from the party/account records.
32
- States that must exist: collecting (per-section progress), submitted/waiting (say who acts next, on which channel, what still works meanwhile), approved, declined (humane copy + what to do next), re-verification on a live account.
33
- Rules that must hold: never render a fake progress percentage - use real per-item status; a waiting state answers how long / who acts / what still works; a decline explains and offers a next step, not a dead end; creating a party is NOT completed verification - show the honest state.`,
53
+ Registry items: venly-tokens, timeline, status-pill, field-list; block: onboarding (CompanyForm, VerificationStatusHome, RestrictedBanner).
54
+ Hooks: useCreateParty, useCreateAccount, useParty, useAccount; verification status from the party/account records verbatim.
55
+ States that must exist: collecting (review before submit), submitted/waiting (say who acts next, on which channel, what still works meanwhile), approved, declined (humane copy, review-request as the primary action), re-verification on a live account (banner naming what pauses and what keeps working).
56
+ Rules that must hold: never render a fake progress percentage - use real status; a waiting state answers how long / who acts / what still works, and where no review window is published the copy says so instead of inventing one; a decline explains and offers a next step, not a dead end; creating a party is NOT completed verification - show the honest state.`,
57
+ "withdraw-bank-accounts": `# Withdraw + bank accounts (off-ramp)
58
+ Shell: settings page for the whitelist; full page for the flow, form clamped ~600px.
59
+ Registry items: venly-tokens, data-table, status-pill, timeline, field-list, arithmetic-ladder; blocks: bank-accounts (BankAccountsBlock, AddBankAccountForm), withdraw (WithdrawFlow, WithdrawalsTable, ConnectedWithdrawDetail).
60
+ Hooks: useCompanyBankAccounts, useBankAccountConfig, useCreateCompanyBankAccount, useRampRequests, useRampRequest, useCreateRampRequest, useFeeQuote, useRampPairs, useReferenceData, useFourEyesApproval, useInitiateRamp, describeRampStatus.
61
+ States that must exist: empty whitelist (one CTA), account in review / verified / declined, no-verified-destination block, amount over balance (two-place signal), fee quote with its unit, awaiting approval (creator sees why they can't approve), stale decision (409 - refetch and re-decide, never auto-retry), awaiting funds (deposit instructions + mandatory reference + tx-hash report), processing, paid out, failed, rejected, cancelled, on hold.
62
+ Rules that must hold: destinations are the company's OWN verified accounts - unverified rows are disabled with the reason, never hidden; the pre-create review renders only known figures (no invented rate, no bank-receives placeholder - the created record carries the fiat arithmetic and the detail opens on it); a refusal never reads as a wait; the event timeline renders actor, role and absolute timestamps.`,
34
63
  reconciliation: `# Reconciliation
35
64
  Shell: split pane (roughly one-third list, two-thirds evidence) - not a drawer.
36
65
  Registry items: venly-tokens, data-table, side-panel, status-pill, field-list.
@@ -51,15 +80,75 @@ States that must exist: queue with awaiting-approval items, detail with the deci
51
80
  Rules that must hold: the optimistic-locking version travels with every decision; a 409 means re-decide against fresh state, never auto-retry; reject requires a reason; the creator sees why they cannot approve.`,
52
81
  };
53
82
  const JOURNEY_KEYS = Object.keys(JOURNEYS);
83
+ // ---------------------------------------------------------------------------
84
+ // Shared mechanics. Three cross-cutting behaviours every rule participates in:
85
+ //
86
+ // 1. Suppression: `venly-allow:<rule-id>` on the offending line or the line
87
+ // immediately above drops the finding silently - no counter, no second
88
+ // severity tier. A consumer's own API may legitimately return what ours
89
+ // does not; without this hatch the audit is uninstallable for them.
90
+ // 2. Comment lines are not copy: rules that judge words skip lines whose
91
+ // trimmed form starts with `*`, `//`, `/*` or `{/*` - otherwise the rule
92
+ // fires on the comment that documents the rule itself.
93
+ // 3. Findings carry the character index they fired at, so suppression can be
94
+ // resolved against the exact offending line.
95
+ // ---------------------------------------------------------------------------
96
+ const COMMENT_LINE = /^\s*(?:\*|\/\/|\/\*|\{\/\*)/;
97
+ function lineBoundsAt(source, idx) {
98
+ const at = Math.min(Math.max(idx, 0), source.length);
99
+ const start = source.lastIndexOf("\n", Math.max(0, at - 1)) + 1;
100
+ const nl = source.indexOf("\n", at);
101
+ return { start, end: nl === -1 ? source.length : nl };
102
+ }
103
+ function lineAt(source, idx) {
104
+ const { start, end } = lineBoundsAt(source, idx);
105
+ return source.slice(start, end);
106
+ }
107
+ function lineAboveAt(source, idx) {
108
+ const { start } = lineBoundsAt(source, idx);
109
+ if (start === 0)
110
+ return "";
111
+ const prevEnd = start - 1; // the \n terminating the previous line
112
+ const prevStart = source.lastIndexOf("\n", prevEnd - 1) + 1;
113
+ return source.slice(prevStart, prevEnd);
114
+ }
115
+ function isCommentLineAt(source, idx) {
116
+ return COMMENT_LINE.test(lineAt(source, idx));
117
+ }
118
+ function isSuppressedAt(source, idx, ruleId) {
119
+ const token = `venly-allow:${ruleId}`;
120
+ return lineAt(source, idx).includes(token) || lineAboveAt(source, idx).includes(token);
121
+ }
122
+ function lineNumberAt(source, idx) {
123
+ let line = 1;
124
+ for (let i = 0; i < idx && i < source.length; i++)
125
+ if (source[i] === "\n")
126
+ line++;
127
+ return line;
128
+ }
54
129
  /** Deterministic design audit. Text in, findings out - no model, no taste. */
55
- export function reviewScreenSource(source) {
130
+ export function reviewScreenSource(source, journey) {
56
131
  const findings = [];
57
- const push = (rule, severity, evidence, fix) => findings.push({ rule, severity, evidence: evidence.slice(0, 120), fix });
132
+ // Returns whether the finding was recorded, so rules that stop after the
133
+ // first hit can keep scanning past a suppressed occurrence instead of
134
+ // letting one venly-allow blind them to a later real violation.
135
+ const push = (rule, severity, evidence, fix, atIndex) => {
136
+ if (isSuppressedAt(source, atIndex, rule))
137
+ return false;
138
+ findings.push({
139
+ rule,
140
+ severity,
141
+ evidence: evidence.slice(0, 120),
142
+ fix,
143
+ line: lineNumberAt(source, atIndex),
144
+ });
145
+ return true;
146
+ };
58
147
  for (const match of source.matchAll(/#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)/g)) {
59
- push("raw-colour", "error", match[0], "Read colours from the venly-tokens custom properties; a reskin must be tokens.css and nothing else.");
148
+ push("raw-colour", "error", match[0], "Read colours from the venly-tokens custom properties; a reskin must be tokens.css and nothing else.", match.index ?? 0);
60
149
  }
61
150
  for (const match of source.matchAll(/-\d[\d,]*\.\d{2}\s*(?:[A-Z]{3}|€|\$|£)/g)) {
62
- push("hyphen-minus-amount", "error", match[0], "Use the true minus sign − before negative amounts (the Money primitive does this).");
151
+ push("hyphen-minus-amount", "error", match[0], "Use the true minus sign − before negative amounts (the Money primitive does this).", match.index ?? 0);
63
152
  }
64
153
  // Only a RENDERED cancelled state counts (a quoted/JSX label or a state
65
154
  // value), never the verb "cancel" in prose or a token file's comment; and
@@ -69,29 +158,238 @@ export function reviewScreenSource(source) {
69
158
  const idx = match.index ?? 0;
70
159
  const around = source.slice(Math.max(0, idx - 150), idx + 150);
71
160
  if (/✓/.test(around)) {
72
- push("success-on-cancelled", "error", around.trim().slice(0, 80), "A cancelled or failed terminal step must never carry a success check - grey ↺ or red ✕.");
73
- break;
161
+ if (push("success-on-cancelled", "error", around.trim().slice(0, 80), "A cancelled or failed terminal step must never carry a success check - grey ↺ or red ✕.", idx))
162
+ break;
74
163
  }
75
164
  }
76
- if (/review|confirm/i.test(source) && /[•*]{3,}/.test(source)) {
77
- push("masked-review-value", "error", source.match(/[•*]{3,}/)[0], "Never mask values on a review screen; its only job is legibility of what is about to happen.");
165
+ // The once-per-source rules below scan every occurrence and stop at the
166
+ // first RECORDED finding, so a venly-allow on one occurrence never hides a
167
+ // later unsuppressed one.
168
+ if (/review|confirm/i.test(source)) {
169
+ for (const masked of source.matchAll(/[•*]{3,}/g)) {
170
+ if (push("masked-review-value", "error", masked[0], "Never mask values on a review screen; its only job is legibility of what is about to happen.", masked.index ?? 0))
171
+ break;
172
+ }
78
173
  }
79
- if (/nth-child\(\s*(?:even|odd|2n)/.test(source)) {
80
- push("zebra-striping", "warn", "nth-child(even/odd) background", "No finance reference uses zebra striping - separate rows with hairlines and spacing.");
174
+ for (const zebra of source.matchAll(/nth-child\(\s*(?:even|odd|2n)/g)) {
175
+ if (push("zebra-striping", "warn", "nth-child(even/odd) background", "No finance reference uses zebra striping - separate rows with hairlines and spacing.", zebra.index ?? 0))
176
+ break;
81
177
  }
82
- if (/box-shadow/.test(source) && !/var\(--shadow-overlay\)/.test(source)) {
83
- push("shadow-outside-overlay", "warn", source.match(/box-shadow[^;"}]*/)?.[0] ?? "box-shadow", "Elevation is only for overlays, and only via the --shadow-overlay token; the base layer is flat.");
178
+ if (!/var\(--shadow-overlay\)/.test(source)) {
179
+ for (const shadow of source.matchAll(/box-shadow[^;"}]*/g)) {
180
+ if (push("shadow-outside-overlay", "warn", shadow[0], "Elevation is only for overlays, and only via the --shadow-overlay token; the base layer is flat.", shadow.index ?? 0))
181
+ break;
182
+ }
84
183
  }
85
- if (/linear-gradient|radial-gradient/.test(source)) {
86
- push("gradient-surface", "warn", source.match(/\w+-gradient\([^)]*\)/)?.[0] ?? "gradient", "Gradient balance heroes read as template, not product; surfaces are flat neutrals with one accent.");
184
+ for (const gradient of source.matchAll(/(?:linear|radial)-gradient(?:\([^)]*\))?/g)) {
185
+ if (push("gradient-surface", "warn", gradient[0], "Gradient balance heroes read as template, not product; surfaces are flat neutrals with one accent.", gradient.index ?? 0))
186
+ break;
87
187
  }
88
- if (/(?:status|state)/i.test(source) && /var\(--state-/.test(source)) {
89
- if (!/[✓✕↺⚠●○]|aria-hidden/.test(source)) {
90
- push("colour-only-state", "warn", "state colours present without any glyph", "Pair every state hue with a glyph or word so status survives greyscale.");
188
+ if (/(?:status|state)/i.test(source) && !/[✓✕↺⚠●○]|aria-hidden/.test(source)) {
189
+ for (const stateVar of source.matchAll(/var\(--state-/g)) {
190
+ if (push("colour-only-state", "warn", "state colours present without any glyph", "Pair every state hue with a glyph or word so status survives greyscale.", stateVar.index ?? 0))
191
+ break;
91
192
  }
92
193
  }
194
+ // --- New rule classes (invented timing copy, crypto currency formatting,
195
+ // required-rendered-optional, blueprint state coverage, fixture honesty)
196
+ // are registered below. Each judges text only, honours the suppression
197
+ // hatch, and skips comment lines wherever it judges copy.
198
+ checkInventedTimingClaim(source, push);
199
+ checkIntlCurrencyCrypto(source, push);
200
+ checkRequiredRenderedOptional(source, push);
201
+ checkBlueprintStateCoverage(source, journey, push);
202
+ checkFixtureHonesty(source, push);
93
203
  return findings;
94
204
  }
205
+ /**
206
+ * invented-timing-claim - copy that promises a duration, a settlement window
207
+ * or custody behaviour ("1-2 business days", "held until claimed",
208
+ * "estimated arrival") that no API in this stack returns. Rendering such a
209
+ * promise invents a guarantee the backend cannot honour; the journey
210
+ * contracts require a labelled omission instead. Copy rule: comment lines
211
+ * are not copy, so matches on them are skipped.
212
+ */
213
+ function checkInventedTimingClaim(source, push) {
214
+ const pattern = /\b(?:typically|usually|normally|generally)\s+(?:arrives?|takes?|clears?|settles?)\b|\b\d+\s*(?:-|–|to)\s*\d+\s+business\s+days?\b|\bwithin\s+\d+\s+(?:seconds?|minutes?|hours?|days?|business\s+days?)\b|\bheld\s+until\s+claimed\b|\bestimated\s+(?:arrival|delivery|completion)\b/gi;
215
+ for (const match of source.matchAll(pattern)) {
216
+ const at = match.index ?? 0;
217
+ if (isCommentLineAt(source, at))
218
+ continue;
219
+ push("invented-timing-claim", "error", match[0], "No API in this stack returns a duration, settlement window or custody guarantee. Render the labelled omission the contract specifies, or - if your own API does return it - name the field path on the line and add venly-allow:invented-timing-claim.", at);
220
+ }
221
+ }
222
+ // --- intl-currency-crypto -------------------------------------------------
223
+ // Intl.NumberFormat validates `currency` against ISO 4217, so a crypto asset
224
+ // code ("USDC", "DAI", ...) throws RangeError the moment the formatter is
225
+ // constructed - a screen that compiles fine crashes on first render. Each
226
+ // Intl.NumberFormat call site is judged by the 200 characters that follow it:
227
+ // a literal crypto code next to style:"currency" is a certain crash (error);
228
+ // a variable-fed `currency:` is a latent one (warn) - it only survives until
229
+ // a crypto asset reaches it. Not a copy rule, so comment lines are not
230
+ // skipped; suppression still applies via the shared venly-allow hatch.
231
+ function checkIntlCurrencyCrypto(source, push) {
232
+ const currencyStyle = /style\s*:\s*["']currency["']/;
233
+ const cryptoCode = /["'](?:USDC|EURC|USDT|USDS|DAI|PYUSD|USDG|RLUSD)["']/;
234
+ // `\s*` lives inside the lookahead: with `currency\s*:\s*(?!["'])` the
235
+ // greedy whitespace backtracks to zero and the lookahead inspects the
236
+ // space instead of the quote, flagging `currency: "USD"` as a variable.
237
+ const variableCurrency = /currency\s*:(?!\s*["'])/;
238
+ for (const match of source.matchAll(/Intl\.NumberFormat/g)) {
239
+ const at = match.index ?? 0;
240
+ const window = source.slice(at, at + 200);
241
+ const style = currencyStyle.exec(window);
242
+ if (!style)
243
+ continue; // plain decimal formatting (the kit's own formatAmount) is safe
244
+ const styleAt = style.index ?? 0;
245
+ const crypto = cryptoCode.exec(window);
246
+ if (crypto) {
247
+ const cryptoAt = crypto.index ?? 0;
248
+ const from = Math.min(styleAt, cryptoAt);
249
+ const to = Math.max(styleAt + style[0].length, cryptoAt + crypto[0].length);
250
+ push("intl-currency-crypto", "error", window.slice(from, to), "Intl.NumberFormat with style:\"currency\" throws RangeError on a non-ISO-4217 code. Render crypto amounts with the Money primitive, which places the code beside the digits instead of inside the formatter.", at);
251
+ continue; // one finding per call site; the certain crash outranks the latent one
252
+ }
253
+ const variable = variableCurrency.exec(window);
254
+ if (variable) {
255
+ const variableAt = variable.index ?? 0;
256
+ const from = Math.min(styleAt, variableAt);
257
+ const to = Math.min(window.length, Math.max(styleAt + style[0].length, variableAt) + 40);
258
+ push("intl-currency-crypto", "warn", window.slice(from, to), "This formatter takes its currency from a variable. If a crypto asset can reach it, it throws at runtime. Use the Money primitive, or narrow the variable to ISO-4217 codes.", at);
259
+ }
260
+ }
261
+ }
262
+ /**
263
+ * A required field labelled as optional. The kit deliberately ships a
264
+ * "(not required)" variant for genuinely optional rows, so the net is scoped
265
+ * tightly: only the payment reference is required-by-contract, and a payer
266
+ * who omits it produces an unmatched credit that someone has to reconcile by
267
+ * hand. The rule therefore fires only when "(not required)" appears on a real
268
+ * code line AND the surrounding code (comments removed) mentions the
269
+ * reference - a comment that merely documents this contract must not trip it.
270
+ */
271
+ function checkRequiredRenderedOptional(source, push) {
272
+ for (const match of source.matchAll(/\(not required\)/gi)) {
273
+ const idx = match.index ?? 0;
274
+ // Copy rule: only judge rendered copy, never commentary about it.
275
+ if (isCommentLineAt(source, idx))
276
+ continue;
277
+ const winStart = Math.max(0, idx - 200);
278
+ const winEnd = Math.min(source.length, idx + match[0].length + 200);
279
+ // Rebuild the window with every comment line removed. Each line is
280
+ // classified on its FULL text (a fragment cut by the window edge could
281
+ // hide its comment marker), but only the in-window portion of surviving
282
+ // code lines feeds the reference test.
283
+ let pos = source.lastIndexOf("\n", Math.max(0, winStart - 1)) + 1;
284
+ let window = "";
285
+ while (pos < winEnd) {
286
+ let lineEnd = source.indexOf("\n", pos);
287
+ if (lineEnd === -1)
288
+ lineEnd = source.length;
289
+ const line = source.slice(pos, lineEnd);
290
+ if (!COMMENT_LINE.test(line)) {
291
+ const from = Math.max(pos, winStart);
292
+ const to = Math.min(lineEnd, winEnd);
293
+ if (to > from)
294
+ window += source.slice(from, to) + "\n";
295
+ }
296
+ pos = lineEnd + 1;
297
+ }
298
+ if (!/reference/i.test(window))
299
+ continue;
300
+ push("required-rendered-optional", "error", lineAt(source, idx).trim(), 'The payment reference is required - a payer who omits it produces an unmatched credit. Render the amber Required pill; never label it "(not required)".', idx);
301
+ }
302
+ }
303
+ function checkBlueprintStateCoverage(source, journey, push) {
304
+ // Only meaningful when the caller declared which journey this screen serves.
305
+ if (journey === undefined)
306
+ return;
307
+ // Whole-source suppression: this finding has no single offending line (it
308
+ // reports blueprint states absent from the entire file), so the escape
309
+ // hatch is whole-source too - the venly-allow token anywhere drops it.
310
+ if (source.includes("venly-allow:blueprint-state-missing"))
311
+ return;
312
+ const blueprint = JOURNEYS[journey];
313
+ const startMarker = "States that must exist:";
314
+ const startIdx = blueprint.indexOf(startMarker);
315
+ if (startIdx === -1)
316
+ return;
317
+ let statesText = blueprint.slice(startIdx + startMarker.length);
318
+ const end = /^Rules that must hold/m.exec(statesText);
319
+ if (end)
320
+ statesText = statesText.slice(0, end.index);
321
+ // Blueprint prose wraps across lines mid-sentence; collapse before parsing.
322
+ statesText = statesText.replace(/\n/g, " ");
323
+ // One state per " · " or "," separator - but only at parenthesis depth 0:
324
+ // a comma inside a parenthetical is part of that state's description, not
325
+ // a state boundary. Naive splitting yields fragments like "terminal)" that
326
+ // no source can contain, making a journey structurally unable to pass.
327
+ const parts = [];
328
+ let depth = 0;
329
+ let current = "";
330
+ for (const ch of statesText) {
331
+ if (ch === "(")
332
+ depth++;
333
+ else if (ch === ")")
334
+ depth = Math.max(0, depth - 1);
335
+ if (depth === 0 && (ch === "," || ch === "·")) {
336
+ parts.push(current);
337
+ current = "";
338
+ continue;
339
+ }
340
+ current += ch;
341
+ }
342
+ parts.push(current);
343
+ // Each state's keyword is the text before the first parenthetical,
344
+ // normalised for a case-insensitive substring probe.
345
+ const keywords = [];
346
+ for (const part of parts) {
347
+ const keyword = part
348
+ .split("(")[0]
349
+ .trim()
350
+ .toLowerCase()
351
+ .replace(/\s+/g, " ")
352
+ .replace(/\.$/, "");
353
+ if (keyword)
354
+ keywords.push(keyword);
355
+ }
356
+ const lowered = source.toLowerCase();
357
+ const missing = keywords.filter((keyword) => !lowered.includes(keyword));
358
+ if (missing.length === 0)
359
+ return;
360
+ // One aggregate warn, never per-keyword findings and never an empty-list
361
+ // finding. Warn (not error) because blueprint phrases are prose - a state
362
+ // can be fully implemented under different wording.
363
+ const list = missing.join(", ");
364
+ push("blueprint-state-missing", "warn", list, `The ${journey} blueprint names ${keywords.length} states. These were not found by name in this source: ${list}. Either they are missing or they render under different wording - check each by hand.`, 0);
365
+ }
366
+ function checkFixtureHonesty(source, push) {
367
+ // Fixture honesty. A demo that seeds parity rates or round-number amounts
368
+ // teaches false patterns: parity hides the crypto/fiat unit distinction,
369
+ // and round numbers let a total look derivable when it is coincidence.
370
+ // ERROR - an explicit parity rate seeded on a rate-named field. Anchored to
371
+ // the three rate names so counters like `rateLimit: 1` never trip it.
372
+ for (const match of source.matchAll(/\b(?:exchangeRate|rate|fxRate)\s*[:=]\s*1(?:\.0+)?\b/g)) {
373
+ push("parity-fixture", "error", match[0], "A parity exchange rate makes the crypto/fiat unit distinction numerically invisible, which is the falsehood a real quoted rate exists to prevent. Seed a real non-parity rate.", match.index ?? 0);
374
+ }
375
+ // WARN - three or more round-number amounts (x.00) in one source. Comment
376
+ // lines are skipped: this sibling judges seeded copy/fixtures, and prose
377
+ // like "may display as 0.00" is documentation, not a seeded amount. One
378
+ // finding per source, anchored at the first counted match.
379
+ let roundCount = 0;
380
+ let firstRoundIdx = -1;
381
+ for (const match of source.matchAll(/\b\d+\.00\b/g)) {
382
+ const idx = match.index ?? 0;
383
+ if (isCommentLineAt(source, idx))
384
+ continue;
385
+ if (firstRoundIdx === -1)
386
+ firstRoundIdx = idx;
387
+ roundCount++;
388
+ }
389
+ if (roundCount >= 3) {
390
+ push("round-number-coincidence", "warn", `${roundCount} round-number (.00) amounts seeded in one source`, "Round-number fixtures hide arithmetic. If a total is coincidentally equal to a part, the screen teaches a false pattern - use amounts that do not divide evenly.", firstRoundIdx);
391
+ }
392
+ }
95
393
  const AGENTS_TEXT = `# Composition rules for coding agents building on the Venly UI registry
96
394
 
97
395
  Delivery: the shadcn CLI expects a working shadcn environment BEFORE any
@@ -104,7 +402,7 @@ registry install. On a fresh Vite/React app that means, in order:
104
402
  works - it never imports base-library components itself).
105
403
  3. Add the registry once to components.json -
106
404
  { "registries": { "@venlyfinance": "${REGISTRY_URL_TEMPLATE}" } }
107
- 4. \`npx shadcn@latest add @venlyfinance/receive @venlyfinance/send @venlyfinance/activity -y -o\`.
405
+ 4. \`npx shadcn@latest add @venlyfinance/balances @venlyfinance/activity @venlyfinance/receive @venlyfinance/send @venlyfinance/auth @venlyfinance/team @venlyfinance/onboarding -y -o\`.
108
406
  Each block auto-installs its components, the venly-tokens file AND its
109
407
  npm dependencies (@venlyfinance/react, @venlyfinance/sdk, TanStack
110
408
  Query) - no separate npm install step is needed.
@@ -128,8 +426,14 @@ installed venly-tokens css once at the app root.
128
426
  on "stale-version" refetch and let the operator re-decide.
129
427
  5. Theme by editing the installed venly-tokens css file and nothing else.
130
428
  6. Before declaring a screen done, run the review_screen tool on its source
131
- and fix every error-severity finding. Consult get_journey_blueprint
132
- before designing a screen the registry has no block for.
429
+ (pass the journey key so blueprint state coverage is checked too) and fix
430
+ every error-severity finding. Consult get_journey_blueprint before
431
+ designing a screen the registry has no block for.
432
+ 7. Wire the same audit into the app you generate as a CI step - it is what
433
+ turns the design contract into a gate:
434
+ \`npx @venlyfinance/settlement-mcp review "src/**/*.tsx"\`
435
+ (exit 1 on any error-severity finding). A deliberate, justified exception
436
+ carries venly-allow:<rule-id> on the offending line or the line above.
133
437
  `;
134
438
  export function registerFrontendTools(server) {
135
439
  server.registerTool("get_journey_blueprint", {
@@ -143,12 +447,16 @@ export function registerFrontendTools(server) {
143
447
  }));
144
448
  server.registerTool("review_screen", {
145
449
  title: "Design-audit a screen",
146
- description: "Deterministic audit of component/markup source against the kit's design contract: raw colours, hyphen-minus amounts, success styling on cancelled steps, masked review values, zebra striping, off-token shadows, gradients, colour-only state. Returns findings, not a score.",
450
+ description: "Deterministic audit of component/markup source against the kit's design contract: raw colours, hyphen-minus amounts, success styling on cancelled steps, masked review values, invented timing/custody copy, crypto codes inside Intl currency formatting, required fields rendered optional, parity and round-number fixtures, zebra striping, off-token shadows, gradients, colour-only state. Pass the journey key to also check the source against that journey's required blueprint states. Suppress a deliberate exception with venly-allow:<rule-id> on the offending line or the line above. Returns findings, not a score.",
147
451
  inputSchema: {
148
452
  source: z.string().min(1).describe("The component/markup/CSS source to audit"),
453
+ journey: z
454
+ .enum(JOURNEY_KEYS)
455
+ .optional()
456
+ .describe("Optional: which journey this screen implements - enables the blueprint state-coverage check"),
149
457
  },
150
- }, async ({ source }) => {
151
- const findings = reviewScreenSource(source);
458
+ }, async ({ source, journey }) => {
459
+ const findings = reviewScreenSource(source, journey);
152
460
  return {
153
461
  content: [
154
462
  {
package/dist/index.js CHANGED
@@ -27,7 +27,24 @@ async function main() {
27
27
  : "writes DISARMED: mutations return dry-run previews (arming needs confirm:true + VENLY_MCP_LIVE=1 + credentials)";
28
28
  process.stderr.write(`venly-finance-mcp started in ${client.environment}. ${writeState}.\n`);
29
29
  }
30
- main().catch((err) => {
31
- process.stderr.write(`Fatal: ${err.message}\n`);
32
- process.exit(1);
33
- });
30
+ const argv = process.argv.slice(2);
31
+ if (argv[0] === "review") {
32
+ // Design-audit CLI mode: `... review "src/**/*.tsx"`. Dynamic import so the
33
+ // MCP/SDK path is never touched; MCP hosts launch with zero args, so plain
34
+ // startup is unchanged.
35
+ import("./review-cli.js")
36
+ .then(({ runReviewCli }) => runReviewCli(argv.slice(1)))
37
+ .then((code) => {
38
+ process.exitCode = code;
39
+ })
40
+ .catch((err) => {
41
+ process.stderr.write(`Fatal: ${err.message}\n`);
42
+ process.exit(2);
43
+ });
44
+ }
45
+ else {
46
+ main().catch((err) => {
47
+ process.stderr.write(`Fatal: ${err.message}\n`);
48
+ process.exit(1);
49
+ });
50
+ }
package/dist/resources.js CHANGED
@@ -15,14 +15,16 @@ Venly Finance provides financial infrastructure through several regulated partne
15
15
  - fiat-to-crypto payment sessions;
16
16
  - account-to-account fiat-denominated and crypto-denominated transfers;
17
17
  - payment-request authorization, settlement and reversal primitives;
18
+ - third-party payouts (contract 1.3.0): beneficiary bank accounts registered per party (SEPA and US ACH, masked details), payout routes activated by wallet-ownership proof, and payouts with a full lifecycle (REQUESTED through COMPLETED, REJECTED, FAILED or RETURNED, failures carrying a reason);
18
19
  - Fundflow on/off-ramp workflows with four-eyes approval.
19
20
 
20
21
  Current boundaries:
21
22
 
22
- - EUR is the currently documented virtual-bank-account currency. Do not infer global bank-account coverage.
23
+ - EUR SEPA and USD ACH are the documented virtual-bank-account types. Do not infer global bank-account coverage.
23
24
  - Creating a party does not complete KYC/KYB. Live virtual-bank-account provisioning requires a VERIFIED account.
25
+ - The payout surface is on the QA contract (Finance API 1.3.0); production may trail it. Verify against the environment you target.
24
26
  - Card issuing is not exposed by the current Finance OpenAPI contract.
25
- - A bank charter, deposit insurance and external-bank payout coverage are not supplied or implied by this MCP.
27
+ - A bank charter and deposit insurance are not supplied or implied by this MCP.
26
28
  - Production x402 settlement is not implemented; the x402 tool is a quote-only stub.
27
29
  `,
28
30
  },
@@ -33,7 +35,7 @@ Current boundaries:
33
35
  description: "Environment, write, compliance and secret-handling rules.",
34
36
  text: `# Venly Finance MCP safety
35
37
 
36
- - Set VENLY_ENV explicitly to mock, staging or production. An absent value defaults to mock (since 0.3.0), so an unconfigured server never points at real infrastructure.
38
+ - Set VENLY_ENV explicitly to mock, qa, staging or production. An absent value defaults to mock (since 0.3.0), so an unconfigured server never points at real infrastructure.
37
39
  - Mock mode uses synthetic SDK fixtures, no credentials and no network. Every mutation result is labelled mode=mock.
38
40
  - Staging writes require confirm=true, VENLY_MCP_LIVE=1 and VENLY_CLIENT_ID/VENLY_CLIENT_SECRET.
39
41
  - Production requires every staging gate plus VENLY_MCP_PRODUCTION=1.
@@ -0,0 +1,4 @@
1
+ /** Expand one glob pattern (posix-style separators) relative to cwd. */
2
+ export declare function expandPattern(pattern: string, cwd: string): string[];
3
+ export declare function expandPatterns(patterns: string[], cwd: string): string[];
4
+ export declare function runReviewCli(args: string[], out?: NodeJS.WritableStream, err?: NodeJS.WritableStream): Promise<0 | 1 | 2>;
@@ -0,0 +1,131 @@
1
+ // `review` subcommand: the review_screen design audit as a CI gate.
2
+ //
3
+ // npx @venlyfinance/settlement-mcp review "src/**/*.tsx"
4
+ //
5
+ // Exit codes: 0 clean (warnings allowed, printed either way) · 1 at least one
6
+ // error-severity finding · 2 usage error or a pattern that matched nothing
7
+ // (a typo'd path must never pass CI silently).
8
+ //
9
+ // Patterns are self-expanded (**, *, {a,b}) so the quoted form works on any
10
+ // shell; unquoted shell-expanded literal paths work too. No dependencies.
11
+ // (Line comments on purpose: a glob's **/ would terminate a block comment.)
12
+ import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
13
+ import { join, relative } from "node:path";
14
+ import { reviewScreenSource } from "./frontend.js";
15
+ const SKIP_DIRS = new Set(["node_modules", ".git", "dist"]);
16
+ function braceExpand(pattern) {
17
+ const m = /\{([^{}]*)\}/.exec(pattern);
18
+ if (!m)
19
+ return [pattern];
20
+ const before = pattern.slice(0, m.index);
21
+ const after = pattern.slice(m.index + m[0].length);
22
+ const out = [];
23
+ for (const option of m[1].split(",")) {
24
+ out.push(...braceExpand(before + option + after));
25
+ }
26
+ return out;
27
+ }
28
+ function patternToRegExp(pattern) {
29
+ // Escape everything regex-special except the glob characters we translate.
30
+ const escaped = pattern.replace(/[.+^$()|[\]\\?]/g, "\\$&");
31
+ const translated = escaped
32
+ .replace(/\*\*\//g, "\u0000") // **/ may match zero segments
33
+ .replace(/\*\*/g, "\u0001") // a bare ** matches anything
34
+ .replace(/\*/g, "[^/]*")
35
+ .replace(/\u0000/g, "(?:.*/)?")
36
+ .replace(/\u0001/g, ".*");
37
+ return new RegExp(`^${translated}$`);
38
+ }
39
+ function walk(dir, into) {
40
+ let entries;
41
+ try {
42
+ entries = readdirSync(dir, { withFileTypes: true });
43
+ }
44
+ catch {
45
+ return;
46
+ }
47
+ for (const entry of entries) {
48
+ if (entry.isDirectory()) {
49
+ if (!SKIP_DIRS.has(entry.name))
50
+ walk(join(dir, entry.name), into);
51
+ }
52
+ else if (entry.isFile()) {
53
+ into.push(join(dir, entry.name));
54
+ }
55
+ }
56
+ }
57
+ /** Expand one glob pattern (posix-style separators) relative to cwd. */
58
+ export function expandPattern(pattern, cwd) {
59
+ const results = [];
60
+ for (const variant of braceExpand(pattern)) {
61
+ const segments = variant.split("/");
62
+ const firstWild = segments.findIndex((s) => s.includes("*"));
63
+ if (firstWild === -1) {
64
+ if (existsSync(join(cwd, variant)) && statSync(join(cwd, variant)).isFile()) {
65
+ results.push(variant);
66
+ }
67
+ continue;
68
+ }
69
+ const staticPrefix = segments.slice(0, firstWild).join("/");
70
+ const root = staticPrefix ? join(cwd, staticPrefix) : cwd;
71
+ const files = [];
72
+ walk(root, files);
73
+ const matcher = patternToRegExp(variant);
74
+ for (const file of files) {
75
+ // relative(), not string slicing: a pattern like "../ui/**/*.tsx"
76
+ // walks outside cwd, where prefix slicing produces garbage.
77
+ const rel = relative(cwd, file).split("\\").join("/");
78
+ if (matcher.test(rel))
79
+ results.push(rel);
80
+ }
81
+ }
82
+ return [...new Set(results)].sort();
83
+ }
84
+ export function expandPatterns(patterns, cwd) {
85
+ const out = [];
86
+ for (const pattern of patterns) {
87
+ if (/[*{]/.test(pattern)) {
88
+ out.push(...expandPattern(pattern.split("\\").join("/"), cwd));
89
+ }
90
+ else {
91
+ out.push(pattern); // shell-expanded or literal; existence checked by caller
92
+ }
93
+ }
94
+ return [...new Set(out)];
95
+ }
96
+ export async function runReviewCli(args, out = process.stdout, err = process.stderr) {
97
+ const patterns = args.filter((a) => !a.startsWith("-"));
98
+ if (patterns.length === 0) {
99
+ err.write('Usage: review "<glob>" [more globs or files]\n' +
100
+ ' e.g. review "src/**/*.tsx"\n' +
101
+ "Exits 1 on any error-severity finding, 2 when nothing matched.\n");
102
+ return 2;
103
+ }
104
+ const cwd = process.cwd();
105
+ const files = expandPatterns(patterns, cwd);
106
+ const missing = files.filter((f) => !existsSync(f));
107
+ if (missing.length > 0) {
108
+ err.write(`No such file: ${missing.join(", ")}\n`);
109
+ return 2;
110
+ }
111
+ if (files.length === 0) {
112
+ err.write(`Nothing matched: ${patterns.join(" ")}\n`);
113
+ return 2;
114
+ }
115
+ let errors = 0;
116
+ let warnings = 0;
117
+ for (const file of files) {
118
+ const findings = reviewScreenSource(readFileSync(file, "utf8"));
119
+ for (const finding of findings) {
120
+ if (finding.severity === "error")
121
+ errors++;
122
+ else
123
+ warnings++;
124
+ const line = finding.line === undefined ? "" : `:${finding.line}`;
125
+ out.write(`${file}${line} ${finding.severity} ${finding.rule} ${finding.evidence}\n`);
126
+ out.write(` fix: ${finding.fix}\n`);
127
+ }
128
+ }
129
+ out.write(`${errors} error(s), ${warnings} warning(s) across ${files.length} file(s)\n`);
130
+ return errors > 0 ? 1 : 0;
131
+ }