Setting the file. One moment.
Gateway Summary · Rp Source Wordpress · wix/skills · Skills Docs
ContentsBack to the top of the page 39.41
PLUGIN READ PLANS
This file
Number 39.17
Position 17 of 92
Type JavaScript
Size 14 KB
Lines 333 lib/ gateway-summary.mjs
JavaScript · 333 lines · 14 KB
14 // is in the model's context, the transcript, and potentially the run artifacts, so there is no
15 // careful-handling mitigation available above this line. The value has to be dropped here.
16 //
17 // THE DEFAULT IS VALUE-FREE. A field's NAME and STATE are reported without its value, which is
18 // what discovery actually needs -- which gateways are registered, which are enabled, whether a
19 // credential is configured. Only a short reviewed allowlist of display and behaviour fields
20 // returns a value at all, and even those pass the shared secret-shape guard first, because
21 // `description` is free text a vendor or an attacker controls. Matching credential-shaped
22 // field NAMES and passing everything else through was the earlier design and it was the wrong
23 // trade: a field called `terminal_number` defeats it, and no name list can anticipate what a
24 // gateway vendor calls its key.
25
26 import { REDACTED, isSecretShaped, isUsable, unusableReason } from './secret-value-guard.js' ;
27
28 /** The one route this module's output can describe. Reported so a reader knows what was read. */
29 export const GATEWAY_COLLECTION_ROUTE = '/wc/v3/payment_gateways' ;
30
31 /** Request-level outcomes. A SEPARATE axis from field state -- see FIELD_STATES. */
32 export const REQUEST_OUTCOMES = Object. freeze ([ 'ok' , 'unauthorized' , 'unreachable' , 'malformed' ]);
33
34 /**
35 * Field states, and the whole vocabulary.
36 *
37 * present the field exists and holds a non-empty value
38 * blank the field exists and is empty
39 * absent the gateway does not define this field
40 *
41 * The secret-resolution states `local_present` / `secret_present` / `missing` / `placeholder` describe secret
42 * RESOLUTION across a local env file and a secret store. That is a different question from
43 * inspecting a live gateway field, and the two vocabularies must not be merged. What IS
44 * inherited from that vocabulary is its output contract: tooling emits names and states, and emits a
45 * value only where a reviewed allowlist says the field is display configuration.
46 */
47 export const FIELD_STATES = Object. freeze ([ 'present' , 'blank' , 'absent' ]);
48
49 /**
50 * Fields whose VALUES may be returned, because a mapper genuinely needs them. Display text and
51 * behavioural configuration -- never credentials.
52 *
53 * Adding an entry is a one-line reviewable decision. Failing to predict what a vendor calls its
54 * credential is not, which is why this is an allowlist of values rather than a denylist of
55 * names. An unrecognised field costs its value, not discovery: the caller still learns the
56 * field exists and whether it is set.
57 */
58 export const VALUE_ALLOWLIST = Object. freeze ([
59 'title' ,
60 'description' ,
61 'enabled' ,
62 'order' ,
63 'order_status' ,
64 'method_title' ,
65 'method_description' ,
66 'instructions' ,
67 'testmode' ,
68 'test_mode' ,
69 'checkout_label' ,
70 ]);
71
72 /**
73 * The credential-like name list, kept here as belt and braces rather than as the
74 * control. If a field is on VALUE_ALLOWLIST and ALSO reads as a credential name, the allowlist
75 * itself is wrong; the value is dropped and a warning is recorded rather than resolving the
76 * contradiction silently in either direction.
77 */
78 const CREDENTIAL_NAME_FRAGMENTS = Object. freeze ([
79 'password' , 'secret' , 'token' , 'apikey' , 'consumerkey' , 'authorization' , 'cookie' , 'privatekey' ,
80 ]);
81
82 /**
83 * Properties of the gateway object that the summary reports in their own right, so they are not
84 * repeated as fields.
85 */
86 const HOISTED_PROPERTIES = new Set ([
87 'id' , 'title' , 'enabled' , 'order' , 'settings' ,
88 // REST envelope, not gateway configuration: `_links` holds hrefs back to the same routes the
89 // transport refuses. Dropping it entirely keeps the summary about the gateway.
90 '_links' ,
91 ]);
92
93 /**
94 * Properties WooCommerce's payment-gateway schema declares on every gateway. Listing them is
95 * what makes `absent` a real observation instead of a state nothing can ever produce: a gateway
96 * that omits `method_description` is reported `absent`, distinct from one that defines it empty.
97 */
98 const DECLARED_GATEWAY_FIELDS = Object. freeze ([ 'description' , 'method_title' , 'method_description' , 'method_supports' ]);
99
100 /** Sentinel for "the gateway does not define this field". Not a value; never returned. */
101 const ABSENT = Symbol ( 'absent' );
102
103 /** Whether the shared secret-shape guard can run at all. See `guardUnusableReason`. */
104 export function guardIsOperational () {
105 return isUsable ();
106 }
107
108 /**
109 * Why the guard cannot run, or null when it is fine.
110 *
111 * A caller that is about to read gateway configuration must check this FIRST and refuse the
112 * read rather than proceed unguarded. A guard that silently evaluates an empty pattern set
113 * returns values verbatim, which is the failure mode worth being loud about.
114 */
115 export function guardUnusableReason () {
116 return unusableReason ();
117 }
118
119 function normalizeFieldName ( name ) {
120 return String (name). toLowerCase (). replace ( / [ ^ a-z0-9] / g , '' );
121 }
122
123 function isCredentialShapedName ( name ) {
124 const normalized = normalizeFieldName (name);
125 return CREDENTIAL_NAME_FRAGMENTS . some (( fragment ) => normalized. includes (fragment));
126 }
127
128 /**
129 * A WooCommerce settings entry is `{ id, label, type, value, default, ... }`; some gateways and
130 * some plugins hand back the bare value instead. Both are accepted, and neither the label nor
131 * the tip is read -- only whether there is a value and, for an allowlisted field, what it is.
132 */
133 function settingEntryValue ( entry ) {
134 if (entry && typeof entry === 'object' && ! Array. isArray (entry) && Object. hasOwn (entry, 'value' )) {
135 return entry.value;
136 }
137 return entry;
138 }
139
140 /**
141 * Whether a returnable string is, or CONTAINS, a secret-shaped token.
142 *
143 * The shared guard matches a whole value, prefix-not-substring — deliberately, because that is
144 * what keeps a 44-character payment-pointer id from being eaten by the long-opaque rule on the
145 * plugin's side, where a column value IS the whole value. Here the returnable fields are free
146 * text: `description` is a paragraph a merchant typed, and a support key pasted mid-sentence
147 * would sail through a whole-value check.
148 *
149 * So the same guard is applied at token granularity. This never widens what counts as
150 * secret-shaped — every verdict still comes from the one canonical manifest — it only asks the
151 * question of each token as well as of the whole string. A hit redacts the WHOLE value, never
152 * the surrounding words: returning "Pay securely. Support key [REDACTED]" would confirm exactly
153 * where the credential sits and how the rest of the sentence reads.
154 */
155 function containsSecretShapedToken ( text ) {
156 if ( isSecretShaped (text)) return true ;
157 for ( const token of text. split ( / [\s,;:"'<>()[ \] {}] + / )) {
158 if (token && token !== text && isSecretShaped (token)) return true ;
159 }
160 return false ;
161 }
162
163 function stateOf ( value ) {
164 if (value === ABSENT ) return 'absent' ;
165 if (value === null || value === undefined || value === '' ) return 'blank' ;
166 if (Array. isArray (value)) return value. length === 0 ? 'blank' : 'present' ;
167 if ( typeof value === 'object' ) return Object. keys (value). length === 0 ? 'blank' : 'present' ;
168 return 'present' ;
169 }
170
171 /**
172 * Whether one field's value may be returned, and in what form.
173 *
174 * Returns `{ include, value?, warning?, redacted? }`. `include: false` means the `value`
175 * property is omitted from the field entry ENTIRELY -- not `null`, not `''` -- so "we withheld
176 * this" stays distinguishable from "this field is empty", which is what `state: "blank"` means.
177 *
178 * `allowlist` is a parameter so the allowlist/credential-name contradiction can be exercised
179 * directly by a test; there is no name on the shipped allowlist that is also credential-shaped,
180 * and there had better never be. It is safe as a seam because this function is pure and decides
181 * only what to do with a value the caller already holds -- it grants no access to anything.
182 * `reduceGatewayResponse` always uses the default, and nothing else in the read path takes an
183 * allowlist at all.
184 */
185 export function valueDecision ( name , state , value , allowlist = VALUE_ALLOWLIST ) {
186 if ( ! allowlist. includes (name)) {
187 return { include: false };
188 }
189 if ( isCredentialShapedName (name)) {
190 return {
191 include: false ,
192 warning: `field "${ name }" is on the value allowlist and also reads as a credential name — value dropped; fix the allowlist` ,
193 };
194 }
195 if (state !== 'present' ) {
196 // `blank` and `absent` have no value to return, and `state` already says so.
197 return { include: false };
198 }
199 if ( typeof value === 'string' ) {
200 // The allowlist grants permission to return A value. It never grants permission to skip the
201 // guard: an allowlisted field is display text under vendor control, and nothing stops
202 // `description` holding a pasted live key.
203 if ( containsSecretShapedToken (value)) {
204 return { include: true , value: REDACTED , redacted: true };
205 }
206 return { include: true , value };
207 }
208 if ( typeof value === 'number' || typeof value === 'boolean' ) {
209 return { include: true , value };
210 }
211 // An object or array under an allowlisted name would carry unmodelled properties straight
212 // through the summary. Report that it is set; do not carry its shape.
213 return {
214 include: false ,
215 warning: `field "${ name }" is allowlisted but holds a ${ Array . isArray ( value ) ? 'list' : 'structured'} value — value withheld` ,
216 };
217 }
218
219 function guardedText ( value ) {
220 if ( typeof value !== 'string' ) return null ;
221 return containsSecretShapedToken (value) ? REDACTED : value;
222 }
223
224 function reduceGateway ( gateway , totals , warnings ) {
225 const settings = gateway && typeof gateway.settings === 'object' && ! Array. isArray (gateway.settings)
226 ? gateway.settings
227 : {};
228
229 // Settings win over a same-named top-level property: WooCommerce mirrors `description` and
230 // `title` into both, and the settings entry is the one that holds what the merchant configured.
231 const values = new Map ();
232 for ( const [ key , value ] of Object. entries (gateway)) {
233 if ( HOISTED_PROPERTIES . has (key)) continue ;
234 values. set (key, value);
235 }
236 for ( const [ key , entry ] of Object. entries (settings)) {
237 values. set (key, settingEntryValue (entry));
238 }
239 for ( const declared of DECLARED_GATEWAY_FIELDS ) {
240 if ( ! values. has (declared)) values. set (declared, ABSENT );
241 }
242
243 const fields = [];
244 for ( const name of [ ... values. keys ()]. sort ()) {
245 const raw = values. get (name);
246 const state = stateOf (raw);
247 if (state === 'present' ) totals.fieldsPresent += 1 ;
248
249 const decision = valueDecision (name, state, raw);
250 if (decision.warning) warnings. push ( `${ gateway . id ?? 'unknown gateway'}: ${ decision . warning }` );
251
252 // Built key by key, never spread from the source object: a raw-response property that is
253 // not modelled here must not be able to ride along into the summary.
254 const field = { name: String (name), state };
255 if (decision.include) {
256 field.value = decision.value;
257 totals.valuesReturned += 1 ;
258 if (decision.redacted) totals.valuesRedacted += 1 ;
259 } else {
260 totals.valuesWithheld += 1 ;
261 }
262 fields. push (field);
263 }
264
265 const order = Number. isFinite ( Number (gateway.order)) && gateway.order !== '' && gateway.order !== null
266 ? Number (gateway.order)
267 : null ;
268
269 return {
270 id: guardedText (gateway.id),
271 title: guardedText (gateway.title),
272 enabled: gateway.enabled === true || gateway.enabled === 'yes' ,
273 order,
274 fields,
275 };
276 }
277
278 /**
279 * Reduce a `wc/v3/payment_gateways` response to the summary schema.
280 *
281 * Input is the transport's result shape (`{ ok, status, json }`) -- plain data, not a client.
282 * Output carries ONLY the properties defined here. Never a prefix or suffix of a withheld
283 * value, never its length, never a hash of it, never whether two fields are equal.
284 *
285 * A failed request yields ZERO field entries rather than a page of `absent`, so "this gateway
286 * has no API key" can never be confused with "we could not ask".
287 */
288 export function reduceGatewayResponse ( response ) {
289 const warnings = [];
290 const totals = { gateways: 0 , enabled: 0 , fieldsPresent: 0 , valuesReturned: 0 , valuesWithheld: 0 , valuesRedacted: 0 };
291 const empty = ( outcome ) => ({ route: GATEWAY_COLLECTION_ROUTE , outcome, gateways: [], totals, warnings });
292
293 if ( ! response || typeof response !== 'object' ) {
294 return empty ( 'unreachable' );
295 }
296 if (response.status === 401 || response.status === 403 ) {
297 return empty ( 'unauthorized' );
298 }
299 if ( ! response.ok) {
300 return empty ( 'unreachable' );
301 }
302 if ( ! Array. isArray (response.json)) {
303 return empty ( 'malformed' );
304 }
305
306 const gateways = [];
307 for ( const entry of response.json) {
308 if ( ! entry || typeof entry !== 'object' || Array. isArray (entry)) {
309 warnings. push ( 'a gateway entry was not an object and was skipped' );
310 continue ;
311 }
312 const gateway = reduceGateway (entry, totals, warnings);
313 totals.gateways += 1 ;
314 if (gateway.enabled) totals.enabled += 1 ;
315 gateways. push (gateway);
316 }
317
318 return { route: GATEWAY_COLLECTION_ROUTE , outcome: 'ok' , gateways, totals, warnings };
319 }
320
321 /**
322 * One line for the approval gate's plan report. A run that states what it read
323 * and what it retained is auditable; one that silently read the settings blob is not.
324 */
325 export function describeGatewaySummary ( summary ) {
326 if ( ! summary || summary.outcome !== 'ok' ) {
327 return `Payment gateways: not read (${ summary ?. outcome ?? 'unreachable'}); no gateway configuration retained.` ;
328 }
329 const t = summary.totals;
330 return `Payment gateways: read ${ summary . route } — ${ t . gateways } registered, ${ t . enabled } enabled, `
331 + `${ t . fieldsPresent } configured field(s), ${ t . valuesWithheld } value(s) withheld, `
332 + `${ t . valuesRedacted } redacted, ${ t . valuesReturned } display value(s) retained. No credential value retained.` ;
333 }