@integraledger/agent-guard 0.9.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 +57 -0
- package/LICENSE +202 -0
- package/NOTICE +15 -0
- package/README.md +154 -0
- package/dist/decision.d.ts +26 -0
- package/dist/decision.d.ts.map +1 -0
- package/dist/decision.js +28 -0
- package/dist/decision.js.map +1 -0
- package/dist/evaluate.d.ts +15 -0
- package/dist/evaluate.d.ts.map +1 -0
- package/dist/evaluate.js +112 -0
- package/dist/evaluate.js.map +1 -0
- package/dist/fetch.d.ts +61 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +126 -0
- package/dist/fetch.js.map +1 -0
- package/dist/fingerprint.d.ts +14 -0
- package/dist/fingerprint.d.ts.map +1 -0
- package/dist/fingerprint.js +15 -0
- package/dist/fingerprint.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/log.d.ts +32 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +8 -0
- package/dist/log.js.map +1 -0
- package/dist/mechanical.d.ts +30 -0
- package/dist/mechanical.d.ts.map +1 -0
- package/dist/mechanical.js +35 -0
- package/dist/mechanical.js.map +1 -0
- package/dist/policy.d.ts +36 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +96 -0
- package/dist/policy.js.map +1 -0
- package/dist/proposal-ack.d.ts +111 -0
- package/dist/proposal-ack.d.ts.map +1 -0
- package/dist/proposal-ack.js +114 -0
- package/dist/proposal-ack.js.map +1 -0
- package/dist/proposal-acp.d.ts +18 -0
- package/dist/proposal-acp.d.ts.map +1 -0
- package/dist/proposal-acp.js +72 -0
- package/dist/proposal-acp.js.map +1 -0
- package/dist/proposal-ap2.d.ts +121 -0
- package/dist/proposal-ap2.d.ts.map +1 -0
- package/dist/proposal-ap2.js +112 -0
- package/dist/proposal-ap2.js.map +1 -0
- package/dist/proposal-mpp.d.ts +44 -0
- package/dist/proposal-mpp.d.ts.map +1 -0
- package/dist/proposal-mpp.js +106 -0
- package/dist/proposal-mpp.js.map +1 -0
- package/dist/proposal-universal.d.ts +239 -0
- package/dist/proposal-universal.d.ts.map +1 -0
- package/dist/proposal-universal.js +470 -0
- package/dist/proposal-universal.js.map +1 -0
- package/dist/proposal.d.ts +33 -0
- package/dist/proposal.d.ts.map +1 -0
- package/dist/proposal.js +107 -0
- package/dist/proposal.js.map +1 -0
- package/dist/transact.d.ts +24 -0
- package/dist/transact.d.ts.map +1 -0
- package/dist/transact.js +11 -0
- package/dist/transact.js.map +1 -0
- package/package.json +81 -0
- package/src/decision.ts +54 -0
- package/src/evaluate.ts +176 -0
- package/src/fetch.ts +169 -0
- package/src/fingerprint.ts +27 -0
- package/src/index.ts +68 -0
- package/src/log.ts +34 -0
- package/src/mechanical.ts +71 -0
- package/src/policy.ts +135 -0
- package/src/proposal-ack.ts +216 -0
- package/src/proposal-acp.ts +89 -0
- package/src/proposal-ap2.ts +195 -0
- package/src/proposal-mpp.ts +125 -0
- package/src/proposal-universal.ts +671 -0
- package/src/proposal.ts +170 -0
- package/src/transact.ts +34 -0
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CarrierClass,
|
|
3
|
+
decodeDeclaredRead,
|
|
4
|
+
type LegalContextRef,
|
|
5
|
+
type PlacementContainer,
|
|
6
|
+
type PlacementEncoding,
|
|
7
|
+
type PlacementManifest,
|
|
8
|
+
type ProtocolId,
|
|
9
|
+
readAtPath,
|
|
10
|
+
readDeclaredPaths,
|
|
11
|
+
readFromContainer,
|
|
12
|
+
} from "@integraledger/lcp-binding-core";
|
|
13
|
+
import {
|
|
14
|
+
type PlacementDeployment,
|
|
15
|
+
placementFor,
|
|
16
|
+
} from "@integraledger/lcp-placements";
|
|
17
|
+
import {
|
|
18
|
+
type GateProposal,
|
|
19
|
+
type ProposalContext,
|
|
20
|
+
parseProposalFromChallenge,
|
|
21
|
+
} from "./proposal.js";
|
|
22
|
+
import { parseProposalFromAcpCheckout } from "./proposal-acp.js";
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
25
|
+
// Structural predicates. Every read goes through `readAtPath`, never through `doc.x` or `doc["x"]`, so the
|
|
26
|
+
// own-property and array-traversal rules a hostile wire is read under are binding-core's audited ones rather
|
|
27
|
+
// than a second set invented here. A prototype key answering as though it were a declared field is exactly
|
|
28
|
+
// how a detector gets talked into naming the wrong protocol.
|
|
29
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
32
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isStringAt(doc: unknown, path: string): boolean {
|
|
36
|
+
return typeof readAtPath(doc, path) === "string";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isArrayAt(doc: unknown, path: string): boolean {
|
|
40
|
+
return Array.isArray(readAtPath(doc, path));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function stringAtIsOneOf(
|
|
44
|
+
doc: unknown,
|
|
45
|
+
path: string,
|
|
46
|
+
permitted: readonly string[],
|
|
47
|
+
): boolean {
|
|
48
|
+
const value = readAtPath(doc, path);
|
|
49
|
+
// `some` rather than a `typeof` guard plus `includes`: the guard would be a branch no input can take, since
|
|
50
|
+
// a list of strings never contains a non-string, and a branch nothing reaches is one no test can constrain.
|
|
51
|
+
return permitted.some((token) => token === value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* ACP's `CheckoutSession.status` — the closed eleven-value enum, and the discriminant that separates an ACP
|
|
56
|
+
* session from every other checkout-shaped document in the set.
|
|
57
|
+
*
|
|
58
|
+
* DERIVED, NOT AUTHORED — from the live ACP schema, which `placement-acp` enumerated protocol-side in a
|
|
59
|
+
* write condition it no longer declares (checked 2026-07-30).
|
|
60
|
+
*
|
|
61
|
+
* ⚠️ THIS COPY NO LONGER HAS A DRIFT GUARD. It was pinned equal to the manifest's write condition through
|
|
62
|
+
* the registry, but the protocol line declares no `writeCondition` on any ACP manifest — its `readAlso` aliases
|
|
63
|
+
* carry `{path, encoding}` only — so the enum is not published anywhere to pin against. The pin test was
|
|
64
|
+
* deleted rather than loosened (an assertion over an absent field guards nothing). Re-derive this list
|
|
65
|
+
* against the live ACP schema when touching it; the discriminant tests only prove it matches our fixtures.
|
|
66
|
+
*/
|
|
67
|
+
export const ACP_SESSION_STATUS: readonly string[] = [
|
|
68
|
+
"incomplete",
|
|
69
|
+
"not_ready_for_payment",
|
|
70
|
+
"requires_escalation",
|
|
71
|
+
"authentication_required",
|
|
72
|
+
"ready_for_payment",
|
|
73
|
+
"pending_approval",
|
|
74
|
+
"complete_in_progress",
|
|
75
|
+
"completed",
|
|
76
|
+
"canceled",
|
|
77
|
+
"in_progress",
|
|
78
|
+
"expired",
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The two Verifiable Intent Autonomous-mode credential types, derived from Mastercard's own open-mandate
|
|
83
|
+
* definitions. Formerly pinned against `placement-mastercard-vi`'s write condition; that placement is now
|
|
84
|
+
* declaration-only (LCP v1.38 §C.7 — an unregistered constraint type gets the WHOLE mandate rejected by a
|
|
85
|
+
* stock verifier), so it declares no write condition and there is nothing to pin against. Same standing as
|
|
86
|
+
* {@link ACP_SESSION_STATUS}: hand-kept, re-derive against the host when touching it.
|
|
87
|
+
*/
|
|
88
|
+
export const VI_OPEN_MANDATE_VCT: readonly string[] = [
|
|
89
|
+
"mandate.checkout.open.1",
|
|
90
|
+
"mandate.payment.open.1",
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
/** The two AP2 v0.2 mandate DataPart keys. An envelope carrying either is carrying an AP2 mandate. */
|
|
94
|
+
const AP2_MANDATE_DATA_KEYS: readonly string[] = [
|
|
95
|
+
"ap2.mandates.CheckoutMandateSdJwt",
|
|
96
|
+
"ap2.mandates.PaymentMandateSdJwt",
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
/** Visa TAP's two agent-recognition signature tags, as they appear quoted in an RFC 9421 `Signature-Input`. */
|
|
100
|
+
const TAP_SIGNATURE_TAGS: readonly string[] = [
|
|
101
|
+
"agent-browser-auth",
|
|
102
|
+
"agent-payer-auth",
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
/** An A2A `Task`: `id` and `status` are the two REQUIRED members (a2a.proto lines 178-190). */
|
|
106
|
+
function isA2aTask(doc: unknown): boolean {
|
|
107
|
+
return isStringAt(doc, "id") && isStringAt(doc, "status.state");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** An A2A `Message`: `messageId`, `role` and `parts` are its three REQUIRED members (a2a.proto 267-280). */
|
|
111
|
+
function isA2aMessage(doc: unknown): boolean {
|
|
112
|
+
return (
|
|
113
|
+
isStringAt(doc, "messageId") &&
|
|
114
|
+
isStringAt(doc, "role") &&
|
|
115
|
+
isArrayAt(doc, "parts")
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Does this document carry an AP2 mandate DataPart?
|
|
121
|
+
*
|
|
122
|
+
* Reads `parts` itself rather than trusting a caller to have checked it, because the AP2 rule asks this
|
|
123
|
+
* question FIRST — the mandate part is AP2's own half of the discriminant, and the A2A envelope check is a
|
|
124
|
+
* second, weaker fact about it.
|
|
125
|
+
*/
|
|
126
|
+
function carriesAp2Mandate(doc: unknown): boolean {
|
|
127
|
+
const parts = readAtPath(doc, "parts");
|
|
128
|
+
if (!Array.isArray(parts)) return false;
|
|
129
|
+
return parts.some((_, index) => {
|
|
130
|
+
const data = readAtPath(doc, `parts.${index}.data`);
|
|
131
|
+
return (
|
|
132
|
+
isRecord(data) &&
|
|
133
|
+
AP2_MANDATE_DATA_KEYS.some((key) => Object.hasOwn(data, key))
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
139
|
+
// The discriminant table
|
|
140
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* How ONE protocol's document is recognized — or the recorded fact that it cannot be.
|
|
144
|
+
*
|
|
145
|
+
* A keyed union rather than an optional `matches`, because "no rule" and "a rule that never fires" are
|
|
146
|
+
* different claims and only one of them is honest about a protocol whose document carries nothing to
|
|
147
|
+
* recognize. An `undiscriminable` row is a finding with a citation, not a hole: it says the live specification
|
|
148
|
+
* was read and no identifying member exists, which is the answer a future unit needs in order not to redo the
|
|
149
|
+
* reading, and it keeps the table TOTAL over the placement registry so a newly registered protocol cannot slip
|
|
150
|
+
* in unnoticed.
|
|
151
|
+
*
|
|
152
|
+
* `cite` is data rather than a comment because a claim about somebody else's protocol is exactly the kind that
|
|
153
|
+
* gets stale, and an audit trail that only a reader of the source can see is not one a deployment can act on.
|
|
154
|
+
*/
|
|
155
|
+
export type ProtocolDiscriminant =
|
|
156
|
+
| {
|
|
157
|
+
readonly protocol: ProtocolId;
|
|
158
|
+
readonly kind: "structural";
|
|
159
|
+
/** Does this document belong to `protocol`? TOTAL — never throws, on any shape a wire can present. */
|
|
160
|
+
readonly matches: (doc: unknown) => boolean;
|
|
161
|
+
/** The host protocol's own source the rule was read from, and when. */
|
|
162
|
+
readonly cite: string;
|
|
163
|
+
}
|
|
164
|
+
| {
|
|
165
|
+
readonly protocol: ProtocolId;
|
|
166
|
+
readonly kind: "undiscriminable";
|
|
167
|
+
/** Why no rule exists, and where the protocol's identity actually lives instead. */
|
|
168
|
+
readonly reason: string;
|
|
169
|
+
readonly cite: string;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Every supported protocol's document discriminant, one row each.
|
|
174
|
+
*
|
|
175
|
+
* DETECTION IS BY DISCRIMINANT, NEVER BY "does the field I want happen to be present": a document is x402
|
|
176
|
+
* because it carries `x402Version`, not because it has an atrHash somewhere. Detecting on the LCP field would
|
|
177
|
+
* make every protocol look alike the moment it carried a reference — which is exactly the situation this table
|
|
178
|
+
* exists to disambiguate.
|
|
179
|
+
*
|
|
180
|
+
* EVERY RULE IS POSITIVE. No row says "and not the other protocol's marker". Documents CAN satisfy two rows,
|
|
181
|
+
* and where they do the answer is AMBIGUITY, which {@link parseProposalUniversal} refuses. A negative term
|
|
182
|
+
* would convert that refusal into a silent choice, and a wrong negative term (one protocol adding a field
|
|
183
|
+
* another already had) converts it into a silent WRONG choice. Refusing is never the wrong answer; guessing
|
|
184
|
+
* sometimes is.
|
|
185
|
+
*
|
|
186
|
+
* THE TWO OVERLAPS ARE NOT THE SAME STRENGTH, and the difference is load-bearing:
|
|
187
|
+
*
|
|
188
|
+
* - ACP/UCP is CONTINGENT. A UCP checkout response that also carries `currency`, `totals` and `line_items`
|
|
189
|
+
* satisfies both rows; one that does not carries only `ucp`, `id`, `status` and `links` and is
|
|
190
|
+
* unambiguously UCP. Either protocol's documents remain individually reachable.
|
|
191
|
+
* - AP2/A2A is TOTAL. `ap2` matches `carriesAp2Mandate(doc) && isA2aMessage(doc)`, and `a2a` matches
|
|
192
|
+
* `isA2aTask(doc) || isA2aMessage(doc)`, so the `ap2` predicate is a strict logical SUBSET of the `a2a`
|
|
193
|
+
* one: every document the AP2 row fires on fires the A2A row too, without exception and by construction.
|
|
194
|
+
* So NO AP2 document is reachable through {@link parseProposalUniversal} — the ambiguity refusal always
|
|
195
|
+
* triggers first, and registering an `ap2` entry in {@link PROPOSAL_PARSERS} would not make one parseable.
|
|
196
|
+
* A caller holding an AP2 envelope names `ap2` and calls its parser directly. That is a consequence of AP2
|
|
197
|
+
* defining no transport of its own (v0.2 rides A2A), not a defect to engineer away: a negative term on the
|
|
198
|
+
* A2A row would be this package asserting that a valid A2A message is not one, which is A2A's call to
|
|
199
|
+
* make rather than ours — the host protocol defines its own documents.
|
|
200
|
+
*
|
|
201
|
+
* ORDERED as `KNOWN_PROTOCOL_IDS` orders the closed set, so `matchProtocols` reports in a stable order rather
|
|
202
|
+
* than in the order units happened to land.
|
|
203
|
+
*/
|
|
204
|
+
export const PROTOCOL_DISCRIMINANTS: readonly ProtocolDiscriminant[] = [
|
|
205
|
+
{
|
|
206
|
+
protocol: "x402",
|
|
207
|
+
kind: "structural",
|
|
208
|
+
matches: (doc) =>
|
|
209
|
+
typeof readAtPath(doc, "x402Version") === "number" &&
|
|
210
|
+
isArrayAt(doc, "accepts"),
|
|
211
|
+
cite: "x402 v2 PaymentRequired — `x402Version` plus the `accepts` requirement array (coinbase/x402 specs/x402-specification-v2.md §5.1.2, read 2026-07-30)",
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
protocol: "mpp",
|
|
215
|
+
kind: "undiscriminable",
|
|
216
|
+
reason:
|
|
217
|
+
"The document `placement-mpp` operates on is the DECODED `request` auth-param body, whose members are `amount` and `currency` (both REQUIRED strings, §5.1.1) plus optional `recipient`, `description`, `externalId` and `methodDetails` (§5.1.2). Not one of them names MPP, and an amount/currency pair is the shape of almost every payment document there is. MPP's identity lives one layer OUT, in the `WWW-Authenticate: Payment` challenge's auth-params (`realm`, `method`, `id`, `request`, `expires`, `digest`, `opaque`) — which is not the document the placement reads, so a caller holding a decoded request body must name `mpp` itself. Note `expires` is an auth-param ONLY: §5.1.2 states that expiry is conveyed in the challenge and that request objects MUST NOT duplicate it, so a body carrying one is malformed rather than discriminating.",
|
|
218
|
+
cite: "MPP charge intent draft-payment-intent-charge-00 §5.1.1 Table 2 (required: amount, currency) and §5.1.2 Table 3 (optional: recipient, description, externalId, methodDetails) (mpp.dev/intents/charge, read 2026-07-30)",
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
protocol: "ap2",
|
|
222
|
+
kind: "structural",
|
|
223
|
+
// AP2's own half first, A2A's second: the mandate DataPart is what makes this AP2 rather than any other
|
|
224
|
+
// A2A message, and asking it first is also what keeps the `parts`-is-an-array guard a reachable branch
|
|
225
|
+
// instead of one another predicate has already made true.
|
|
226
|
+
//
|
|
227
|
+
// This conjunction is a strict subset of the `a2a` row's disjunction, so the two ALWAYS co-fire — see the
|
|
228
|
+
// table docblock. `ap2` is therefore a detect-and-name row, never a dispatchable one.
|
|
229
|
+
matches: (doc) => carriesAp2Mandate(doc) && isA2aMessage(doc),
|
|
230
|
+
cite: "AP2 v0.2 defines no transport; its reference samples carry mandates as A2A DataParts keyed `ap2.mandates.CheckoutMandateSdJwt` / `ap2.mandates.PaymentMandateSdJwt`, and the reference rides the A2A envelope's `metadata` beside them (placement-ap2 specification gate discharged 2026-07-30)",
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
protocol: "ack",
|
|
234
|
+
kind: "structural",
|
|
235
|
+
matches: (doc) => {
|
|
236
|
+
const types = readAtPath(doc, "type");
|
|
237
|
+
return (
|
|
238
|
+
Array.isArray(types) &&
|
|
239
|
+
types.includes("PaymentReceiptCredential") &&
|
|
240
|
+
isRecord(readAtPath(doc, "credentialSubject"))
|
|
241
|
+
);
|
|
242
|
+
},
|
|
243
|
+
cite: 'ACK-Pay `createPaymentReceipt` mints a W3C credential typed ["VerifiableCredential", "PaymentReceiptCredential"] with a `credentialSubject` (agentcommercekit/ack packages/ack-pay/src/create-payment-receipt.ts + packages/vc/src/create-credential.ts, read 2026-07-30)',
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
protocol: "acp",
|
|
247
|
+
kind: "structural",
|
|
248
|
+
matches: (doc) =>
|
|
249
|
+
stringAtIsOneOf(doc, "status", ACP_SESSION_STATUS) &&
|
|
250
|
+
isStringAt(doc, "currency") &&
|
|
251
|
+
isArrayAt(doc, "totals") &&
|
|
252
|
+
isArrayAt(doc, "line_items"),
|
|
253
|
+
cite: "ACP agentic checkout (stable 2026-04-17) — `status` is REQUIRED on `CheckoutSessionBase` and is a closed eleven-value enum absent from all three request schemas; `currency`, `totals` and `line_items` are its siblings (enum enumerated live 2026-07-30, see placement-acp's write condition)",
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
protocol: "ucp",
|
|
257
|
+
kind: "structural",
|
|
258
|
+
matches: (doc) =>
|
|
259
|
+
isRecord(readAtPath(doc, "ucp")) &&
|
|
260
|
+
isStringAt(doc, "id") &&
|
|
261
|
+
isStringAt(doc, "status") &&
|
|
262
|
+
isArrayAt(doc, "links"),
|
|
263
|
+
cite: "UCP checkout response — `ucp` (the UCP metadata object), `id`, `status` and `links` are all REQUIRED, and `ucp` is the member no other protocol in the set carries (ucp.dev/latest/specification/checkout, read 2026-07-30)",
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
protocol: "visa-tap",
|
|
267
|
+
kind: "structural",
|
|
268
|
+
matches: (doc) => {
|
|
269
|
+
const signatureInput = readFromContainer(
|
|
270
|
+
doc,
|
|
271
|
+
{ kind: "header-map" },
|
|
272
|
+
"headers.signature-input",
|
|
273
|
+
);
|
|
274
|
+
return (
|
|
275
|
+
typeof signatureInput === "string" &&
|
|
276
|
+
TAP_SIGNATURE_TAGS.some((tag) => signatureInput.includes(`"${tag}"`))
|
|
277
|
+
);
|
|
278
|
+
},
|
|
279
|
+
cite: "Visa TAP agent recognition signature — an RFC 9421 `Signature-Input` whose `tag` parameter is `agent-browser-auth` or `agent-payer-auth` (placement-visa-tap specification gate discharged 2026-07-30)",
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
protocol: "mastercard-vi",
|
|
283
|
+
kind: "structural",
|
|
284
|
+
matches: (doc) =>
|
|
285
|
+
stringAtIsOneOf(doc, "vct", VI_OPEN_MANDATE_VCT) &&
|
|
286
|
+
isArrayAt(doc, "constraints"),
|
|
287
|
+
cite: "Verifiable Intent — `constraints` appears in Autonomous-mode open mandates only, whose `vct` is `mandate.checkout.open.1` or `mandate.payment.open.1` (placement-mastercard-vi specification gate discharged 2026-07-30)",
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
protocol: "a2a",
|
|
291
|
+
kind: "structural",
|
|
292
|
+
matches: (doc) => isA2aTask(doc) || isA2aMessage(doc),
|
|
293
|
+
cite: "A2A — `Task` REQUIRES `id` and `status`, `Message` REQUIRES `messageId`, `role` and `parts`; both carry the free-form `metadata` map this placement writes (a2aproject/A2A specification/a2a.proto @ main, read 2026-07-30)",
|
|
294
|
+
},
|
|
295
|
+
];
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Every protocol whose discriminant this document satisfies, in the table's order.
|
|
299
|
+
*
|
|
300
|
+
* ALL matches, never the first. A first-match detector cannot tell a document that belongs to one protocol
|
|
301
|
+
* from a document that belongs to two, and the second case is the one that costs a buyer money: an AP2
|
|
302
|
+
* envelope IS an A2A message, so both rows fire on it, and answering "a2a" because it came first would be a
|
|
303
|
+
* guess wearing an answer's clothes.
|
|
304
|
+
*/
|
|
305
|
+
export function matchProtocols(wire: unknown): readonly ProtocolId[] {
|
|
306
|
+
const matched: ProtocolId[] = [];
|
|
307
|
+
for (const row of PROTOCOL_DISCRIMINANTS) {
|
|
308
|
+
if (row.kind === "structural" && row.matches(wire))
|
|
309
|
+
matched.push(row.protocol);
|
|
310
|
+
}
|
|
311
|
+
return matched;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Identify which commerce protocol a wire document belongs to, structurally.
|
|
316
|
+
*
|
|
317
|
+
* Returns `undefined` unless EXACTLY ONE discriminant fires — so both "nothing matched" and "several matched"
|
|
318
|
+
* answer `undefined`, because a single-valued return cannot honestly distinguish them and inventing a
|
|
319
|
+
* preference is the failure this function exists to prevent. A caller that needs to tell the two apart calls
|
|
320
|
+
* {@link matchProtocols} and reads the length; {@link parseProposalUniversal} does exactly that, and refuses
|
|
321
|
+
* each case with its own message.
|
|
322
|
+
*
|
|
323
|
+
* This function never guesses and never throws.
|
|
324
|
+
*/
|
|
325
|
+
export function detectProtocol(wire: unknown): ProtocolId | undefined {
|
|
326
|
+
const matched = matchProtocols(wire);
|
|
327
|
+
return matched.length === 1 ? matched[0] : undefined;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
331
|
+
// The universal LCP read — dispatched through the placement registry
|
|
332
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* The terms URL a document advertises at the path its manifest declares — or the reason there is no answer,
|
|
336
|
+
* stated rather than folded into an `undefined`.
|
|
337
|
+
*
|
|
338
|
+
* A UNION, because `string | undefined` conflated three facts and only two of them are absences. The third is
|
|
339
|
+
* x402 today: `PlacementManifest.termsUrlField` is SINGULAR, and x402's names a path INSIDE the
|
|
340
|
+
* `extensions.legalContext.info` carrier — while the challenge LCP v1.38 §C.4 illustrates carries both the
|
|
341
|
+
* reference and the URL in `accepts[].extra` instead, which the manifest declares as a `readAlso` alias. Read
|
|
342
|
+
* such a challenge and the hash answers from the alias while the declared terms path holds nothing. That is
|
|
343
|
+
* not "this seller advertised no terms"; it is "the manifest says nothing about where a terms URL rides on the
|
|
344
|
+
* carrier that answered". Returning `undefined` there would assert the seller's silence — and
|
|
345
|
+
* `parseProposalFromChallenge`, reading the same bytes in the same package, would contradict it.
|
|
346
|
+
*
|
|
347
|
+
* The gap is owed back to the manifest: a per-alias terms-URL declaration is the fix and it is a protocol-repo
|
|
348
|
+
* change. Until it lands this type REPORTS the gap, because a reader that cannot tell two facts apart must say
|
|
349
|
+
* which one it cannot tell.
|
|
350
|
+
*/
|
|
351
|
+
export type AdvertisedTermsUrl =
|
|
352
|
+
/** Read from the field the manifest declares. */
|
|
353
|
+
| { readonly kind: "read"; readonly url: string }
|
|
354
|
+
/** The manifest declares no terms-URL field at all: the protocol has no room for one. */
|
|
355
|
+
| { readonly kind: "no-field-declared" }
|
|
356
|
+
/** The manifest declares one, its declaration reaches the answering carrier, and the document leaves it empty. */
|
|
357
|
+
| { readonly kind: "declared-field-empty"; readonly field: string }
|
|
358
|
+
/**
|
|
359
|
+
* The manifest's terms-URL path lies INSIDE the canonical carrier's own object, and the reference answered
|
|
360
|
+
* from a declared alias instead — so the declaration never reached the carrier this document used. Whether
|
|
361
|
+
* the document advertises a terms URL is UNKNOWN to this reader, not answered.
|
|
362
|
+
*/
|
|
363
|
+
| {
|
|
364
|
+
readonly kind: "undeclared-at-answering-carrier";
|
|
365
|
+
readonly field: string;
|
|
366
|
+
readonly answeredAt: string;
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
/** What a protocol document advertises about the terms governing it. */
|
|
370
|
+
export type AdvertisedTerms = {
|
|
371
|
+
/** The protocol whose manifest the carriers were read through. */
|
|
372
|
+
readonly protocol: ProtocolId;
|
|
373
|
+
/** The advertised ATR hash, reconciled across every integrity-bearing carrier the manifest declares. */
|
|
374
|
+
readonly advertisedAtrHash: `0x${string}`;
|
|
375
|
+
/** What the document says about where its terms live — read, absent, or not answerable from here. */
|
|
376
|
+
readonly legalContextUrl: AdvertisedTermsUrl;
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
/** One carrier slot a manifest declares, flattened so the canonical field and its aliases read alike. */
|
|
380
|
+
type CarrierSlot = {
|
|
381
|
+
readonly path: string;
|
|
382
|
+
readonly container: PlacementContainer;
|
|
383
|
+
readonly encoding: PlacementEncoding;
|
|
384
|
+
readonly carrierClass: CarrierClass;
|
|
385
|
+
readonly carrierTypes: readonly LegalContextRef["type"][];
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* The canonical field plus every declared alias, each flattened into a one-field manifest of its own.
|
|
390
|
+
*
|
|
391
|
+
* A SLOT IS A MANIFEST, so the actual reading is `readDeclaredPaths` — the same function, once per declared
|
|
392
|
+
* path, rather than a second decoder that has to be kept honest by review. Only the INHERITANCE rules are
|
|
393
|
+
* restated (an alias takes the manifest's container and encoding unless it declares its own, and is
|
|
394
|
+
* `integrity` unless it says otherwise), because binding-core resolves those inside the first-hit-wins loop a
|
|
395
|
+
* buyer cannot use. Everything downstream of a resolved slot — the `bare-value` type coming from
|
|
396
|
+
* `carrierTypes[0]`, the wrapping into the §8.1 codec, the corrupt-value throw — stays where it was written.
|
|
397
|
+
*
|
|
398
|
+
* A declared `bareType` becomes the slot's WHOLE `carrierTypes`, which is the same thing said in the shape
|
|
399
|
+
* this call takes: a bare value carries no type tag, so its type is whatever its one-field contract fixes.
|
|
400
|
+
*/
|
|
401
|
+
function carrierSlots(manifest: PlacementManifest): readonly CarrierSlot[] {
|
|
402
|
+
const canonical: CarrierSlot = {
|
|
403
|
+
path: manifest.field,
|
|
404
|
+
container: manifest.container,
|
|
405
|
+
encoding: manifest.encoding,
|
|
406
|
+
carrierClass: "integrity",
|
|
407
|
+
carrierTypes: manifest.carrierTypes,
|
|
408
|
+
};
|
|
409
|
+
return [
|
|
410
|
+
canonical,
|
|
411
|
+
...(manifest.readAlso ?? []).map(
|
|
412
|
+
(alias): CarrierSlot => ({
|
|
413
|
+
path: alias.path,
|
|
414
|
+
container: alias.container ?? manifest.container,
|
|
415
|
+
encoding: alias.encoding ?? manifest.encoding,
|
|
416
|
+
carrierClass: alias.carrierClass ?? "integrity",
|
|
417
|
+
carrierTypes:
|
|
418
|
+
alias.bareType === undefined
|
|
419
|
+
? manifest.carrierTypes
|
|
420
|
+
: [alias.bareType],
|
|
421
|
+
}),
|
|
422
|
+
),
|
|
423
|
+
];
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* The reference each declared INTEGRITY carrier actually holds, with the path it was read from.
|
|
428
|
+
*
|
|
429
|
+
* Discovery-class slots are skipped before they are read, not after: UCP's `links[type=terms_of_service].url`
|
|
430
|
+
* locates the terms without attesting to them — LCP v1.38 §C.3 files UCP's `links` under "discovery without
|
|
431
|
+
* integrity" and says a standing policy page "is not a per-transaction terms record and carries no hash",
|
|
432
|
+
* which is exactly why one cannot stand in for the other. Reading
|
|
433
|
+
* it and discarding it later would also mean decoding a URL under the capability field's `sha256` contract,
|
|
434
|
+
* which is a corrupt carrier and throws.
|
|
435
|
+
*/
|
|
436
|
+
function integrityHits(
|
|
437
|
+
doc: unknown,
|
|
438
|
+
manifest: PlacementManifest,
|
|
439
|
+
): readonly { readonly path: string; readonly ref: LegalContextRef }[] {
|
|
440
|
+
const hits: { path: string; ref: LegalContextRef }[] = [];
|
|
441
|
+
for (const slot of carrierSlots(manifest)) {
|
|
442
|
+
if (slot.carrierClass !== "integrity") continue;
|
|
443
|
+
const read = readDeclaredPaths(doc, {
|
|
444
|
+
field: slot.path,
|
|
445
|
+
container: slot.container,
|
|
446
|
+
encoding: slot.encoding,
|
|
447
|
+
carrierTypes: slot.carrierTypes,
|
|
448
|
+
});
|
|
449
|
+
if (read === undefined) continue;
|
|
450
|
+
const ref = decodeDeclaredRead(read);
|
|
451
|
+
if (ref === undefined) continue;
|
|
452
|
+
hits.push({ path: slot.path, ref });
|
|
453
|
+
}
|
|
454
|
+
return hits;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Two references are the same reference iff type and value agree — hex compared case-insensitively. */
|
|
458
|
+
function carrierKey(ref: LegalContextRef): string {
|
|
459
|
+
return ref.type === "sha256"
|
|
460
|
+
? `sha256:${ref.value.toLowerCase()}`
|
|
461
|
+
: `${ref.type}:${ref.value}`;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Read what ANY supported protocol document advertises: its ATR hash, and its terms URL where the protocol
|
|
466
|
+
* has room for one.
|
|
467
|
+
*
|
|
468
|
+
* UNIVERSAL BY CONSTRUCTION. The carriers are not listed here — they are read out of the protocol's own
|
|
469
|
+
* `PlacementManifest` through `@integraledger/lcp-placements`, so a protocol this function supports is precisely
|
|
470
|
+
* one the build can also place a reference INTO, and adding a protocol product-side is nothing at all. There
|
|
471
|
+
* is no second place protocols are listed and no path by which a reader and a writer can drift.
|
|
472
|
+
*
|
|
473
|
+
* THE PROTOCOL IS AN ARGUMENT, NOT A DETECTION. Detection is the only part of this seam that can be wrong, so
|
|
474
|
+
* a caller that knows its protocol — which is the ordinary case, since a buyer agent knows which counterparty
|
|
475
|
+
* it dialled — never pays for it. {@link detectProtocol} is available for the caller that genuinely does not.
|
|
476
|
+
*
|
|
477
|
+
* FAIL-FAST, four ways, all loud:
|
|
478
|
+
*
|
|
479
|
+
* - a protocol with no registered placement (`mcp` is the only one, and LCP v1.38 §C.9 makes that terminal:
|
|
480
|
+
* it describes an LCP-aware MCP *server*, which has no document field for a reference to ride in);
|
|
481
|
+
* - a document advertising nothing at any declared carrier;
|
|
482
|
+
* - DISAGREEMENT between two declared carriers. This is the generalization of the x402 two-carrier rule to
|
|
483
|
+
* every protocol in the set, and it is deliberately stricter than the placement adapter's own `extract`,
|
|
484
|
+
* which answers with the canonical field and says nothing (`readDeclaredPaths` returns the first hit, not
|
|
485
|
+
* the set). A placement is structural and does not adjudicate a host's document; a BUYER must, because two
|
|
486
|
+
* different values on one document would let a seller advertise different terms to different readers of it
|
|
487
|
+
* and then disown whichever one it lost by. Preference is not an answer here — refusal is;
|
|
488
|
+
* - a reference that is not a `sha256` carrier. `carrierTypes` permits `url` on several manifests and that is
|
|
489
|
+
* correct for a placement, but the gate compares the advertised value against a RECOMPUTED record hash, and
|
|
490
|
+
* nothing but a hash can be compared to a hash.
|
|
491
|
+
*
|
|
492
|
+
* `deployment` is required only for a protocol whose placement is namespaced — Mastercard VI, whose constraint
|
|
493
|
+
* type is minted under the deployment's own reverse-domain namespace and has no default. Omitting it there
|
|
494
|
+
* throws from the registry rather than answering about some invented namespace.
|
|
495
|
+
*/
|
|
496
|
+
export function readAdvertisedTerms(
|
|
497
|
+
protocol: ProtocolId,
|
|
498
|
+
wire: unknown,
|
|
499
|
+
deployment?: PlacementDeployment,
|
|
500
|
+
): AdvertisedTerms {
|
|
501
|
+
const placement = placementFor(protocol, deployment);
|
|
502
|
+
if (placement === undefined)
|
|
503
|
+
throw new Error(
|
|
504
|
+
`no placement is registered for ${protocol} — this build cannot read a reference out of its documents`,
|
|
505
|
+
);
|
|
506
|
+
const manifest = placement.manifest;
|
|
507
|
+
|
|
508
|
+
const hits = integrityHits(wire, manifest);
|
|
509
|
+
const sole = hits[0];
|
|
510
|
+
if (sole === undefined) {
|
|
511
|
+
const looked = carrierSlots(manifest)
|
|
512
|
+
.filter((slot) => slot.carrierClass === "integrity")
|
|
513
|
+
.map((slot) => slot.path)
|
|
514
|
+
.join(", ");
|
|
515
|
+
throw new Error(
|
|
516
|
+
`${protocol} document advertises no LCP reference at any declared integrity carrier (${looked})`,
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const distinct = new Set(hits.map((hit) => carrierKey(hit.ref)));
|
|
521
|
+
if (distinct.size > 1)
|
|
522
|
+
throw new Error(
|
|
523
|
+
`${protocol} carriers disagree — ${hits
|
|
524
|
+
.map(
|
|
525
|
+
(hit) =>
|
|
526
|
+
`${hit.path} advertises lcp:${hit.ref.type}:${hit.ref.value}`,
|
|
527
|
+
)
|
|
528
|
+
.join(", ")}`,
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
const ref = sole.ref;
|
|
532
|
+
if (ref.type !== "sha256")
|
|
533
|
+
throw new Error(
|
|
534
|
+
`${protocol} advertises an lcp:${ref.type}: reference; the gate compares the advertised value against a recomputed record hash, which only a sha256 carrier can be`,
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
return {
|
|
538
|
+
protocol,
|
|
539
|
+
advertisedAtrHash: ref.value as `0x${string}`,
|
|
540
|
+
legalContextUrl: readTermsUrl(protocol, wire, manifest, sole.path),
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* The terms URL at the field the manifest DECLARES — or which of the three absences this is.
|
|
546
|
+
*
|
|
547
|
+
* `termsUrlField` is a plain dotted path on the manifest: it declares no container of its own, so it is read
|
|
548
|
+
* with `readAtPath` rather than through the reference carrier's container. A manifest that omits it (A2A, ACK,
|
|
549
|
+
* Visa TAP, Mastercard VI) is stating that its protocol has no room for one — a fact, not a gap.
|
|
550
|
+
*
|
|
551
|
+
* NOTHING AT THE DECLARED PATH IS TWO DIFFERENT FACTS, and which one it is turns on where the reference
|
|
552
|
+
* answered from. `termsUrlField` is declared once, beside the canonical `field`; a `readAlso` alias carries no
|
|
553
|
+
* terms-URL declaration of its own. So when the declared path is nested INSIDE the canonical field — x402's
|
|
554
|
+
* `extensions.legalContext.info.legalContextUrl` inside `extensions.legalContext.info` — and the reference
|
|
555
|
+
* answered from an alias instead, the manifest's declaration was scoped to a carrier this document did not
|
|
556
|
+
* use, and its emptiness says nothing about the document. When the declared path is a SIBLING of the canonical
|
|
557
|
+
* field (ACP's `metadata.legal_context_url`, MPP's `methodDetails.legalContextUrl`), it is reachable whichever
|
|
558
|
+
* carrier answered, and empty means empty.
|
|
559
|
+
*
|
|
560
|
+
* `answeredAt` is the path the reconciled reference was read from. `carrierSlots` puts the canonical field
|
|
561
|
+
* first and `integrityHits` preserves that order, so it differs from `manifest.field` exactly when the
|
|
562
|
+
* canonical carrier did not answer.
|
|
563
|
+
*/
|
|
564
|
+
function readTermsUrl(
|
|
565
|
+
protocol: ProtocolId,
|
|
566
|
+
doc: unknown,
|
|
567
|
+
manifest: PlacementManifest,
|
|
568
|
+
answeredAt: string,
|
|
569
|
+
): AdvertisedTermsUrl {
|
|
570
|
+
const field = manifest.termsUrlField;
|
|
571
|
+
if (field === undefined) return { kind: "no-field-declared" };
|
|
572
|
+
const raw = readAtPath(doc, field);
|
|
573
|
+
if (raw === undefined)
|
|
574
|
+
return answeredAt !== manifest.field &&
|
|
575
|
+
field.startsWith(`${manifest.field}.`)
|
|
576
|
+
? { kind: "undeclared-at-answering-carrier", field, answeredAt }
|
|
577
|
+
: { kind: "declared-field-empty", field };
|
|
578
|
+
if (typeof raw !== "string")
|
|
579
|
+
throw new Error(
|
|
580
|
+
`${protocol} ${field} is not a string: ${JSON.stringify(raw)}`,
|
|
581
|
+
);
|
|
582
|
+
if (!raw.startsWith("https://"))
|
|
583
|
+
throw new Error(`legalContextUrl must be HTTPS: ${raw}`);
|
|
584
|
+
return { kind: "read", url: raw };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
588
|
+
// The universal gate parse
|
|
589
|
+
// ---------------------------------------------------------------------------------------------------------
|
|
590
|
+
|
|
591
|
+
/** A parser from one protocol's wire document to the one `GateProposal` every parser produces. */
|
|
592
|
+
export type ProposalParser = (
|
|
593
|
+
wire: unknown,
|
|
594
|
+
ctx: ProposalContext,
|
|
595
|
+
) => GateProposal;
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Every protocol this build can turn into a complete `GateProposal`, keyed by `ProtocolId`.
|
|
599
|
+
*
|
|
600
|
+
* SMALLER THAN THE PLACEMENT REGISTRY, and the difference is a fact about the protocols rather than a gap in
|
|
601
|
+
* this package. A `GateProposal` carries an OFFER — an amount and a unit — and an offer is protocol-native
|
|
602
|
+
* economics that no `PlacementManifest` declares and LCP does not standardize: x402 quotes it in
|
|
603
|
+
* `accepts[].amount` with a `network:asset` unit, ACP in the row of `totals` typed `total` with an ISO 4217
|
|
604
|
+
* currency, and the remaining seven each differently again. Inventing an offer-locator axis product-side would
|
|
605
|
+
* put protocol knowledge in a second place and put it there UNGATED, which is the one thing the placement seam
|
|
606
|
+
* was built to stop. {@link readAdvertisedTerms} is universal because the reference is; this is not, because
|
|
607
|
+
* the offer is not.
|
|
608
|
+
*
|
|
609
|
+
* ONE PROTOCOL COULD NOT BE DISPATCHED HERE EVEN IF ITS OFFER WERE READABLE. `ap2`'s discriminant is a strict
|
|
610
|
+
* subset of `a2a`'s, so an AP2 envelope always matches two rows and {@link parseProposalUniversal} refuses it
|
|
611
|
+
* as ambiguous before any lookup in this map. An `ap2` entry added here would be unreachable through the
|
|
612
|
+
* universal door; the parser a future unit writes must be exported and called by name.
|
|
613
|
+
*
|
|
614
|
+
* `Object.freeze` for the same reason `PLACEMENTS` is frozen: a published package is consumed as JavaScript,
|
|
615
|
+
* where the type alone does not stop a consumer swapping the parser that decides what a buyer is agreeing to.
|
|
616
|
+
*/
|
|
617
|
+
export const PROPOSAL_PARSERS: Readonly<
|
|
618
|
+
Partial<Record<ProtocolId, ProposalParser>>
|
|
619
|
+
> = Object.freeze({
|
|
620
|
+
x402: parseProposalFromChallenge,
|
|
621
|
+
acp: parseProposalFromAcpCheckout,
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
/** Every protocol this build can parse a complete proposal from, in registration order. */
|
|
625
|
+
export function parseableProtocols(): readonly ProtocolId[] {
|
|
626
|
+
return Object.keys(PROPOSAL_PARSERS) as ProtocolId[];
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Parse ANY supported wire into the one `GateProposal` every parser produces.
|
|
631
|
+
*
|
|
632
|
+
* The universal entry point: a buyer no longer has to know which protocol it is on to gate a
|
|
633
|
+
* transaction. Every named parser stays exported — a caller that DOES know its protocol should keep calling
|
|
634
|
+
* the specific one, because a known protocol needs no detection and detection is the only part that can be
|
|
635
|
+
* wrong.
|
|
636
|
+
*
|
|
637
|
+
* Fail-fast on four conditions, all loud: an unidentifiable wire, an AMBIGUOUS wire matching more than one
|
|
638
|
+
* protocol's discriminant, a protocol whose offer this build cannot read, and any refusal from the protocol
|
|
639
|
+
* parser it routes to. There is no "try them all and take the first that works" path — that is a fallback
|
|
640
|
+
* chain, and it would let a malformed document of one protocol be silently reinterpreted as a valid document
|
|
641
|
+
* of another.
|
|
642
|
+
*
|
|
643
|
+
* The named parsers are DELEGATED to rather than reimplemented over the placement manifests, and one of them
|
|
644
|
+
* proves why that is not merely convenient: x402's wire carries `legalContextUrl` in BOTH `accepts[].extra`
|
|
645
|
+
* and `extensions.legalContext.info` — LCP v1.38 §C.4's own illustration uses the first — while
|
|
646
|
+
* `PlacementManifest.termsUrlField` is singular and declares only the second. Routing x402 through the
|
|
647
|
+
* manifest would drop the terms URL from a spec-legal challenge. The reference reconcile generalizes; the
|
|
648
|
+
* terms-URL locator does not yet, so this entry point delegates and {@link readAdvertisedTerms} REPORTS the
|
|
649
|
+
* shortfall as `undeclared-at-answering-carrier` rather than answering an absence it cannot vouch for.
|
|
650
|
+
*/
|
|
651
|
+
export function parseProposalUniversal(
|
|
652
|
+
wire: unknown,
|
|
653
|
+
ctx: ProposalContext,
|
|
654
|
+
): GateProposal {
|
|
655
|
+
const matched = matchProtocols(wire);
|
|
656
|
+
if (matched.length > 1)
|
|
657
|
+
throw new Error(
|
|
658
|
+
`ambiguous wire — it matches the discriminants of ${matched.length} protocols (${matched.join(", ")}); a document that is legitimately two protocols' documents is not resolved by preference, so name the protocol and call its parser directly`,
|
|
659
|
+
);
|
|
660
|
+
const protocol = matched[0];
|
|
661
|
+
if (protocol === undefined)
|
|
662
|
+
throw new Error(
|
|
663
|
+
"could not identify which commerce protocol this document belongs to — no declared discriminant matched",
|
|
664
|
+
);
|
|
665
|
+
const parse = PROPOSAL_PARSERS[protocol];
|
|
666
|
+
if (parse === undefined)
|
|
667
|
+
throw new Error(
|
|
668
|
+
`no buyer proposal parser for ${protocol} — this build parses ${parseableProtocols().join(", ")}. ${protocol} quotes its offer in a shape no placement manifest declares; readAdvertisedTerms("${protocol}", …) reads its LCP reference today`,
|
|
669
|
+
);
|
|
670
|
+
return parse(wire, ctx);
|
|
671
|
+
}
|