@agent-cards/sdk 0.23.27 → 0.24.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/CHANGELOG.md +66 -0
- package/PREFLIGHT.md +7 -1
- package/README.md +137 -99
- package/dist/aci.d.ts +52 -22
- package/dist/aci.js +79 -15
- package/dist/adyen-merchant-hosted.generated.d.ts +1 -1
- package/dist/adyen-merchant-hosted.generated.js +459 -21
- package/dist/agent-amount.generated.js +1 -1
- package/dist/agentcard.d.ts +4 -5
- package/dist/agentcard.js +4 -5
- package/dist/amadeus.d.ts +9 -3
- package/dist/amadeus.js +33 -4
- package/dist/api-module.d.ts +1 -7
- package/dist/api-module.js +3 -5
- package/dist/{voice-retry.js → api-retry.js} +1 -1
- package/dist/api-types.d.ts +53 -0
- package/dist/{voice-types.js → api-types.js} +7 -2
- package/dist/approvals.d.ts +166 -0
- package/dist/approvals.js +213 -0
- package/dist/buy.d.ts +20 -101
- package/dist/buy.js +19 -74
- package/dist/capabilities.generated.js +1 -1
- package/dist/card-fields.generated.js +1398 -106
- package/dist/cdp.d.ts +3 -13
- package/dist/cdp.js +231 -175
- package/dist/client.d.ts +104 -22
- package/dist/client.js +215 -40
- package/dist/creditguard.generated.d.ts +4 -4
- package/dist/datatrans.generated.js +22 -1
- package/dist/elavon-converge.generated.js +5 -3
- package/dist/hold.d.ts +8 -0
- package/dist/hold.js +43 -2
- package/dist/hosted-entry.js +2 -0
- package/dist/index.d.ts +9 -15
- package/dist/index.js +6 -10
- package/dist/juspay-gate.js +2 -2
- package/dist/juspay.generated.d.ts +2 -2
- package/dist/juspay.generated.js +174 -4
- package/dist/lifecycle.d.ts +9 -61
- package/dist/lifecycle.js +24 -10
- package/dist/merchant-card-endpoints.generated.d.ts +6 -1
- package/dist/merchant-card-endpoints.generated.js +344 -19
- package/dist/merchant-hosted.d.ts +8 -1
- package/dist/nike-order.d.ts +51 -0
- package/dist/nike-order.js +81 -0
- package/dist/oceanpayment.d.ts +38 -0
- package/dist/oceanpayment.js +70 -0
- package/dist/paddle-tokenex.generated.js +105 -10
- package/dist/payplug.d.ts +41 -0
- package/dist/payplug.js +71 -0
- package/dist/payrails.generated.js +27 -7
- package/dist/payrexx.generated.js +176 -2
- package/dist/pre-claim.d.ts +9 -0
- package/dist/pre-claim.js +86 -7
- package/dist/preflight-capabilities.generated.d.ts +0 -4
- package/dist/preflight-capabilities.generated.js +0 -5
- package/dist/preflight-catalog.json +46 -5
- package/dist/preflight-playwright.d.ts +2 -2
- package/dist/preflight-playwright.js +63 -14
- package/dist/preflight-schemas.json +4 -0
- package/dist/preflight.generated.d.ts +0 -4
- package/dist/preflight.generated.js +24 -1
- package/dist/prepared-processor.d.ts +18 -5
- package/dist/prepared-processor.js +48 -8
- package/dist/primer.generated.js +1418 -120
- package/dist/processout.generated.d.ts +61 -0
- package/dist/processout.generated.js +577 -0
- package/dist/requests.js +1 -1
- package/dist/shiji-sts.generated.js +105 -10
- package/dist/stripe-checkout.generated.js +1405 -234
- package/dist/substitutions.generated.js +460 -26
- package/dist/tap-payments.generated.js +105 -10
- package/dist/twoc2p.generated.js +105 -10
- package/dist/url-masks/bluesnap.d.ts +2 -0
- package/dist/url-masks/bluesnap.js +15 -0
- package/dist/url-masks/oceanpayment.d.ts +2 -0
- package/dist/url-masks/oceanpayment.js +13 -0
- package/dist/url-masks/payplug.d.ts +2 -0
- package/dist/url-masks/payplug.js +13 -0
- package/dist/url-masks/vtex_payments.d.ts +2 -0
- package/dist/url-masks/vtex_payments.js +12 -0
- package/dist/url-masks.generated.d.ts +1 -1
- package/dist/url-masks.generated.js +6 -2
- package/dist/verifone.generated.js +42 -2
- package/dist/vtex-payments.generated.d.ts +127 -0
- package/dist/vtex-payments.generated.js +544 -0
- package/dist/webhooks.d.ts +2 -2
- package/dist/webhooks.js +2 -2
- package/dist/wspay.generated.d.ts +1 -1
- package/dist/wspay.generated.js +175 -3
- package/examples/preflight/kernel-native/inventory.json +1 -1
- package/package.json +6 -8
- package/dist/languages.d.ts +0 -23
- package/dist/languages.js +0 -76
- package/dist/voice-pkce.d.ts +0 -49
- package/dist/voice-pkce.js +0 -147
- package/dist/voice-stream.generated.d.ts +0 -118
- package/dist/voice-stream.generated.js +0 -170
- package/dist/voice-types.d.ts +0 -735
- package/dist/voice.d.ts +0 -373
- package/dist/voice.js +0 -815
- package/readme.test.mjs +0 -53
- /package/dist/{voice-retry.d.ts → api-retry.d.ts} +0 -0
package/dist/voice.js
DELETED
|
@@ -1,815 +0,0 @@
|
|
|
1
|
-
import { ApiModule } from './api-module.js';
|
|
2
|
-
import { withRetries } from './voice-retry.js';
|
|
3
|
-
import { openStream, streamAddress } from './voice-stream.generated.js';
|
|
4
|
-
import { mintPkce, readReturnLink } from './voice-pkce.js';
|
|
5
|
-
import { stripePaymentIntentConfirmRequest } from './requests.js';
|
|
6
|
-
import { AgentcardError, } from './voice-types.js';
|
|
7
|
-
export { stripePaymentIntentConfirm } from './requests.js';
|
|
8
|
-
export const GRANT_REQUIRES_EXCHANGE_MESSAGE = 'This session is an authorization grant: its result is not read by polling. Hand the return link to voice.completeConnect(url), or its code and state to voice.exchange({ code, state, session_id }).';
|
|
9
|
-
/** How many minted grants the client remembers (verifier and state, by session); the oldest is forgotten past this. */
|
|
10
|
-
const GRANTS_REMEMBERED = 5_000;
|
|
11
|
-
/** The exchange refusals that end a grant for good; a `not_linked` leaves it open. */
|
|
12
|
-
const TERMINAL_EXCHANGE_CODES = new Set(['invalid_grant', 'not_a_grant', 'code_expired', 'already_exchanged', 'revoked', 'expired', 'account_suspended']);
|
|
13
|
-
/** The steps a Vault link's page reports, as the API spells them; anything else reads as null. */
|
|
14
|
-
const SESSION_STEPS = new Set(['opened', 'signed_in', 'challenge_minted', 'voice_refused', 'confirmed', 'stopped', 'left', 'reset_done']);
|
|
15
|
-
export function stepOf(v) { return typeof v === 'string' && SESSION_STEPS.has(v) ? v : null; }
|
|
16
|
-
export function reasonOf(v) { return typeof v === 'string' && v ? v : null; }
|
|
17
|
-
/** What a grant asks for when the caller names nothing: the three terms, so the row says what is enforced. */
|
|
18
|
-
const ALL_AUTHORIZATION_DETAILS = [{ type: 'agentcard_card' }, { type: 'voice' }, { type: 'rule' }];
|
|
19
|
-
/** The business's words for withdrawing a purchase (`authorizations.cancel`); anything else is refused before any call. */
|
|
20
|
-
export const CANCEL_REASONS = ['person_closed_sheet', 'person_changed_mind', 'business_cancelled'];
|
|
21
|
-
/** Why no rule took the purchase, when the API says so plainly (`unverified_origin`). */
|
|
22
|
-
function executionReasonFields(a) {
|
|
23
|
-
return {
|
|
24
|
-
...(typeof a.execution_reason === 'string' ? { execution_reason: a.execution_reason } : {}),
|
|
25
|
-
...(typeof a.execution_detail === 'string' ? { execution_detail: a.execution_detail } : {}),
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
/**
|
|
29
|
-
* The origin a paused request pays from, when its `origin` header is one Agentcard admits as `checkout_origin`
|
|
30
|
-
* (an exact https origin, or http://localhost for a local run); null otherwise. The Stripe request builders
|
|
31
|
-
* set that header, so a create that names no `checkout_origin` still carries the app's.
|
|
32
|
-
*/
|
|
33
|
-
export function checkoutOriginOf(request) {
|
|
34
|
-
const header = Object.entries(request.headers ?? {}).find(([k]) => k.toLowerCase() === 'origin')?.[1];
|
|
35
|
-
if (typeof header !== 'string')
|
|
36
|
-
return null;
|
|
37
|
-
try {
|
|
38
|
-
const u = new URL(header);
|
|
39
|
-
return u.origin === header && !u.username && !u.password && (u.protocol === 'https:' || (u.protocol === 'http:' && u.hostname === 'localhost')) ? header : null;
|
|
40
|
-
}
|
|
41
|
-
catch {
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
/** The approval request's fields as the API answers them, typed, and nothing the row does not carry. */
|
|
46
|
-
function approvalRequestFields(a) {
|
|
47
|
-
const out = {};
|
|
48
|
-
if ('request_id' in a)
|
|
49
|
-
out.request_id = a.request_id ?? null;
|
|
50
|
-
if (typeof a.interval === 'number')
|
|
51
|
-
out.interval = a.interval;
|
|
52
|
-
if (typeof a.poll_interval_ms === 'number')
|
|
53
|
-
out.poll_interval_ms = a.poll_interval_ms;
|
|
54
|
-
if ('intent_hash' in a)
|
|
55
|
-
out.intent_hash = a.intent_hash ?? null;
|
|
56
|
-
if (typeof a.expires_at === 'string')
|
|
57
|
-
out.expires_at = a.expires_at;
|
|
58
|
-
if ('delivery_channel' in a)
|
|
59
|
-
out.delivery_channel = a.delivery_channel ?? null;
|
|
60
|
-
if ('terminal_reason' in a)
|
|
61
|
-
out.terminal_reason = a.terminal_reason ?? null;
|
|
62
|
-
if (Array.isArray(a.interaction_evidence))
|
|
63
|
-
out.interaction_evidence = a.interaction_evidence;
|
|
64
|
-
if ('intent' in a)
|
|
65
|
-
out.intent = a.intent ?? null;
|
|
66
|
-
if ('intent_version' in a)
|
|
67
|
-
out.intent_version = a.intent_version ?? null;
|
|
68
|
-
if ('external_user_id' in a)
|
|
69
|
-
out.external_user_id = a.external_user_id ?? null;
|
|
70
|
-
return out;
|
|
71
|
-
}
|
|
72
|
-
export const MISSING_APPROVAL_POSTURE_MESSAGE = "Name how your product approves purchases: voice: { approval: 'sheet' } (the person approves on Agentcard's page with a credential Agentcard verifies; the default when approval is left out) or voice: { approval: 'in_app' } (hold-to-talk in your app, judged on the phone and by Agentcard, signed by a key in your app).";
|
|
73
|
-
/** The default posture: the person approves on Agentcard's page. */
|
|
74
|
-
export const DEFAULT_APPROVAL_POSTURE = 'sheet';
|
|
75
|
-
/**
|
|
76
|
-
* The posture as named, or `'sheet'` when none was: a client built without
|
|
77
|
-
* `voice`, or with `voice` and no `approval`, approves on Agentcard's page.
|
|
78
|
-
* Throws `missing_approval_posture` only for a posture that is neither.
|
|
79
|
-
*/
|
|
80
|
-
export function approvalPostureOf(options) {
|
|
81
|
-
const posture = options?.approval;
|
|
82
|
-
if (posture === undefined)
|
|
83
|
-
return DEFAULT_APPROVAL_POSTURE;
|
|
84
|
-
if (posture === 'sheet' || posture === 'in_app')
|
|
85
|
-
return posture;
|
|
86
|
-
throw new AgentcardError('missing_approval_posture', MISSING_APPROVAL_POSTURE_MESSAGE);
|
|
87
|
-
}
|
|
88
|
-
export { streamAddress } from './voice-stream.generated.js';
|
|
89
|
-
/**
|
|
90
|
-
* The sandbox request: a first run's purchase, naming no processor. Under a
|
|
91
|
-
* sandbox client `voice.authorizations.create({ request: 'sandbox' })` sends
|
|
92
|
-
* this; the sandbox completes it without reaching any processor. A live
|
|
93
|
-
* client refuses it before any call (`sandbox_request_in_live`).
|
|
94
|
-
*/
|
|
95
|
-
export const SANDBOX_REQUEST = stripePaymentIntentConfirmRequest({
|
|
96
|
-
origin: 'https://sandbox.agentcard.sh',
|
|
97
|
-
intent: { id: 'pi_sandbox', client_secret: 'pi_sandbox_secret_sandbox', publishable_key: 'pk_test_sandbox' },
|
|
98
|
-
});
|
|
99
|
-
/** The paying card as the API names it (decision 213), read for its type; null for null or anything that is not one. */
|
|
100
|
-
function cardOf(v) {
|
|
101
|
-
if (!v || typeof v !== 'object')
|
|
102
|
-
return null;
|
|
103
|
-
const c = v;
|
|
104
|
-
if (typeof c.id !== 'string' || !c.id)
|
|
105
|
-
return null;
|
|
106
|
-
return { id: c.id, name: typeof c.name === 'string' ? c.name : '', last4: typeof c.last4 === 'string' ? c.last4 : null, brand: typeof c.brand === 'string' ? c.brand : null, reason: typeof c.reason === 'string' ? c.reason : null };
|
|
107
|
-
}
|
|
108
|
-
/** The choosable cards as the API lists them; a list only, every entry a card. */
|
|
109
|
-
function cardsOf(v) {
|
|
110
|
-
if (!Array.isArray(v))
|
|
111
|
-
return undefined;
|
|
112
|
-
const out = [];
|
|
113
|
-
for (const raw of v) {
|
|
114
|
-
const card = cardOf(raw);
|
|
115
|
-
if (!card)
|
|
116
|
-
continue;
|
|
117
|
-
const c = raw;
|
|
118
|
-
out.push({ ...card, recommended: c.recommended === true, ...(c.auto_approve === true ? { auto_approve: true } : {}) });
|
|
119
|
-
}
|
|
120
|
-
return out;
|
|
121
|
-
}
|
|
122
|
-
/** `card` and `cards` off a purchase read, as the API carries them; absent from an older API. */
|
|
123
|
-
function cardFields(a) {
|
|
124
|
-
const cards = cardsOf(a.cards);
|
|
125
|
-
return { ...('card' in a ? { card: cardOf(a.card) } : {}), ...(cards ? { cards } : {}) };
|
|
126
|
-
}
|
|
127
|
-
/** A challenge as the API answers it, every field read for its type; the four host fields stay absent until the API mints them. */
|
|
128
|
-
function challengeOf(ch, authorizationId) {
|
|
129
|
-
const str = (v) => (typeof v === 'string' && v ? v : undefined);
|
|
130
|
-
const minor = (v) => (typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : undefined);
|
|
131
|
-
const lines = (v) => (v && typeof v === 'object' ? v : undefined);
|
|
132
|
-
const intent = ch.intent && typeof ch.intent === 'object' && typeof ch.intent.hash === 'string'
|
|
133
|
-
? { kind: String(ch.intent.kind ?? ''), hash: ch.intent.hash } : undefined;
|
|
134
|
-
const audioDelivery = ch.audio_delivery === 'spoken' || ch.audio_delivery === 'screen' ? ch.audio_delivery : undefined;
|
|
135
|
-
return {
|
|
136
|
-
id: String(ch.id ?? ''),
|
|
137
|
-
authorization_id: str(ch.authorization_id) ?? authorizationId,
|
|
138
|
-
word: String(ch.word ?? ''),
|
|
139
|
-
...(str(ch.lang) ? { lang: str(ch.lang) } : {}),
|
|
140
|
-
say: lines(ch.say) ?? {},
|
|
141
|
-
...(lines(ch.say_private) ? { say_private: lines(ch.say_private) } : {}),
|
|
142
|
-
...('card' in ch ? { card: cardOf(ch.card) } : {}),
|
|
143
|
-
...(str(ch.audio_url) ? { audio_url: str(ch.audio_url) } : {}),
|
|
144
|
-
...(audioDelivery ? { audio_delivery: audioDelivery } : {}),
|
|
145
|
-
...(str(ch.challenge) ? { challenge: str(ch.challenge) } : {}),
|
|
146
|
-
...(intent ? { intent } : {}),
|
|
147
|
-
...(str(ch.verifier_url) ? { verifier_url: str(ch.verifier_url) } : {}),
|
|
148
|
-
...(str(ch.answer_token) ? { answer_token: str(ch.answer_token) } : {}),
|
|
149
|
-
...(str(ch.verifier_public_key) ? { verifier_public_key: str(ch.verifier_public_key) } : {}),
|
|
150
|
-
...(minor(ch.amount_minor) !== undefined ? { amount_minor: minor(ch.amount_minor) } : {}),
|
|
151
|
-
...(str(ch.currency) ? { currency: str(ch.currency) } : {}),
|
|
152
|
-
expires_at: String(ch.expires_at ?? ''),
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
/** The sealed bundle for the host, from a typed challenge; refuses one the frame cannot sign for. */
|
|
156
|
-
export function bundleOf(ch) {
|
|
157
|
-
if (!ch.challenge || !ch.intent || !ch.authorization_id)
|
|
158
|
-
throw new AgentcardError('challenge_incomplete', 'The API minted this challenge without the nonce or the intent the hosted frame signs over; an in-app approval needs both. Take the approval page this once.');
|
|
159
|
-
return {
|
|
160
|
-
challenge_id: ch.id,
|
|
161
|
-
authorization_id: ch.authorization_id,
|
|
162
|
-
word: ch.word,
|
|
163
|
-
...(ch.lang ? { lang: ch.lang } : {}),
|
|
164
|
-
say: ch.say,
|
|
165
|
-
...(ch.say_private ? { say_private: ch.say_private } : {}),
|
|
166
|
-
...(ch.card !== undefined ? { card: ch.card } : {}),
|
|
167
|
-
...(ch.audio_url ? { audio_url: ch.audio_url } : {}),
|
|
168
|
-
...(ch.audio_delivery ? { audio_delivery: ch.audio_delivery } : {}),
|
|
169
|
-
challenge: ch.challenge,
|
|
170
|
-
intent: ch.intent,
|
|
171
|
-
...(ch.verifier_url ? { verifier_url: ch.verifier_url } : {}),
|
|
172
|
-
...(ch.answer_token ? { answer_token: ch.answer_token } : {}),
|
|
173
|
-
...(ch.verifier_public_key ? { verifier_public_key: ch.verifier_public_key } : {}),
|
|
174
|
-
...(ch.amount_minor !== undefined ? { amount_minor: ch.amount_minor } : {}),
|
|
175
|
-
...(ch.currency ? { currency: ch.currency } : {}),
|
|
176
|
-
expires_at: ch.expires_at,
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
/** What a paused browser purchase resumes with: the mode and the processor's answer, ciphertext or device stamp, read for their types. */
|
|
180
|
-
function replayFields(a) {
|
|
181
|
-
const out = {};
|
|
182
|
-
if (typeof a.mode === 'string')
|
|
183
|
-
out.mode = a.mode;
|
|
184
|
-
const r = a.response;
|
|
185
|
-
if (r && typeof r === 'object' && Number.isInteger(r.status) && typeof r.body === 'string' && r.headers && typeof r.headers === 'object' && !Array.isArray(r.headers)) {
|
|
186
|
-
out.response = { status: r.status, headers: r.headers, body: r.body };
|
|
187
|
-
}
|
|
188
|
-
const sub = a.substitutions;
|
|
189
|
-
if (sub && typeof sub === 'object' && sub.encoding === 'json' && typeof sub.at === 'string' && sub.fields && typeof sub.fields === 'object') {
|
|
190
|
-
out.substitutions = { encoding: 'json', at: sub.at, fields: { ...sub.fields }, ...(Array.isArray(sub.remove) ? { remove: sub.remove } : {}) };
|
|
191
|
-
}
|
|
192
|
-
if (typeof a.submitted_at === 'string')
|
|
193
|
-
out.submitted_at = a.submitted_at;
|
|
194
|
-
return out;
|
|
195
|
-
}
|
|
196
|
-
/** A purchase as one read answers it: the status, what approved it, the request fields, the settlement and what a paused page resumes with. */
|
|
197
|
-
function resultOf(a) {
|
|
198
|
-
const fields = approvalRequestFields(a);
|
|
199
|
-
// The settlement answer as the API carries it (the same object as on the finishing webhook), read defensively:
|
|
200
|
-
// an object with a status word, or null when there is nothing to read back; absent from an older API.
|
|
201
|
-
const settlement = 'settlement' in a
|
|
202
|
-
? (a.settlement && typeof a.settlement === 'object' && typeof a.settlement.status === 'string' ? a.settlement : null)
|
|
203
|
-
: undefined;
|
|
204
|
-
const approvalUrl = typeof a.approval_url === 'string' ? a.approval_url : typeof a.approvalUrl === 'string' ? a.approvalUrl : undefined;
|
|
205
|
-
return { ...(typeof a.id === 'string' ? { id: a.id } : {}), status: a.status, approved_with: a.approved_with, execution_mode: a.execution_mode, ...executionReasonFields(a), ...fields, ...(settlement === undefined ? {} : { settlement }), ...(approvalUrl && a.status === 'awaiting_approval' ? { approval_url: approvalUrl } : {}), ...replayFields(a), ...cardFields(a) };
|
|
206
|
-
}
|
|
207
|
-
/** A random idempotency key for one create, so a retried call makes one purchase. */
|
|
208
|
-
function idempotencyKey() {
|
|
209
|
-
const c = globalThis.crypto;
|
|
210
|
-
if (c?.randomUUID)
|
|
211
|
-
return c.randomUUID();
|
|
212
|
-
return 'ik_' + Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
213
|
-
}
|
|
214
|
-
/** `agentcard.voice`: constructed by the client, never on its own. */
|
|
215
|
-
export class VoiceModule extends ApiModule {
|
|
216
|
-
options;
|
|
217
|
-
clientIsSandbox;
|
|
218
|
-
/** The approval posture the client was constructed with, one per business; `'sheet'` when the client named none. */
|
|
219
|
-
approval;
|
|
220
|
-
/** The grants this client minted, by session id: the verifier and the state, then the exchanged result. Memory only. */
|
|
221
|
-
grants = new Map();
|
|
222
|
-
/** Session id by minted `state`, so a return link finds its grant without naming the session. */
|
|
223
|
-
grantsByState = new Map();
|
|
224
|
-
constructor(t, options, clientIsSandbox) {
|
|
225
|
-
super(t);
|
|
226
|
-
this.options = options;
|
|
227
|
-
this.clientIsSandbox = clientIsSandbox;
|
|
228
|
-
this.approval = approvalPostureOf(options);
|
|
229
|
-
}
|
|
230
|
-
posture() {
|
|
231
|
-
return this.approval;
|
|
232
|
-
}
|
|
233
|
-
/** Where the Vault sends the person back when a call names nothing, as the client was constructed. */
|
|
234
|
-
get returnUrl() { return this.options?.returnUrl; }
|
|
235
|
-
/**
|
|
236
|
-
* The languages Agentcard speaks, as the API lists them (`{ tag, name }`),
|
|
237
|
-
* read once and kept for ten minutes. Every call that names a `lang` checks
|
|
238
|
-
* it against this list first and refuses one not on it with
|
|
239
|
-
* `unsupported_language` before anything is sent. `null` on an API that
|
|
240
|
-
* does not serve the list yet; tags then travel unchecked.
|
|
241
|
-
*/
|
|
242
|
-
languages() { return this.t.languages.list(); }
|
|
243
|
-
// --- connecting a person --------------------------------------------------------------------------------
|
|
244
|
-
remember(grant) {
|
|
245
|
-
if (this.grants.size >= GRANTS_REMEMBERED) {
|
|
246
|
-
const oldest = this.grants.keys().next().value;
|
|
247
|
-
if (oldest !== undefined)
|
|
248
|
-
this.forget(oldest);
|
|
249
|
-
}
|
|
250
|
-
this.grants.set(grant.sessionId, grant);
|
|
251
|
-
this.grantsByState.set(grant.state, grant.sessionId);
|
|
252
|
-
}
|
|
253
|
-
forget(sessionId) {
|
|
254
|
-
const g = this.grants.get(sessionId);
|
|
255
|
-
if (!g)
|
|
256
|
-
return;
|
|
257
|
-
this.grants.delete(sessionId);
|
|
258
|
-
if (this.grantsByState.get(g.state) === sessionId)
|
|
259
|
-
this.grantsByState.delete(g.state);
|
|
260
|
-
}
|
|
261
|
-
/**
|
|
262
|
-
* Mints the connect link. The person signs in, stores a card if none, sets
|
|
263
|
-
* up their voice if the app requires it, and comes back. Open `url` in the
|
|
264
|
-
* in-app sheet. The link is an OAuth authorization grant: the client mints
|
|
265
|
-
* `state` and a PKCE challenge, keeps the verifier, and the return link
|
|
266
|
-
* comes back as `yourapp://connected?code=…&state=…`; hand it to
|
|
267
|
-
* `completeConnect(url)`, once, within 60 seconds. With no return link there
|
|
268
|
-
* is nothing to carry the code, so the mint polls (`grant: 'poll'`, then
|
|
269
|
-
* `awaitConnected`); `pkce: false` asks for that shape outright (deprecated).
|
|
270
|
-
* A grant asks for the terms it names in `authorization_details`, and for
|
|
271
|
-
* all three when it names none; the terms are enforced from then on. The
|
|
272
|
-
* result carries the client's approval posture as `approval`, for the app.
|
|
273
|
-
*/
|
|
274
|
-
async connect(input = {}) {
|
|
275
|
-
const approval = this.posture();
|
|
276
|
-
await this.t.languages.check(input.lang);
|
|
277
|
-
const returnUrl = input.return_url ?? this.returnUrl;
|
|
278
|
-
if (input.pkce === true && !returnUrl)
|
|
279
|
-
throw new AgentcardError('missing_return_url', 'A grant rides the return link: name return_url on the call or returnUrl on the client.');
|
|
280
|
-
// A landing collects nothing, so it polls unless the caller asks for a grant outright.
|
|
281
|
-
const grant = input.pkce === true || (input.pkce !== false && !input.landing);
|
|
282
|
-
const pkce = grant && !!returnUrl ? await mintPkce(this.options?.pkce) : null;
|
|
283
|
-
const details = input.authorization_details ?? (pkce ? ALL_AUTHORIZATION_DETAILS : undefined);
|
|
284
|
-
const body = await this.request('/api/v2/vault_sessions', {
|
|
285
|
-
method: 'POST',
|
|
286
|
-
body: {
|
|
287
|
-
mode: 'open',
|
|
288
|
-
...(returnUrl ? { return_url: returnUrl } : {}),
|
|
289
|
-
...(input.voice ? { voice: input.voice } : {}),
|
|
290
|
-
...(input.email ? { email: input.email } : {}),
|
|
291
|
-
...(input.external_user_id ? { external_user_id: input.external_user_id } : {}),
|
|
292
|
-
...(input.lang ? { lang: input.lang } : {}),
|
|
293
|
-
...(input.landing ? { steps: [input.landing] } : {}),
|
|
294
|
-
...(pkce ? { state: pkce.state, code_challenge: pkce.code_challenge, code_challenge_method: pkce.code_challenge_method } : {}),
|
|
295
|
-
...(details ? { authorization_details: details } : {}),
|
|
296
|
-
},
|
|
297
|
-
});
|
|
298
|
-
if (pkce)
|
|
299
|
-
this.remember({ sessionId: body.id, state: pkce.state, verifier: pkce.code_verifier });
|
|
300
|
-
return {
|
|
301
|
-
url: body.url,
|
|
302
|
-
session_id: body.id,
|
|
303
|
-
voice: body.voice ?? input.voice,
|
|
304
|
-
approval,
|
|
305
|
-
grant: pkce ? 'exchange' : 'poll',
|
|
306
|
-
request_id: body.request_id ?? null,
|
|
307
|
-
...(pkce ? { state: pkce.state, code_verifier: pkce.code_verifier } : {}),
|
|
308
|
-
...(details ? { authorization_details: body.authorization_details ?? details } : {}),
|
|
309
|
-
};
|
|
310
|
-
}
|
|
311
|
-
/**
|
|
312
|
-
* The grant's exchange: the `code` from the return link, under this client's
|
|
313
|
-
* credentials, with the verifier the client kept for the session. Answers
|
|
314
|
-
* once, within 60 seconds of the link. A `state` that is not the one minted
|
|
315
|
-
* for the session is refused here, before any call (`state_mismatch`). When
|
|
316
|
-
* another instance minted the link, pass the `code_verifier` AND the
|
|
317
|
-
* `expected_state` that `connect` returned there: the link's `state` is
|
|
318
|
-
* compared with `expected_state` locally, and no exchange is made without
|
|
319
|
-
* that comparison. The API's refusals come through verbatim: `invalid_grant`,
|
|
320
|
-
* `not_linked` (the person is not done; try again), `code_expired`,
|
|
321
|
-
* `already_exchanged`, `revoked`. `completeConnect(url)` reads the link and
|
|
322
|
-
* calls this.
|
|
323
|
-
*/
|
|
324
|
-
async exchange(input) {
|
|
325
|
-
this.posture();
|
|
326
|
-
const bySession = input.session_id ? this.grants.get(input.session_id) : undefined;
|
|
327
|
-
const byState = !bySession && input.state ? this.grants.get(this.grantsByState.get(input.state) ?? '') : undefined;
|
|
328
|
-
const grant = bySession ?? byState;
|
|
329
|
-
if (grant && input.state !== grant.state) {
|
|
330
|
-
throw new AgentcardError('state_mismatch', input.state === undefined
|
|
331
|
-
? 'The return link carries no state; this session was minted with one. Refused before any call.'
|
|
332
|
-
: 'The state on the return link is not the one minted for this session. Refused before any call.');
|
|
333
|
-
}
|
|
334
|
-
if (!grant && input.code_verifier) {
|
|
335
|
-
// Another instance minted the link: the persisted state must be given and must be the link's, or nothing is sent.
|
|
336
|
-
if (!input.expected_state)
|
|
337
|
-
throw new AgentcardError('state_mismatch', 'Another instance minted this link: pass expected_state (the state connect returned there, persisted with the code_verifier) so the return link can be checked before any call.');
|
|
338
|
-
if (input.state !== input.expected_state) {
|
|
339
|
-
throw new AgentcardError('state_mismatch', input.state === undefined
|
|
340
|
-
? 'The return link carries no state; this session was minted with one. Refused before any call.'
|
|
341
|
-
: 'The state on the return link is not the one minted for this session. Refused before any call.');
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
if (!grant && input.state !== undefined && !input.code_verifier) {
|
|
345
|
-
throw new AgentcardError('state_mismatch', 'No session minted by this client carries that state. Pass session_id, and the code_verifier and expected_state connect returned, when another instance minted the link.');
|
|
346
|
-
}
|
|
347
|
-
const verifier = input.code_verifier ?? grant?.verifier;
|
|
348
|
-
if (!verifier)
|
|
349
|
-
throw new AgentcardError('unknown_grant', 'This client did not mint that session, or has restarted since. Pass the code_verifier and expected_state connect returned, or connect again.');
|
|
350
|
-
let body;
|
|
351
|
-
try {
|
|
352
|
-
body = await this.request('/api/v2/vault_sessions/exchange', { method: 'POST', body: { code: input.code, code_verifier: verifier } });
|
|
353
|
-
}
|
|
354
|
-
catch (err) {
|
|
355
|
-
// A refusal that ends the grant is what awaitConnected answers from then on; a grant already exchanged keeps its result.
|
|
356
|
-
if (grant && !grant.result && err instanceof AgentcardError && TERMINAL_EXCHANGE_CODES.has(err.code))
|
|
357
|
-
grant.failure = err;
|
|
358
|
-
throw err;
|
|
359
|
-
}
|
|
360
|
-
const userId = typeof body.user_id === 'string' ? body.user_id : null;
|
|
361
|
-
if (!userId)
|
|
362
|
-
throw new AgentcardError('invalid_grant', 'The exchange answered without a person.');
|
|
363
|
-
const result = {
|
|
364
|
-
user_id: userId,
|
|
365
|
-
card: body.card === true,
|
|
366
|
-
voice: Array.isArray(body.voice) ? body.voice : [],
|
|
367
|
-
rule: body.rule === true,
|
|
368
|
-
passkey: body.passkey === true,
|
|
369
|
-
external_user_id: typeof body.external_user_id === 'string' ? body.external_user_id : null,
|
|
370
|
-
session_id: typeof body.session_id === 'string' ? body.session_id : (grant?.sessionId ?? input.session_id ?? ''),
|
|
371
|
-
request_id: typeof body.request_id === 'string' ? body.request_id : null,
|
|
372
|
-
state: typeof body.state === 'string' ? body.state : null,
|
|
373
|
-
authorization_details: Array.isArray(body.authorization_details) ? body.authorization_details : null,
|
|
374
|
-
action: body.action && typeof body.action === 'object' ? body.action : null,
|
|
375
|
-
linked_at: typeof body.linked_at === 'string' ? body.linked_at : null,
|
|
376
|
-
// The link's diary, as the exchange answers it: why a grant came back with `voice: []`.
|
|
377
|
-
last_step: stepOf(body.last_step),
|
|
378
|
-
reason: reasonOf(body.reason),
|
|
379
|
-
// Only the return link says this; `completeConnect` fills it from the link.
|
|
380
|
-
voice_reset: null,
|
|
381
|
-
connection: body.connection && typeof body.connection === 'object' ? body.connection : null,
|
|
382
|
-
};
|
|
383
|
-
if (grant)
|
|
384
|
-
grant.result = result;
|
|
385
|
-
return result;
|
|
386
|
-
}
|
|
387
|
-
/**
|
|
388
|
-
* The return link, as the sheet handed it back (`yourapp://connected?code=…&state=…`):
|
|
389
|
-
* finds the grant by its `state`, checks it, and exchanges the code. Throws
|
|
390
|
-
* `missing_code` when the link carries none (a session minted to poll, a
|
|
391
|
-
* reset or an action link: read those with `awaitConnected`).
|
|
392
|
-
*/
|
|
393
|
-
async completeConnect(returnUrl, opts = {}) {
|
|
394
|
-
this.posture();
|
|
395
|
-
const { code, state, voice_reset } = readReturnLink(returnUrl);
|
|
396
|
-
// A link with no code is not a grant's: the error names what the link did say (`voice_reset` after a reset
|
|
397
|
-
// visit), so the caller reads the outcome from `details` and polls the session for the rest.
|
|
398
|
-
if (!code)
|
|
399
|
-
throw new AgentcardError('missing_code', 'The return link carries no code. A session minted to poll (pkce: false, reset, or an action) is read with awaitConnected.', undefined, { voice_reset });
|
|
400
|
-
const result = await this.exchange({ code, ...(state !== null ? { state } : {}), ...opts });
|
|
401
|
-
return { ...result, voice_reset };
|
|
402
|
-
}
|
|
403
|
-
/**
|
|
404
|
-
* Resolves once the session is linked: who, and what the app may use.
|
|
405
|
-
* For a grant this client minted it resolves when `exchange` (or
|
|
406
|
-
* `completeConnect`) has answered for that session, and reads nothing
|
|
407
|
-
* meanwhile; for a polling mint it polls the session. Throws
|
|
408
|
-
* `connect_timeout` at the deadline, and `grant_requires_exchange` when the
|
|
409
|
-
* API says the session is a grant this client does not hold the verifier for.
|
|
410
|
-
* For one read with no wait, `sessions.get(session_id)`.
|
|
411
|
-
*/
|
|
412
|
-
async awaitConnected(sessionId, opts = {}) {
|
|
413
|
-
this.posture();
|
|
414
|
-
const grant = this.grants.get(sessionId);
|
|
415
|
-
if (grant) {
|
|
416
|
-
return this.poll(async () => {
|
|
417
|
-
if (grant.result)
|
|
418
|
-
return grant.result;
|
|
419
|
-
if (grant.failure)
|
|
420
|
-
throw grant.failure;
|
|
421
|
-
return undefined;
|
|
422
|
-
}, opts.timeout_ms, () => { throw new AgentcardError('connect_timeout', 'The Vault session was not exchanged in time. ' + GRANT_REQUIRES_EXCHANGE_MESSAGE); });
|
|
423
|
-
}
|
|
424
|
-
const deadline = Date.now() + (opts.timeout_ms ?? this.pollTimeoutMs);
|
|
425
|
-
return this.poll(async () => {
|
|
426
|
-
let s;
|
|
427
|
-
try {
|
|
428
|
-
s = await this.read(`/api/v2/vault_sessions/${encodeURIComponent(sessionId)}`, { deadline });
|
|
429
|
-
}
|
|
430
|
-
catch (err) {
|
|
431
|
-
if (err instanceof AgentcardError && err.code === 'grant_requires_exchange')
|
|
432
|
-
throw new AgentcardError('grant_requires_exchange', GRANT_REQUIRES_EXCHANGE_MESSAGE, err.status, err.details);
|
|
433
|
-
throw err;
|
|
434
|
-
}
|
|
435
|
-
// A linked grant this client does not hold the verifier for: the API answers the diary and withholds the
|
|
436
|
-
// result (`exchange_required`); that is the refusal this call has always given, said plainly.
|
|
437
|
-
if (s.status === 'linked' && (s.exchange_required === true || (s.grant === 'exchange' && !s.user_id))) {
|
|
438
|
-
throw new AgentcardError('grant_requires_exchange', GRANT_REQUIRES_EXCHANGE_MESSAGE, 400, { request_id: s.request_id ?? null, last_step: stepOf(s.last_step), reason: reasonOf(s.reason) });
|
|
439
|
-
}
|
|
440
|
-
if (s.status === 'linked' && s.user_id) {
|
|
441
|
-
return { user_id: s.user_id, card: s.card === true, voice: Array.isArray(s.voice) ? s.voice : [], rule: s.rule === true, passkey: s.passkey === true, external_user_id: s.external_user_id ?? null, last_step: stepOf(s.last_step), reason: reasonOf(s.reason) };
|
|
442
|
-
}
|
|
443
|
-
if (s.status === 'expired' || s.status === 'consumed')
|
|
444
|
-
throw new AgentcardError('vault_session_' + s.status, 'The Vault link is no longer usable. Connect again.');
|
|
445
|
-
return undefined;
|
|
446
|
-
}, opts.timeout_ms, () => { throw new AgentcardError('connect_timeout', 'The Vault session was not linked in time.'); });
|
|
447
|
-
}
|
|
448
|
-
sessions = {
|
|
449
|
-
/**
|
|
450
|
-
* Where a link stands, in one read and with no wait: its status, how its result is collected (`grant`), and
|
|
451
|
-
* the diary (`last_step`, `reason`) that says why a link came back with nothing. On a linked grant the
|
|
452
|
-
* connected result stays behind the exchange (`exchange_required: true`, the person's fields null); a
|
|
453
|
-
* polling mint reads its result here as `awaitConnected` gives it. `slow_down` is waited out.
|
|
454
|
-
*/
|
|
455
|
-
get: async (sessionId) => {
|
|
456
|
-
this.posture();
|
|
457
|
-
const s = await this.read(`/api/v2/vault_sessions/${encodeURIComponent(sessionId)}`);
|
|
458
|
-
const status = s.status === 'linked' ? 'linked' : s.status === 'expired' ? 'expired' : 'pending';
|
|
459
|
-
const grant = s.grant === 'exchange' ? 'exchange' : 'poll';
|
|
460
|
-
const withheld = status === 'linked' && (s.exchange_required === true || (grant === 'exchange' && !s.user_id));
|
|
461
|
-
return {
|
|
462
|
-
session_id: typeof s.id === 'string' ? s.id : sessionId,
|
|
463
|
-
request_id: typeof s.request_id === 'string' ? s.request_id : null,
|
|
464
|
-
status,
|
|
465
|
-
grant,
|
|
466
|
-
exchange_required: withheld,
|
|
467
|
-
last_step: stepOf(s.last_step),
|
|
468
|
-
reason: reasonOf(s.reason),
|
|
469
|
-
external_user_id: typeof s.external_user_id === 'string' ? s.external_user_id : null,
|
|
470
|
-
user_id: withheld || typeof s.user_id !== 'string' ? null : s.user_id,
|
|
471
|
-
card: withheld || typeof s.card !== 'boolean' ? null : s.card,
|
|
472
|
-
voice: withheld || !Array.isArray(s.voice) ? null : s.voice,
|
|
473
|
-
rule: withheld || typeof s.rule !== 'boolean' ? null : s.rule,
|
|
474
|
-
passkey: withheld || typeof s.passkey !== 'boolean' ? null : s.passkey,
|
|
475
|
-
action: withheld || !s.action || typeof s.action !== 'object' ? null : s.action,
|
|
476
|
-
linked_at: typeof s.linked_at === 'string' ? s.linked_at : null,
|
|
477
|
-
expires_at: typeof s.expires_at === 'string' ? s.expires_at : null,
|
|
478
|
-
};
|
|
479
|
-
},
|
|
480
|
-
};
|
|
481
|
-
/**
|
|
482
|
-
* Revokes the connection a session made: the token its exchange minted is
|
|
483
|
-
* revoked, and an unexchanged code refuses from then on. Idempotent.
|
|
484
|
-
*/
|
|
485
|
-
async disconnect(sessionId) {
|
|
486
|
-
this.posture();
|
|
487
|
-
const body = await this.request(`/api/v2/vault_sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' });
|
|
488
|
-
return { session_id: body.id ?? sessionId, revoked: body.revoked === true, revoked_at: body.revoked_at, token_revoked: body.token_revoked === true };
|
|
489
|
-
}
|
|
490
|
-
people = {
|
|
491
|
-
/**
|
|
492
|
-
* A returning person: what they already have with this business, without
|
|
493
|
-
* minting a link or sending them anywhere. Store `user_id` from the first
|
|
494
|
-
* exchange; on a later launch read here first, and `connect` only for what
|
|
495
|
-
* is missing (no card, or `voice: 'required'` and no voice for the bot's
|
|
496
|
-
* language). 404 `connection_not_found` when the person never connected
|
|
497
|
-
* through this client (or disconnected). The `user_id` in the answer is
|
|
498
|
-
* the effective one: a merged account answers with its survivor, worth
|
|
499
|
-
* storing from then on.
|
|
500
|
-
*/
|
|
501
|
-
get: async (userId) => {
|
|
502
|
-
this.posture();
|
|
503
|
-
const p = await this.read(`/api/v2/vault_users/${encodeURIComponent(userId)}`);
|
|
504
|
-
return { user_id: p.user_id, card: p.card === true, voice: Array.isArray(p.voice) ? p.voice : [], rule: p.rule === true, passkey: p.passkey === true, team_member: p.team_member === true };
|
|
505
|
-
},
|
|
506
|
-
};
|
|
507
|
-
// --- the purchase ---------------------------------------------------------------------------------------
|
|
508
|
-
authorizations = {
|
|
509
|
-
/**
|
|
510
|
-
* Creates the purchase, paused for approval, for the person `user_id`
|
|
511
|
-
* names (the `user_id` the connect exchange returned; required, so a
|
|
512
|
-
* server serving many people never charges the wrong one).
|
|
513
|
-
* `execution_mode: 'autopilot'` means the person's rule took it and no
|
|
514
|
-
* voice step follows. The return link (the call's, else the client's
|
|
515
|
-
* `returnUrl`) goes in the create, so Agentcard stores it on the request
|
|
516
|
-
* and the approval page reads it from there; the `approval_url` returned
|
|
517
|
-
* is id-only. `request: 'sandbox'` is the first-run form: under a sandbox
|
|
518
|
-
* client (`sandbox: true`, an `sk_test_` credential, or a mode the token
|
|
519
|
-
* exchange named) it sends the sandbox request and names no processor; a
|
|
520
|
-
* live client refuses it before the create leaves (`sandbox_request_in_live`). Every
|
|
521
|
-
* create carries an idempotency key, so a retried call makes one purchase.
|
|
522
|
-
*/
|
|
523
|
-
create: async (input) => {
|
|
524
|
-
this.posture();
|
|
525
|
-
const user = input.user_id;
|
|
526
|
-
if (typeof user !== 'string' || !user)
|
|
527
|
-
throw new AgentcardError('not_connected', 'Name user_id: the person the connect exchange returned. Refused before any call.');
|
|
528
|
-
let request;
|
|
529
|
-
if (input.request === 'sandbox') {
|
|
530
|
-
// A client that was not named sandbox may still learn it from the token exchange; the create itself has not left.
|
|
531
|
-
if (!this.clientIsSandbox())
|
|
532
|
-
await this.t.bearer();
|
|
533
|
-
if (!this.clientIsSandbox())
|
|
534
|
-
throw new AgentcardError('sandbox_request_in_live', "request: 'sandbox' is for a sandbox client. Under live credentials build the processor's request (checkout.requests.build) or hand over the request a browser purchase paused on. Refused before any call.");
|
|
535
|
-
request = SANDBOX_REQUEST;
|
|
536
|
-
}
|
|
537
|
-
else if (input.request && typeof input.request === 'object' && typeof input.request.url === 'string') {
|
|
538
|
-
request = input.request;
|
|
539
|
-
}
|
|
540
|
-
else {
|
|
541
|
-
throw new AgentcardError('invalid_request', "request must be a paused request ({ url, method, headers, body }) or 'sandbox'. Refused before any call.");
|
|
542
|
-
}
|
|
543
|
-
const returnUrl = input.return_url ?? this.returnUrl;
|
|
544
|
-
// The origin the purchase is paid from goes in the create (Agentcard keeps no `origin` header from the
|
|
545
|
-
// paused request), so a rule the person confirmed can take it; named on the call, else the paused
|
|
546
|
-
// request's own when it is an origin Agentcard admits.
|
|
547
|
-
const checkoutOrigin = input.checkout_origin ?? checkoutOriginOf(request);
|
|
548
|
-
const body = await this.request('/v2/checkout/authorizations', {
|
|
549
|
-
method: 'POST',
|
|
550
|
-
body: { user, merchant: input.merchant, amount: input.amount, currency: input.currency, psp: input.psp ?? 'stripe', mode: input.mode ?? 'token', request, ...(returnUrl ? { return_url: returnUrl } : {}), ...(checkoutOrigin ? { checkout_origin: checkoutOrigin } : {}), ...(input.card_id ? { card_id: input.card_id } : {}) },
|
|
551
|
-
headers: { 'idempotency-key': input.idempotency_key ?? idempotencyKey() },
|
|
552
|
-
});
|
|
553
|
-
const approvalUrl = body.approval_url ?? body.approvalUrl;
|
|
554
|
-
return { id: body.id, status: body.status, execution_mode: body.execution_mode, ...executionReasonFields(body), ...approvalRequestFields(body), ...(approvalUrl ? { approval_url: approvalUrl } : {}), ...(typeof body.return_url === 'string' ? { return_url: body.return_url } : {}), ...cardFields(body) };
|
|
555
|
-
},
|
|
556
|
-
/**
|
|
557
|
-
* Names the card that pays, before the approval (decision 213): the person said "another card", or chose
|
|
558
|
-
* one from `cards`. `cardId` is one of the person's stored cards as `cards` lists them; a card that is not
|
|
559
|
-
* theirs is `card_not_found`. Refused once the purchase is decided (`already_finalized`) or while the card is
|
|
560
|
-
* with the processor (`replay_in_progress`). Answers the purchase as `get` reads it, `card` and `cards`
|
|
561
|
-
* included. Ask for the key word after this, so the line names the card that will pay.
|
|
562
|
-
*/
|
|
563
|
-
setCard: async (authorizationId, cardId) => {
|
|
564
|
-
this.posture();
|
|
565
|
-
if (typeof cardId !== 'string' || !cardId)
|
|
566
|
-
throw new AgentcardError('invalid_card', 'Name the card: one of the ids the purchase\'s `cards` lists. Refused before any call.');
|
|
567
|
-
const a = await this.request(`/v2/checkout/authorizations/${encodeURIComponent(authorizationId)}/card`, { method: 'POST', body: { card_id: cardId } });
|
|
568
|
-
return resultOf(a);
|
|
569
|
-
},
|
|
570
|
-
/**
|
|
571
|
-
* One read of a purchase, with no wait: its status, what approved it, the
|
|
572
|
-
* request fields, the settlement once it finished, and what a paused
|
|
573
|
-
* page resumes with. The way to ask "is it decided yet?" from a handler
|
|
574
|
-
* that cannot wait; `awaitResult` is the way to wait. `slow_down` is
|
|
575
|
-
* waited out.
|
|
576
|
-
*/
|
|
577
|
-
get: async (authorizationId) => {
|
|
578
|
-
this.posture();
|
|
579
|
-
const a = await this.read(`/v2/checkout/authorizations/${encodeURIComponent(authorizationId)}`);
|
|
580
|
-
return resultOf(a);
|
|
581
|
-
},
|
|
582
|
-
/**
|
|
583
|
-
* Withdraws a purchase that still awaits the person, in the business's own words: the person closed the
|
|
584
|
-
* sheet (`person_closed_sheet`), the person changed their mind (`person_changed_mind`), or a reason of the
|
|
585
|
-
* business's own (`business_cancelled`). The purchase reads `status: 'cancelled'` from then on, the reason
|
|
586
|
-
* is its `terminal_reason` on every read and on the `checkout_authorization.cancelled` webhook, and
|
|
587
|
-
* `awaitResult` reads `status: 'cancelled'` at once. Idempotent: a purchase already
|
|
588
|
-
* withdrawn answers what it became. One that the person already approved cannot be withdrawn
|
|
589
|
-
* (`409 already_finalized` once it finished, `409 outcome_unknown` while the card is with the processor).
|
|
590
|
-
*/
|
|
591
|
-
cancel: async (authorizationId, opts) => {
|
|
592
|
-
this.posture();
|
|
593
|
-
const reason = opts?.reason;
|
|
594
|
-
if (!CANCEL_REASONS.includes(reason))
|
|
595
|
-
throw new AgentcardError('invalid_cancel_reason', `reason must be one of ${CANCEL_REASONS.join(', ')}.`);
|
|
596
|
-
const body = await this.request(`/v2/checkout/authorizations/${encodeURIComponent(authorizationId)}/cancel`, { method: 'POST', body: { reason } });
|
|
597
|
-
const stamped = CANCEL_REASONS.includes(body.reason) ? body.reason : reason;
|
|
598
|
-
return { id: body.id ?? authorizationId, status: 'cancelled', reason: stamped, terminal_reason: (typeof body.terminal_reason === 'string' ? body.terminal_reason : stamped), request_id: body.request_id ?? null };
|
|
599
|
-
},
|
|
600
|
-
};
|
|
601
|
-
approvals = {
|
|
602
|
-
/**
|
|
603
|
-
* The purchase's approval page on the Vault. It offers whichever doors the
|
|
604
|
-
* device has (voice, Face ID, Touch ID, the master password), so it is the
|
|
605
|
-
* way in for a person who forgot the passphrase, a bot with no microphone,
|
|
606
|
-
* or an agent with no screen, and it is the person-held tier of an in-app
|
|
607
|
-
* approval (`approve` from the app half opens it in the sheet). The read
|
|
608
|
-
* names the return link (this call's, else the client's `returnUrl`),
|
|
609
|
-
* the exact registered link the Vault sends the person back to once
|
|
610
|
-
* approved, and Agentcard stores it on the request, so the sheet closes on
|
|
611
|
-
* its own; the link returned is id-only, the page reads the return from
|
|
612
|
-
* Agentcard. Open it (or send it), then `awaitResult`. Null once the
|
|
613
|
-
* purchase no longer awaits a person.
|
|
614
|
-
*/
|
|
615
|
-
approvalUrl: async (authorizationId, opts = {}) => {
|
|
616
|
-
this.posture();
|
|
617
|
-
const returnUrl = opts.return_url ?? this.returnUrl;
|
|
618
|
-
const a = await this.read(`/v2/checkout/authorizations/${encodeURIComponent(authorizationId)}${returnUrl ? `?return_url=${encodeURIComponent(returnUrl)}` : ''}`);
|
|
619
|
-
return a.status === 'awaiting_approval' ? (a.approval_url ?? a.approvalUrl ?? null) : null;
|
|
620
|
-
},
|
|
621
|
-
};
|
|
622
|
-
/**
|
|
623
|
-
* Asks for the one-time key word. Your bot says `say[lang]` when the
|
|
624
|
-
* person wears headphones, `say_private[lang]` when the phone plays through
|
|
625
|
-
* its speaker or the route is unknown (the line then points at the screen,
|
|
626
|
-
* where your app shows `word`). With `voice: 'braxter'` the response also
|
|
627
|
-
* carries `audio_url`, Agentcard's own voice saying the line, when the
|
|
628
|
-
* deployment has it; `delivery: 'screen'` asks for the private form of that
|
|
629
|
-
* audio. Every field is typed: `challenge` and `intent` are what the app's
|
|
630
|
-
* hosted frame signs over, `verifier_url` and `answer_token` where and with
|
|
631
|
-
* what the phone streams. `lang` defaults to `en` and is checked against
|
|
632
|
-
* `languages()` first. 409 `voice_not_enrolled` when the person has no
|
|
633
|
-
* voice key for apps.
|
|
634
|
-
*/
|
|
635
|
-
async challenge(authorizationId, opts = {}) {
|
|
636
|
-
this.posture();
|
|
637
|
-
const lang = opts.lang ?? 'en';
|
|
638
|
-
await this.t.languages.check(lang);
|
|
639
|
-
const ch = await this.request(`/v2/checkout/authorizations/${encodeURIComponent(authorizationId)}/voice_challenge`, { method: 'POST', body: { lang, ...(opts.voice ? { voice: opts.voice } : {}), ...(opts.delivery ? { delivery: opts.delivery } : {}) } });
|
|
640
|
-
return { ...challengeOf(ch, authorizationId), lang: typeof ch.lang === 'string' && ch.lang ? ch.lang : lang };
|
|
641
|
-
}
|
|
642
|
-
/**
|
|
643
|
-
* The key word for an in-app approval, as one sealed bundle: mints the
|
|
644
|
-
* challenge and hands back the object your app forwards to
|
|
645
|
-
* `VoiceHost.approve` unread. It carries the word and both lines, Agentcard's
|
|
646
|
-
* audio when asked for, the nonce and the intent the frame signs over, and
|
|
647
|
-
* the verifier's address with the answer token the phone streams with (both
|
|
648
|
-
* absent from an API older than them). Refuses with `challenge_incomplete`
|
|
649
|
-
* when the API minted no nonce or intent, since the frame cannot sign
|
|
650
|
-
* without them.
|
|
651
|
-
*/
|
|
652
|
-
async challengeForHost(authorizationId, opts = {}) {
|
|
653
|
-
const ch = await this.challenge(authorizationId, opts);
|
|
654
|
-
return bundleOf(ch);
|
|
655
|
-
}
|
|
656
|
-
appKeys = {
|
|
657
|
-
/**
|
|
658
|
-
* Registers the app voice key your app's hosted frame minted (`VoiceHost.provision` relays its description),
|
|
659
|
-
* under this client's credentials, against the person connected to your app. Hand the `credential` back to
|
|
660
|
-
* the phone and nothing else. `user_id` is the person the connect exchange returned; required. 404
|
|
661
|
-
* `connection_not_found` when the person never connected through this client; 409 `credential_exists`
|
|
662
|
-
* when the credential id already names a different key. The same key again answers with a fresh credential.
|
|
663
|
-
* `hardware` (WP199), when the phone minted a hardware key for this app voice key, is its attestation as the
|
|
664
|
-
* phone handed it over: on the key itself (`key.hardware`, where `VoiceHost` 1.9.8 puts it, so a server that
|
|
665
|
-
* forwards the key as it came needs no change) or in `opts.hardware`, which wins when both are given. It is sent
|
|
666
|
-
* on as is, and the answer's `hardware_proven` and `hardware_level` say what Agentcard made of it.
|
|
667
|
-
*/
|
|
668
|
-
register: async (key, opts) => {
|
|
669
|
-
this.posture();
|
|
670
|
-
const user = opts?.user_id;
|
|
671
|
-
if (typeof user !== 'string' || !user)
|
|
672
|
-
throw new AgentcardError('not_connected', 'Name user_id: the person the connect exchange returned. Refused before any call.');
|
|
673
|
-
if (!key || typeof key.credential_id !== 'string' || typeof key.public_key !== 'string')
|
|
674
|
-
throw new AgentcardError('invalid_app_key', 'The app voice key needs credential_id and public_key, as VoiceHost describes it.');
|
|
675
|
-
await this.t.languages.check(key.lang);
|
|
676
|
-
const hardware = opts.hardware ?? key.hardware;
|
|
677
|
-
const body = { credential_id: key.credential_id, public_key: key.public_key, kind: 'app_voice_key', alg: 'ES256', proof_version: 1, ...(key.lang ? { lang: key.lang } : {}), ...(key.device_label ? { device_label: key.device_label } : {}), ...(hardware ? { hardware } : {}) };
|
|
678
|
-
const reg = await this.request(`/api/v2/vault_users/${encodeURIComponent(user)}/app_voice_keys`, { method: 'POST', body });
|
|
679
|
-
if (!reg || !reg.credential || typeof reg.credential.token !== 'string')
|
|
680
|
-
throw new AgentcardError('register_failed', 'Agentcard registered the key without a scoped credential.');
|
|
681
|
-
return reg;
|
|
682
|
-
},
|
|
683
|
-
/** Revokes one app voice key of the person `user_id` names; the person's other keys, the card and the connection stay. Idempotent. */
|
|
684
|
-
revoke: async (keyId, opts) => {
|
|
685
|
-
this.posture();
|
|
686
|
-
const user = opts?.user_id;
|
|
687
|
-
if (typeof user !== 'string' || !user)
|
|
688
|
-
throw new AgentcardError('not_connected', 'Name user_id: the person the connect exchange returned. Refused before any call.');
|
|
689
|
-
const r = await this.request(`/api/v2/vault_users/${encodeURIComponent(user)}/app_voice_keys/${encodeURIComponent(keyId)}`, { method: 'DELETE' });
|
|
690
|
-
return { id: r.id ?? keyId, revoked: r.revoked !== false, revoked_at: typeof r.revoked_at === 'string' ? r.revoked_at : null };
|
|
691
|
-
},
|
|
692
|
-
};
|
|
693
|
-
/**
|
|
694
|
-
* The streaming door from your server, the push direction: your pipeline
|
|
695
|
-
* already has the person's audio, so hand it to the verifier as it comes.
|
|
696
|
-
* `push` the bytes in `format`, `end` when the person is done (or let the
|
|
697
|
-
* verifier close the span itself), and read `hearing` and `verdict`. A
|
|
698
|
-
* finished recording is a short stream: see `streamBuffer`. The bearer is
|
|
699
|
-
* this client's token. The address is `stream_url` on the call, else the
|
|
700
|
-
* client's `voice.verifierUrl`, else the `verifier_url` the challenge
|
|
701
|
-
* itself carries. `lang` defaults to the challenge's language, else `en`.
|
|
702
|
-
*/
|
|
703
|
-
async stream(challenge, opts) {
|
|
704
|
-
this.posture();
|
|
705
|
-
const authorizationId = opts.authorization_id ?? challenge.authorization_id;
|
|
706
|
-
if (!authorizationId)
|
|
707
|
-
throw new AgentcardError('missing_authorization', 'Name authorization_id, or pass the challenge voice.challenge returned.');
|
|
708
|
-
const challengeId = 'challenge_id' in challenge ? challenge.challenge_id : challenge.id;
|
|
709
|
-
const verifierUrl = this.options?.verifierUrl ?? challenge.verifier_url;
|
|
710
|
-
const base = opts.stream_url ?? (verifierUrl ? streamAddress(verifierUrl) : null);
|
|
711
|
-
if (!base)
|
|
712
|
-
throw new AgentcardError('missing_verifier_url', 'Name stream_url, or construct the client with voice.verifierUrl.');
|
|
713
|
-
const lang = opts.lang ?? challenge.lang ?? 'en';
|
|
714
|
-
await this.t.languages.check(lang);
|
|
715
|
-
const token = await withRetries(() => this.t.bearer(), this.backoff);
|
|
716
|
-
return openStream({ stream_url: base.replace(/^http/, 'ws'), authorization_id: authorizationId, challenge_id: challengeId, lang, format: opts.format, token, ...(opts.channel ? { channel: opts.channel } : {}) });
|
|
717
|
-
}
|
|
718
|
-
/**
|
|
719
|
-
* "I forgot my passphrase." Mints a voice-only link that starts the
|
|
720
|
-
* person's voice key over: the Vault forgets the device key, revokes the
|
|
721
|
-
* enrolment, and runs set-up again. Open `url` in the sheet, then
|
|
722
|
-
* `awaitConnected(session_id)`; `voice` reads `['en']` once it is done.
|
|
723
|
-
*/
|
|
724
|
-
async reset(input = {}) {
|
|
725
|
-
const approval = this.posture();
|
|
726
|
-
await this.t.languages.check(input.lang);
|
|
727
|
-
const returnUrl = input.return_url ?? this.returnUrl;
|
|
728
|
-
const body = await this.request('/api/v2/vault_sessions', {
|
|
729
|
-
method: 'POST',
|
|
730
|
-
body: { mode: 'open', ...(returnUrl ? { return_url: returnUrl } : {}), steps: ['voice'], reset: true, voice: 'required', ...(input.lang ? { lang: input.lang } : {}) },
|
|
731
|
-
});
|
|
732
|
-
return { url: body.url, session_id: body.id, voice: 'required', approval, grant: 'poll', request_id: body.request_id ?? null };
|
|
733
|
-
}
|
|
734
|
-
/**
|
|
735
|
-
* Polls the purchase until it leaves `awaiting_approval`; `{ status: 'timeout' }`
|
|
736
|
-
* at the deadline. The first read's `interval` (seconds) is the least this
|
|
737
|
-
* waits between reads, and a `slow_down` answer backs off to the interval
|
|
738
|
-
* it names and keeps polling. The result carries `request_id`,
|
|
739
|
-
* `terminal_reason`, `interaction_evidence`, `intent` and the other request
|
|
740
|
-
* fields beside `status`, and on a finished purchase the `settlement`
|
|
741
|
-
* answer the finishing webhook carries. For one read with no wait,
|
|
742
|
-
* `authorizations.get(id)`.
|
|
743
|
-
*/
|
|
744
|
-
async awaitResult(authorizationId, opts = {}) {
|
|
745
|
-
this.posture();
|
|
746
|
-
let intervalMs;
|
|
747
|
-
const deadline = Date.now() + (opts.timeout_ms ?? this.pollTimeoutMs);
|
|
748
|
-
return this.poll(async () => {
|
|
749
|
-
let a;
|
|
750
|
-
try {
|
|
751
|
-
// The shared read waits out slow_down up to this call's deadline; past it the refusal is the poll
|
|
752
|
-
// loop's cue to space its next read by the interval named, and the deadline reads `timeout`.
|
|
753
|
-
a = await this.read(`/v2/checkout/authorizations/${encodeURIComponent(authorizationId)}`, { deadline, onInterval: (ms) => { intervalMs = ms; } });
|
|
754
|
-
}
|
|
755
|
-
catch (err) {
|
|
756
|
-
if (err instanceof AgentcardError && err.code === 'slow_down')
|
|
757
|
-
return undefined;
|
|
758
|
-
throw err;
|
|
759
|
-
}
|
|
760
|
-
if (typeof a.interval === 'number')
|
|
761
|
-
intervalMs = a.interval * 1000;
|
|
762
|
-
if (a.status === 'awaiting_approval')
|
|
763
|
-
return undefined;
|
|
764
|
-
return resultOf(a);
|
|
765
|
-
}, opts.timeout_ms, () => ({ status: 'timeout' }), () => intervalMs);
|
|
766
|
-
}
|
|
767
|
-
actions = {
|
|
768
|
-
/**
|
|
769
|
-
* Mints the link that carries one action. Open `url` in the sheet:
|
|
770
|
-
* Agentcard's voice reads the action back, the person confirms it with the
|
|
771
|
-
* secure passphrase and the key word, the Vault runs it and returns.
|
|
772
|
-
* Throws `action_unavailable` for a kind the Vault cannot carry out yet.
|
|
773
|
-
*/
|
|
774
|
-
propose: async (action, input = {}) => {
|
|
775
|
-
this.posture();
|
|
776
|
-
await this.t.languages.check(input.lang);
|
|
777
|
-
const returnUrl = input.return_url ?? this.returnUrl;
|
|
778
|
-
const body = await this.request('/api/v2/vault_sessions', {
|
|
779
|
-
method: 'POST',
|
|
780
|
-
body: { mode: 'open', ...(returnUrl ? { return_url: returnUrl } : {}), steps: ['action'], action, ...(input.lang ? { lang: input.lang } : {}) },
|
|
781
|
-
});
|
|
782
|
-
return { url: body.url, session_id: body.id, action_hash: body.action_hash ?? null };
|
|
783
|
-
},
|
|
784
|
-
/**
|
|
785
|
-
* One read of an action's link, with no wait: whether the action is done and confirmed, whether a rule
|
|
786
|
-
* covers the card now, the link's status, and the last step and reason its page recorded.
|
|
787
|
-
*/
|
|
788
|
-
get: async (sessionId) => {
|
|
789
|
-
this.posture();
|
|
790
|
-
const s = await this.read(`/api/v2/vault_sessions/${encodeURIComponent(sessionId)}`);
|
|
791
|
-
const status = s.status === 'linked' ? 'linked' : s.status === 'expired' ? 'expired' : 'pending';
|
|
792
|
-
return { session_id: typeof s.id === 'string' ? s.id : sessionId, status, done: s.action?.done === true, confirmed: s.action?.confirmed === true, rule: s.rule === true, kind: s.action?.kind ?? null, last_step: stepOf(s.last_step), reason: reasonOf(s.reason) };
|
|
793
|
-
},
|
|
794
|
-
/**
|
|
795
|
-
* Polls the action's session until it reads done, or the deadline; the last state comes back either
|
|
796
|
-
* way, with the last step the Vault's page reached and the last reason it recorded, so a bot that
|
|
797
|
-
* gets `done: false` can say why ("your voice didn't confirm it", "that browser holds no voice key").
|
|
798
|
-
* For one read with no wait, `actions.get(session_id)`.
|
|
799
|
-
*/
|
|
800
|
-
awaitDone: async (sessionId, opts = {}) => {
|
|
801
|
-
this.posture();
|
|
802
|
-
const deadline = Date.now() + (opts.timeout_ms ?? this.pollTimeoutMs);
|
|
803
|
-
let last = { done: false, confirmed: false, rule: false, last_step: null, reason: null };
|
|
804
|
-
return this.poll(async () => {
|
|
805
|
-
const s = await this.read(`/api/v2/vault_sessions/${encodeURIComponent(sessionId)}`, { deadline });
|
|
806
|
-
last = { done: s.action?.done === true, confirmed: s.action?.confirmed === true, rule: s.rule === true, last_step: stepOf(s.last_step), reason: reasonOf(s.reason) };
|
|
807
|
-
if (last.done)
|
|
808
|
-
return last;
|
|
809
|
-
if (s.status === 'expired')
|
|
810
|
-
throw new AgentcardError('vault_session_expired', 'The Vault link is no longer usable.');
|
|
811
|
-
return undefined;
|
|
812
|
-
}, opts.timeout_ms, () => last);
|
|
813
|
-
},
|
|
814
|
-
};
|
|
815
|
-
}
|