@agent-cards/sdk 0.22.0 → 0.23.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.
@@ -2,18 +2,26 @@
2
2
  //
3
3
  // Some merchants post the typed card from their own checkout page straight to their
4
4
  // own server, with no processor in between (AT&T, Italo, Universal Orlando, Target
5
- // Optical, Opus Virtual Offices). Agentcard reviews each one and pins it as a profile
6
- // in the payment core (merchant-card-endpoints.generated.ts, the SDK's copy of the
7
- // same rules the API and the Vault run): the page the card is typed into, the exact
8
- // origin, path and query of the card request, which session headers and cookies it
9
- // needs, and which answer headers may travel back.
5
+ // Optical, Opus Virtual Offices, Delta, Hilton, IHG, American Airlines, EconomyBookings,
6
+ // The Verb Hotel, RunSignUp, Mindbody, LookFantastic), some after their page encrypts it
7
+ // under a key the merchant publishes (Hyatt, LifeMiles, Southwest, United): the paused
8
+ // body then holds the placeholder card only as the page encrypted it. Agentcard reviews
9
+ // each one and pins it as a profile in the payment core (merchant-card-endpoints.generated.ts,
10
+ // the SDK's copy of the same rules the API and the Vault run): the page the card is
11
+ // typed into, the exact origin, path and query of the card request, which session
12
+ // headers and cookies it needs, and which answer headers may travel back.
10
13
  //
11
14
  // The adapters arm every profile's endpoint. A POST that is exactly a profile's card
12
- // request pauses. While this build's copy of the profile does not take payments
13
- // (observe, a fact only a partner HAR can pin, no reviewed HAR, a recurring charge)
14
- // the request is aborted as an unsupported checkout and nothing is created. Otherwise
15
+ // request URL pauses when it carries a card (cardEndpointCarriesCard); one that carries
16
+ // none, such as a stay paid with points or a card a processor's own fields tokenized,
17
+ // is the merchant's own business and continues untouched. While this build's copy of
18
+ // the profile does not take payments (observe, a fact only a partner HAR can pin, no
19
+ // reviewed HAR, a recurring charge) the card request is aborted as an unsupported
20
+ // checkout and nothing is created. Otherwise
15
21
  // the adapter captures the request's session (the profile's own header list, the
16
- // endpoint's cookie jar for a cookie profile, a Referer on the checkout page) and
22
+ // endpoint's cookie jar for a cookie profile, a Referer on the page the request is sent
23
+ // from: the merchant's card vault frame when the profile names one, else the checkout
24
+ // page) and
17
25
  // hands it with the paused body, which holds only the placeholder card the agent
18
26
  // typed, to the create. The cardholder's device sends the real card request to the
19
27
  // merchant itself, over the checkout relay, and reports the merchant's answer; the
@@ -22,11 +30,28 @@
22
30
  // write replaces) and answers the paused request with it. The real card never reaches
23
31
  // this process or the agent's page.
24
32
  //
33
+ // Some merchants' pages submit the card form as a page load, not a fetch (a profile
34
+ // whose `navigation` is 'top', or 'frame' for a form inside an iframe): MyRec, Conduent
35
+ // eTIMS, the D-EDGE card frame. For those the adapters let a post that carries no card
36
+ // through untouched (a cancel, another payment method), refuse a card post that is not
37
+ // that navigation, answer the paid one with the merchant's page or a GET redirect, and
38
+ // answer every stopped one with a page that says what happened (card-endpoint-page.ts),
39
+ // never with an aborted load that invites another click on Pay.
40
+ //
41
+ // Some merchants need more (FastSpring's popup checkout for Lexicon). The card is typed
42
+ // into a frame the merchant's page embeds, so for a profile with checkout_frame the page
43
+ // a request pauses on is that frame's own document. The same URL also takes card-free
44
+ // payments (PayPal, wallets), which continue untouched (cardEndpointPasses). A request
45
+ // that sends the typed card again outside the card request is aborted in every status
46
+ // (cardEndpointResendFor). And the merchant's own record of the order, which its page
47
+ // reads from the endpoint origin, is recorded read-only (card-endpoint-facts.ts) and sent
48
+ // with the create, so the agent's amount is held to the merchant's total.
49
+ //
25
50
  // The endpoint URL (it carries cart, booking and instance ids), the paused body, the
26
51
  // session and the answer are cardholder data even with no real card in them: no
27
52
  // event, error or log line from this module names any of them. Events name the
28
53
  // profile id and the endpoint origin only.
29
- import { MERCHANT_CARD_ENDPOINT_MODE as SHARED_MODE, MERCHANT_CARD_ENDPOINT_PSP as SHARED_PSP, MERCHANT_CARD_ENDPOINT_RECOGNIZERS as SHARED_RECOGNIZERS, matchMerchantCardEndpoint as sharedMatch, merchantCardEndpointLive as sharedLive, merchantCardEndpointSessionRefusal as sharedSessionRefusal, } from './merchant-card-endpoints.generated.js';
54
+ import { MERCHANT_CARD_ENDPOINT_MODE as SHARED_MODE, MERCHANT_CARD_ENDPOINT_PSP as SHARED_PSP, MERCHANT_CARD_ENDPOINT_RECOGNIZERS as SHARED_RECOGNIZERS, matchMerchantCardEndpoint as sharedMatch, matchMerchantCardEndpointResend as sharedResend, merchantCardEndpointBodyRefusal as sharedBodyRefusal, merchantCardEndpointCarriesCard as sharedCarriesCard, merchantCardEndpointLive as sharedLive, merchantCardEndpointNavigationAnswer as sharedNavigationAnswer, merchantCardEndpointPasses as sharedPasses, merchantCardEndpointSessionRefusal as sharedSessionRefusal, } from './merchant-card-endpoints.generated.js';
30
55
  /** The psp and mode a card endpoint authorization carries; neither is a processor. */
31
56
  export const CARD_ENDPOINT_PSP = SHARED_PSP;
32
57
  export const CARD_ENDPOINT_MODE = SHARED_MODE;
@@ -43,7 +68,7 @@ export class CardEndpointRefusedError extends Error {
43
68
  this.name = 'CardEndpointRefusedError';
44
69
  }
45
70
  /** True when every later request on the attachment would be refused the same way. */
46
- get permanent() { return this.reason !== 'request_unsupported'; }
71
+ get permanent() { return !['request_unsupported', 'facts_unavailable', 'facts_refused'].includes(this.reason); }
47
72
  }
48
73
  /** The reviewed profile (any status) whose card request `url` and `method` are exactly, or null. */
49
74
  export function cardEndpointFor(url, method) {
@@ -58,10 +83,99 @@ export function cardEndpointFor(url, method) {
58
83
  export function cardEndpointById(id) {
59
84
  return PROFILES.find((profile) => profile.id === id) ?? null;
60
85
  }
86
+ /**
87
+ * Whether a request to the profile's card endpoint is a card-free payment (the profile's
88
+ * payment_method names another method in the body): it is never Agentcard's and
89
+ * continues untouched, in every status. False for anything the rule cannot read, which
90
+ * is then held to the card request's rules.
91
+ */
92
+ export function cardEndpointPasses(profile, body, contentType) {
93
+ if (typeof body !== 'string')
94
+ return false;
95
+ try {
96
+ return sharedPasses(profile.id, body, contentType) === true;
97
+ }
98
+ catch {
99
+ return false;
100
+ }
101
+ }
102
+ /** The reviewed profile (any status) one of whose resend requests `url` and `method` are, or null: the adapters abort it. */
103
+ export function cardEndpointResendFor(url, method) {
104
+ try {
105
+ return sharedResend(url, method) ?? null;
106
+ }
107
+ catch {
108
+ return null;
109
+ }
110
+ }
111
+ /** The origins of the profiles whose merchant keeps its own record of the order: the only responses card-endpoint-facts.ts records. */
112
+ export function cardEndpointFactsOrigins() {
113
+ return [...new Set(PROFILES.filter((profile) => profile.facts).map((profile) => profile.endpoint))];
114
+ }
115
+ /**
116
+ * The checkout session a request on the endpoint of a profile with facts belongs to: the
117
+ * endpoint origin and the profile's card path up to its bind placeholder, written with the
118
+ * request's own segments, the session payment-core binds the record to
119
+ * ('https://rekordcloud.onfastspring.com/popup-lexicon/session/<id>'). Null for any other
120
+ * request. card-endpoint-facts.ts counts what it keeps, and what a checkout lost, by it.
121
+ * When the paths of two profiles on one origin both fit, the shorter session is the one:
122
+ * every request on a card request's session then gets the card request's own.
123
+ */
124
+ export function cardEndpointFactsSession(url) {
125
+ let parsed;
126
+ try {
127
+ parsed = new URL(url);
128
+ }
129
+ catch {
130
+ return null;
131
+ }
132
+ const segments = parsed.pathname.split('/');
133
+ let shortest = null;
134
+ for (const profile of PROFILES) {
135
+ if (!profile.facts || profile.endpoint !== parsed.origin)
136
+ continue;
137
+ const template = profile.path.split('/');
138
+ const at = template.indexOf(`:${profile.facts.bind}`);
139
+ if (at < 1 || segments.length <= at || (shortest !== null && at >= shortest))
140
+ continue;
141
+ if (template.slice(0, at + 1).every((part, i) => (part.startsWith(':') ? segments[i] !== '' : part === segments[i])))
142
+ shortest = at;
143
+ }
144
+ return shortest === null ? null : `${parsed.origin}${segments.slice(0, shortest + 1).join('/')}`;
145
+ }
61
146
  /** Whether this build's copy of the profile takes payments (enabled, nothing pending, a reviewed HAR, not recurring). */
62
147
  export function cardEndpointLive(profile) {
63
148
  return sharedLive(typeof profile === 'string' ? profile : profile.id) === true;
64
149
  }
150
+ /**
151
+ * Whether a paused request to the profile's card request URL carries a card, so the
152
+ * adapters hold it (payment-core merchantCardEndpointCarriesCard): anything at the
153
+ * profile's card number field, or a Luhn-valid number of 12 to 19 digits anywhere in
154
+ * the body, however it is written. A body the adapter could not read (null), an empty
155
+ * one, and any answer the rule cannot give count as carrying one. A post to a
156
+ * navigation profile carries none only when it also reads under the profile's
157
+ * encoding, its card fields are empty and it holds no card number anywhere: no run of
158
+ * 12 to 19 digits, spaces, dots and dashes dropped, that passes the card check digit.
159
+ * Its page posts such a body for a cancel, another payment method or a bank payment,
160
+ * and it continues untouched.
161
+ */
162
+ export function cardEndpointCarriesCard(profile, body, contentType) {
163
+ try {
164
+ return sharedCarriesCard(profile.id, body, contentType) !== false;
165
+ }
166
+ catch {
167
+ return true;
168
+ }
169
+ }
170
+ /** Whether a paused body is the card request the profile reviewed (its body rules, never its status). */
171
+ export function cardEndpointReviewed(profile, body, contentType) {
172
+ try {
173
+ return typeof body === 'string' && sharedBodyRefusal(profile.id, body, contentType) === null;
174
+ }
175
+ catch {
176
+ return false;
177
+ }
178
+ }
65
179
  /** What an event may say about a card endpoint request: the profile and the endpoint origin, never the URL's path or query. */
66
180
  export function cardEndpointDetail(profile) {
67
181
  return { profile: profile.id, endpoint: profile.endpoint };
@@ -75,19 +189,26 @@ const globEscape = (text) => text.replace(/[\\*?]/g, (char) => `\\${char}`);
75
189
  * each paused request is held to, and anything it refuses continues untouched.
76
190
  */
77
191
  export function cardEndpointPatterns() {
78
- return [...new Set(PROFILES.map((profile) => `${globEscape(profile.endpoint)}${profile.path.split('/').map((segment) => (segment.startsWith(':') ? '*' : globEscape(segment))).join('/')}*`))];
192
+ const pattern = (origin, path) => `${globEscape(origin)}${path.split('/').map((segment) => (segment.startsWith(':') ? '*' : globEscape(segment))).join('/')}*`;
193
+ return [...new Set(PROFILES.flatMap((profile) => [pattern(profile.endpoint, profile.path),
194
+ // Each resend is paused only to be refused (cardEndpointResendFor).
195
+ ...(profile.resends ?? []).map((resend) => pattern(profile.endpoint, resend.path))]))];
79
196
  }
80
197
  // Header names a capture never keeps, whatever a profile lists: the device's transport
81
198
  // writes Cookie, Origin and Host itself, the connection's own headers are its own, and
82
199
  // the Referer travels in its own member. payment-core's session check refuses the same.
83
200
  const NEVER_KEPT = /^(?:cookie|cookie2|set-cookie|origin|referer|host|content-length|transfer-encoding|connection|keep-alive|upgrade|te|trailer|expect|proxy-.*|:.*)$/;
84
- /** The paused request's Referer when it is a page on the profile's checkout origin, written as a browser serializes it; else nothing. */
201
+ /**
202
+ * The paused request's Referer when it is a page on the origin the request is sent from
203
+ * (the profile's card vault frame when it names one, else its checkout origin), written
204
+ * as a browser serializes it; else nothing.
205
+ */
85
206
  function keptReferer(profile, value) {
86
207
  if (typeof value !== 'string' || value.includes('#'))
87
208
  return undefined;
88
209
  try {
89
210
  const url = new URL(value);
90
- return url.origin === profile.checkout && url.href === value && !url.username && !url.password ? value : undefined;
211
+ return url.origin === (profile.frame ?? profile.checkout) && url.href === value && !url.username && !url.password ? value : undefined;
91
212
  }
92
213
  catch {
93
214
  return undefined;
@@ -95,8 +216,9 @@ function keptReferer(profile, value) {
95
216
  }
96
217
  /**
97
218
  * The session the create carries for a paused card endpoint request: the profile's own
98
- * header names and nothing else, the Referer only when it is a page on the checkout
99
- * origin, the endpoint's cookies only for a cookie profile, and the body. Checked with
219
+ * header names and nothing else, the Referer only when it is a page on the origin the
220
+ * request is sent from (the profile's card vault frame when it names one, else the
221
+ * checkout page), the endpoint's cookies only for a cookie profile, and the body. Checked with
100
222
  * the same rules the API and the device run (payment-core
101
223
  * merchantCardEndpointSessionRefusal), so a request they would refuse never leaves this
102
224
  * process: a CardEndpointRefusedError (`request_unsupported`) instead, naming no value.
@@ -119,9 +241,13 @@ export async function captureCardEndpointSession(profile, capture) {
119
241
  headers[lower] = typeof value === 'string' ? value : String(value);
120
242
  }
121
243
  const session = { url: capture.url, page_origin: capture.pageOrigin ?? '', headers, body: capture.body };
244
+ if (capture.navigation !== undefined)
245
+ session.navigation = capture.navigation;
122
246
  if (profile.session.cookies) {
123
247
  const jar = await capture.cookies();
124
- session.cookies = (Array.isArray(jar) ? jar : []).map(({ name, value }) => ({ name, value }));
248
+ // Only the names the profile lists leave this process; every other cookie of the jar stays in the browser.
249
+ session.cookies = (Array.isArray(jar) ? jar : []).map(({ name, value }) => ({ name, value }))
250
+ .filter(({ name }) => cookieListed(profile, name));
125
251
  }
126
252
  if (referer !== undefined)
127
253
  session.referer = referer;
@@ -156,6 +282,17 @@ export const CARD_ENDPOINT_WITHHELD_ANSWER = Object.freeze({
156
282
  headers: Object.freeze({ 'content-type': 'application/json', 'cache-control': 'no-store' }),
157
283
  body: JSON.stringify({ error: { code: 'agentcard_answer_withheld', message: 'The merchant answered, and Agentcard withheld the answer because it could carry card data. Check the order with the merchant before trying again.' } }),
158
284
  });
285
+ /**
286
+ * The fixed answer for a merchant answer the device withheld because it asks the page
287
+ * for a step the review refuses (withheld `refused_step_in_response`: FastSpring's EBANX
288
+ * step, which reads the typed card back and sends it again). The card request was sent,
289
+ * so the outcome is unknown until the order is checked.
290
+ */
291
+ export const CARD_ENDPOINT_REFUSED_STEP_ANSWER = Object.freeze({
292
+ status: 502,
293
+ headers: Object.freeze({ 'content-type': 'application/json', 'cache-control': 'no-store' }),
294
+ body: JSON.stringify({ error: { code: 'agentcard_step_refused', message: 'The merchant asked for a payment step Agentcard does not take part in, so Agentcard withheld its answer. Check the order with the merchant before trying again.' } }),
295
+ });
159
296
  // RFC 6265 cookie-name (a token) and cookie-value, as payment-core checks a session cookie.
160
297
  const COOKIE_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]{1,256}$/;
161
298
  const COOKIE_VALUE = /^(?:[\x21\x23-\x2b\x2d-\x3a\x3c-\x5b\x5d-\x7e]*|"[\x21\x23-\x2b\x2d-\x3a\x3c-\x5b\x5d-\x7e]*")$/;
@@ -280,6 +417,21 @@ export function cardEndpointCookies(profile, requestUrl, setCookies, nowMs = Dat
280
417
  }
281
418
  return { cookies, skipped };
282
419
  }
420
+ /** Whether a cookie name is one the profile lets travel (payment-core reads the same list). */
421
+ function cookieListed(profile, name) {
422
+ const names = profile.session.cookie_names;
423
+ if (names === null)
424
+ return true;
425
+ return typeof name === 'string' && names.some((entry) => (entry.endsWith('*') ? name.startsWith(entry.slice(0, -1)) && name.length > entry.length - 1 : name === entry));
426
+ }
427
+ /**
428
+ * How a paused navigation is answered from the merchant's answer (payment-core
429
+ * merchantCardEndpointNavigationAnswer): the page, a 301, 302 or 303 to one https URL,
430
+ * or refused (a redirect that repeats the post, or a Location that is not one https URL).
431
+ */
432
+ export function cardEndpointNavigationAnswer(profile, answer, requestUrl) {
433
+ return sharedNavigationAnswer(profile.id, answer, requestUrl);
434
+ }
283
435
  /** Why a session is not one the profile lets travel (payment-core merchantCardEndpointSessionRefusal), or null. Names no value. */
284
436
  export function cardEndpointSessionRefusal(profile, session) {
285
437
  return sharedSessionRefusal(profile.id, session) ?? null;