@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,71 @@
|
|
|
1
|
+
import type { Assurance } from "@integraledger/lcp-authority";
|
|
2
|
+
import type {
|
|
3
|
+
SettlementRef,
|
|
4
|
+
VerifierPorts,
|
|
5
|
+
WeldAdapter,
|
|
6
|
+
} from "@integraledger/lcp-binding-core";
|
|
7
|
+
import {
|
|
8
|
+
type RecordIdentity,
|
|
9
|
+
type TransactionClass,
|
|
10
|
+
type VerificationReport,
|
|
11
|
+
verify,
|
|
12
|
+
} from "@integraledger/lcp-verify";
|
|
13
|
+
|
|
14
|
+
/** Inputs for the post-settlement mechanical verify. `verifierPorts` (chain + artifacts) is INJECTED, which is
|
|
15
|
+
* what keeps the gate viem-free. `atrBytes` is the fetched ATR; `coverage` matches verify's shape. */
|
|
16
|
+
export interface MechanicalPorts {
|
|
17
|
+
readonly verifierPorts: VerifierPorts;
|
|
18
|
+
readonly atrBytes: Uint8Array;
|
|
19
|
+
readonly asOf: string;
|
|
20
|
+
readonly coverage: { ports: string[]; bindings: string[] };
|
|
21
|
+
readonly claimedClass?: TransactionClass;
|
|
22
|
+
/**
|
|
23
|
+
* The parties as the buyer resolved them (IDN-1/3). TC-1 and up require the attribution rung, so a
|
|
24
|
+
* record verified without this reads honestly as unverified — the gate KNOWS both sides at this point
|
|
25
|
+
* (it holds the seller's stated assurance from the proposal and it is itself the payer), so it says so.
|
|
26
|
+
*/
|
|
27
|
+
readonly identity: {
|
|
28
|
+
readonly sellerAssurance: Assurance;
|
|
29
|
+
readonly payer: string;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Turn a settled receipt into a TC-2/TC-3 *transaction* record: recover the on-chain atrHash via the injected
|
|
34
|
+
* binding adapter, then run `verify({depth:"mechanical"})` — `verified` is raised honestly iff the ATR bytes
|
|
35
|
+
* hash to the recovered atrHash (and the class-required steps hold). A refused recovery leaves settledAtrHash
|
|
36
|
+
* absent → the fingerprint is indeterminate → verified stays false. */
|
|
37
|
+
export async function verifySettled(
|
|
38
|
+
ref: SettlementRef,
|
|
39
|
+
adapter: Pick<WeldAdapter, "recover">,
|
|
40
|
+
ports: MechanicalPorts,
|
|
41
|
+
): Promise<VerificationReport> {
|
|
42
|
+
const recovered = await adapter.recover(ref, ports.verifierPorts);
|
|
43
|
+
// The buyer resolves the seller at the assurance the proposal stated, and itself at the level the rail
|
|
44
|
+
// actually supports: a wallet signature. Stating the low level honestly is conformant (IDN-3); claiming
|
|
45
|
+
// a level the rail cannot support would not be.
|
|
46
|
+
const identity: RecordIdentity = {
|
|
47
|
+
seller: {
|
|
48
|
+
subject: "seller",
|
|
49
|
+
assurance: ports.identity.sellerAssurance,
|
|
50
|
+
chain: [{ via: "domain-control" }],
|
|
51
|
+
},
|
|
52
|
+
buyer: {
|
|
53
|
+
subject: ports.identity.payer,
|
|
54
|
+
assurance: "wallet-signature-only",
|
|
55
|
+
chain: [{ via: "key" }],
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
const base = {
|
|
59
|
+
depth: "mechanical" as const,
|
|
60
|
+
atrBytes: ports.atrBytes,
|
|
61
|
+
coverage: ports.coverage,
|
|
62
|
+
claimedClass: ports.claimedClass ?? ("TC-2" as TransactionClass),
|
|
63
|
+
asOf: ports.asOf,
|
|
64
|
+
// The settlement the buyer just made IS the enumeration it can honestly attest to (PAY).
|
|
65
|
+
settlements: [ref],
|
|
66
|
+
identity,
|
|
67
|
+
};
|
|
68
|
+
return verify(
|
|
69
|
+
"ok" in recovered ? { ...base, settledAtrHash: recovered.value } : base,
|
|
70
|
+
);
|
|
71
|
+
}
|
package/src/policy.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { Assurance } from "@integraledger/lcp-authority";
|
|
2
|
+
import type { LegalContextJson } from "@integraledger/lcp-discovery";
|
|
3
|
+
import type { Disposition } from "./decision.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The buyer's stated policy.
|
|
7
|
+
*
|
|
8
|
+
* **`onNotAttempted` is the only gap disposition here, and its absent sibling is the point.** An earlier
|
|
9
|
+
* shape also required `onIndeterminate`, which `evaluate` never read — and could not have, because no step
|
|
10
|
+
* it runs yields `indeterminate`: the fingerprint proves or fails, and the record parses or does not. A
|
|
11
|
+
* required field promising a disposition the gate cannot reach is worse than no field, because a buyer
|
|
12
|
+
* reads it as a control. Consumers folding their own step ladders use `decideOutcome`, which carries its
|
|
13
|
+
* own four-valued gaps shape and does handle `indeterminate`.
|
|
14
|
+
*/
|
|
15
|
+
export interface BuyerPolicy {
|
|
16
|
+
readonly requiredLevel: 1 | 2 | 3 | 4;
|
|
17
|
+
readonly acceptableJurisdictions: readonly string[] | "any";
|
|
18
|
+
readonly acceptableDisputeMethods: readonly string[] | "any";
|
|
19
|
+
readonly maxCommitment: Readonly<Record<string, string>>;
|
|
20
|
+
readonly forbiddenClauseCategories: readonly string[];
|
|
21
|
+
readonly requiredAssurance: Assurance | "any";
|
|
22
|
+
readonly onNotAttempted: Disposition;
|
|
23
|
+
}
|
|
24
|
+
export type PolicyResult =
|
|
25
|
+
| { readonly ok: true }
|
|
26
|
+
| { readonly ok: false; readonly code: string; readonly detail: string };
|
|
27
|
+
|
|
28
|
+
const accepts = (
|
|
29
|
+
allow: readonly string[] | "any",
|
|
30
|
+
v: string | undefined,
|
|
31
|
+
): boolean => (allow === "any" ? true : v !== undefined && allow.includes(v));
|
|
32
|
+
|
|
33
|
+
/** Evaluate the buyer policy on the TYPED legal-context envelope + the typed offer — NEVER on prose (LCP §12.7).
|
|
34
|
+
* Every check reads a structured field; the function's inputs cannot carry the natural-language body. */
|
|
35
|
+
export function evaluatePolicy(
|
|
36
|
+
policy: BuyerPolicy,
|
|
37
|
+
terms: LegalContextJson,
|
|
38
|
+
offer: { amount: string; unit: string },
|
|
39
|
+
): PolicyResult {
|
|
40
|
+
// NARROWED, not cast — the same treatment `clauseCategories` gets below, and for the same reason. The
|
|
41
|
+
// cast that stood here asserted `{ jurisdiction?: string; method?: string }` about a value the discovery
|
|
42
|
+
// parser never checked, which made the two fields policy actually reads the two the type system was not
|
|
43
|
+
// checking. It failed closed, so this was never a bypass: a seller publishing `"disputeResolution":
|
|
44
|
+
// "arbitration"` yielded `undefined` for both members and declined. It declined for the WRONG REASON —
|
|
45
|
+
// "jurisdiction (none) not acceptable" tells a seller its jurisdiction is unacceptable when its actual
|
|
46
|
+
// defect is that the field is a string. A gate whose refusals misdescribe the defect cannot be acted on,
|
|
47
|
+
// and being right by accident is not the same as being right.
|
|
48
|
+
const rawDr = (terms as { disputeResolution?: unknown }).disputeResolution;
|
|
49
|
+
if (
|
|
50
|
+
rawDr !== undefined &&
|
|
51
|
+
(typeof rawDr !== "object" || rawDr === null || Array.isArray(rawDr))
|
|
52
|
+
)
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
code: "policy/malformed-dispute-resolution",
|
|
56
|
+
detail: `seller's disputeResolution is ${Array.isArray(rawDr) ? "an array" : rawDr === null ? "null" : typeof rawDr}, not an object`,
|
|
57
|
+
};
|
|
58
|
+
const drRecord = (rawDr ?? {}) as Readonly<Record<string, unknown>>;
|
|
59
|
+
// A member that is present and not a string is the seller's defect too, named rather than silently read
|
|
60
|
+
// as absent. Absent stays absent: that is the ordinary "no jurisdiction declared" path, and it is the
|
|
61
|
+
// buyer's stated policy that decides whether absence is acceptable.
|
|
62
|
+
for (const member of ["jurisdiction", "method"] as const) {
|
|
63
|
+
const v = drRecord[member];
|
|
64
|
+
if (v !== undefined && typeof v !== "string")
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
code: "policy/malformed-dispute-resolution",
|
|
68
|
+
detail: `seller's disputeResolution.${member} is ${typeof v}, not a string`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
// NOT optionally chained, and the narrowing above is why. The cast that stood here produced a possibly
|
|
72
|
+
// absent object, so every read was `dr?.member`; the loop has now proved both members are a string or
|
|
73
|
+
// absent, so the object is always there and `?.` could never short-circuit. Mutation testing is what
|
|
74
|
+
// surfaced it — four optional-chaining mutants survived because no input could reach the branch they
|
|
75
|
+
// removed, which is the signature of a branch that cannot happen.
|
|
76
|
+
const jurisdiction = drRecord["jurisdiction"] as string | undefined;
|
|
77
|
+
const method = drRecord["method"] as string | undefined;
|
|
78
|
+
if (!accepts(policy.acceptableJurisdictions, jurisdiction))
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
code: "policy/jurisdiction",
|
|
82
|
+
detail: `jurisdiction ${jurisdiction ?? "(none)"} not acceptable`,
|
|
83
|
+
};
|
|
84
|
+
if (!accepts(policy.acceptableDisputeMethods, method))
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
code: "policy/dispute-method",
|
|
88
|
+
detail: `dispute method ${method ?? "(none)"} not acceptable`,
|
|
89
|
+
};
|
|
90
|
+
// `Object.hasOwn`, never a bare index read. `offer.unit` is SELLER-DERIVED — on the ACP path it is the
|
|
91
|
+
// session's `currency`, an unconstrained string — so a bare read answers from `Object.prototype` for
|
|
92
|
+
// `constructor`, `toString`, `valueOf`, `hasOwnProperty` or `__proto__`. Each returns a non-undefined
|
|
93
|
+
// non-string, slips past the `undefined` check below, and reaches `BigInt()`, which throws a SyntaxError
|
|
94
|
+
// out of `evaluate` naming nothing a buyer can act on. An own-property read makes every one of them the
|
|
95
|
+
// ordinary "no cap declared" decline.
|
|
96
|
+
const cap = Object.hasOwn(policy.maxCommitment, offer.unit)
|
|
97
|
+
? policy.maxCommitment[offer.unit]
|
|
98
|
+
: undefined;
|
|
99
|
+
if (typeof cap !== "string")
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
code: "policy/unit",
|
|
103
|
+
detail: `no cap declared for unit ${offer.unit}`,
|
|
104
|
+
};
|
|
105
|
+
if (BigInt(offer.amount) > BigInt(cap))
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
code: "policy/over-cap",
|
|
109
|
+
detail: `offer ${offer.amount} exceeds cap ${cap} (${offer.unit})`,
|
|
110
|
+
};
|
|
111
|
+
// NARROWED, not cast. `clauseCategories` is not in the discovery schema at all — it arrives through the
|
|
112
|
+
// record's loose index signature as `unknown`, so it is whatever the SELLER wrote. A cast asserting
|
|
113
|
+
// `readonly string[]` was a claim about a hostile value: a seller publishing `"clauseCategories":
|
|
114
|
+
// "arbitration"` made `cats.find` a TypeError that escaped `evaluate` entirely, past its contract to
|
|
115
|
+
// RETURN a GateDecision. A malformed field is now the seller's defect, named as such and declined.
|
|
116
|
+
const raw = (terms as { clauseCategories?: unknown }).clauseCategories;
|
|
117
|
+
if (raw !== undefined && !Array.isArray(raw))
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
code: "policy/malformed-clause-categories",
|
|
121
|
+
detail: `seller's clauseCategories is ${typeof raw}, not an array of category strings`,
|
|
122
|
+
};
|
|
123
|
+
const cats: readonly unknown[] = raw ?? [];
|
|
124
|
+
const forbidden = cats.find(
|
|
125
|
+
(c): c is string =>
|
|
126
|
+
typeof c === "string" && policy.forbiddenClauseCategories.includes(c),
|
|
127
|
+
);
|
|
128
|
+
if (forbidden !== undefined)
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
code: "policy/forbidden-clause",
|
|
132
|
+
detail: `forbidden clause category: ${forbidden}`,
|
|
133
|
+
};
|
|
134
|
+
return { ok: true };
|
|
135
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ACK buyer surface: read an ACK-Pay `PaymentReceiptCredential` and say which rung of the record each
|
|
3
|
+
* half of it feeds.
|
|
4
|
+
*
|
|
5
|
+
* **There is deliberately no `GateProposal` here, and that is a finding about ACK rather than a gap.** Every
|
|
6
|
+
* other parser in this package turns a PROPOSAL into the typed inputs a buyer decides on. ACK has no
|
|
7
|
+
* proposal-time carrier to turn: read live at `agentcommercekit/ack@main` on 2026-07-30,
|
|
8
|
+
* `packages/ack-pay/src/schemas/valibot.ts` declares `paymentRequestSchema` as
|
|
9
|
+
* `{ id, description?, serviceCallback?, expiresAt?, paymentOptions }` — no `metadata`, no open field, no
|
|
10
|
+
* extension point anywhere on the request. The open map (`v.optional(v.record(v.string(), v.unknown()))`)
|
|
11
|
+
* exists on `paymentReceiptClaimSchema` ALONE. So an LCP reference reaches an ACK document only at receipt
|
|
12
|
+
* time, and manufacturing a `GateProposal` from a receipt would name a decision the buyer no longer has:
|
|
13
|
+
* the payment has already settled. What the receipt genuinely supports is post-settlement verification, and
|
|
14
|
+
* that is what this module produces.
|
|
15
|
+
*
|
|
16
|
+
* An ACK buyer that wants a decision BEFORE paying gets it the same way any Level 1–2 buyer does: from the
|
|
17
|
+
* seller's `/.well-known/legal-context.json` (LCP §2), which is out of band from ACK entirely.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readAtPath } from "@integraledger/lcp-binding-core";
|
|
21
|
+
import type { Artifact } from "@integraledger/lcp-evidence";
|
|
22
|
+
import { ACK_PLACEMENT, ackPlacement } from "@integraledger/lcp-placement-ack";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The DID methods ACK's own resolver registers — `packages/did/src/did-resolvers/get-did-resolver.ts`
|
|
26
|
+
* composes exactly four (`key-did-resolver`, `./web-did-resolver`, `jwks-did-resolver`,
|
|
27
|
+
* `./pkh-did-resolver`), read live 2026-07-30. A receipt naming any other method is one ACK's own chain
|
|
28
|
+
* could not have verified, so it is refused here rather than carried into a record as an identity nobody
|
|
29
|
+
* can resolve. `did:jwks` was still pending when earlier surveys of ACK were written and has since landed,
|
|
30
|
+
* which is why this list is cut from ACK's own resolver code rather than from any survey of it.
|
|
31
|
+
*/
|
|
32
|
+
export const ACK_DID_METHODS: readonly string[] = ["key", "web", "jwks", "pkh"];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The leaf key names the LCP placement owns — DERIVED from the manifest so the two cannot drift.
|
|
36
|
+
*
|
|
37
|
+
* `readAlso` is OPTIONAL on `PlacementManifest` and manifests legitimately omit it, so `[]` is the CORRECT
|
|
38
|
+
* value for "this placement owns no alias keys" — under it `siblingRefKeys` rightly reports the un-owned key
|
|
39
|
+
* as one of ACK's own. Total handling of an optional field, not a fallback path: there is nothing here to
|
|
40
|
+
* fail loudly about. `ACK_PLACEMENT` declares an alias today, which is why the arm is unreached.
|
|
41
|
+
*/
|
|
42
|
+
function lcpKeys(): string[] {
|
|
43
|
+
const leaf = (path: string): string => path.slice(path.lastIndexOf(".") + 1);
|
|
44
|
+
return [
|
|
45
|
+
leaf(ACK_PLACEMENT.field),
|
|
46
|
+
...(ACK_PLACEMENT.readAlso ?? []).map((alias) => leaf(alias.path)),
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function requireString(value: unknown, what: string): string {
|
|
51
|
+
if (typeof value !== "string" || value === "")
|
|
52
|
+
throw new Error(`ACK receipt: ${what} is absent or not a non-empty string`);
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** What an issued ACK receipt yields, before anything is decided about it. */
|
|
57
|
+
export interface AckReceiptFacts {
|
|
58
|
+
/** The reference recovered from `credentialSubject.metadata`, through the placement's own manifest. */
|
|
59
|
+
readonly atrHash: `0x${string}`;
|
|
60
|
+
/** ACK's join key: the signed payment request this receipt attests, and no other. */
|
|
61
|
+
readonly paymentRequestToken: string;
|
|
62
|
+
/** Which of the request's `paymentOptions` was taken. */
|
|
63
|
+
readonly paymentOptionId: string;
|
|
64
|
+
/** `credentialSubject.id` — the payer DID the receipt attributes the payment to. */
|
|
65
|
+
readonly payerDid: string;
|
|
66
|
+
/** The payer DID's method, narrowed to `ACK_DID_METHODS`. */
|
|
67
|
+
readonly payerDidMethod: string;
|
|
68
|
+
/** `issuer.id` — the receipt service that signed. Recorded; never mistaken for the payer. */
|
|
69
|
+
readonly issuerDid: string;
|
|
70
|
+
/**
|
|
71
|
+
* The other keys ACK's own audit trail put in the same map, in wire order — `policyRef`, `mandateRef`,
|
|
72
|
+
* `executionRef`, `executionReceiptHash`, `settlementNetwork`, `settlementReference` and whatever else a
|
|
73
|
+
* deployment added. Reported because a record composer wants to know what else this receipt
|
|
74
|
+
* cross-references, and because naming them is the honest form of "we are a guest in this map": we read
|
|
75
|
+
* exactly one key and own none of the rest.
|
|
76
|
+
*/
|
|
77
|
+
readonly siblingRefKeys: readonly string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Parse an ISSUED `PaymentReceiptCredential` into its facts. Fail-fast on every departure from what ACK's
|
|
82
|
+
* own code emits — a receipt this cannot read is one ACK's verification chain could not have produced.
|
|
83
|
+
*
|
|
84
|
+
* **The unissued receipt is refused, and it is the mirror of the seller's rule.**
|
|
85
|
+
* `placement-ack.place` refuses a credential that ALREADY carries a `proof`, because a field added after
|
|
86
|
+
* issuance either breaks the embedded signature or falls outside the JWT payload ACK treats as
|
|
87
|
+
* authoritative. This refuses one that does NOT YET carry one, because a credential nobody signed attests
|
|
88
|
+
* nothing and must not reach a record as evidence of a payment. Between the two rules the reference can
|
|
89
|
+
* only ever ride a receipt the issuer signed over. The predicate is ACK's own — `isDecodedCredential`
|
|
90
|
+
* accepts a value iff `"proof" in value && value.proof != null` — mirrored, so a `null` proof is unissued
|
|
91
|
+
* by the host's rule and is unissued here.
|
|
92
|
+
*/
|
|
93
|
+
export function parseAckReceipt(credential: unknown): AckReceiptFacts {
|
|
94
|
+
const proof = readAtPath(credential, "proof");
|
|
95
|
+
if (proof === undefined || proof === null)
|
|
96
|
+
throw new Error(
|
|
97
|
+
"ack/receipt-not-issued: this credential carries no proof — a receipt nobody signed attests nothing, " +
|
|
98
|
+
"and the reference it carries is outside anything ACK's verification chain would return",
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const issuerDid = requireString(
|
|
102
|
+
readAtPath(credential, "issuer.id"),
|
|
103
|
+
"issuer.id (ACK's own createCredential emits `issuer: { id: issuer }`, never a bare string)",
|
|
104
|
+
);
|
|
105
|
+
const payerDid = requireString(
|
|
106
|
+
readAtPath(credential, "credentialSubject.id"),
|
|
107
|
+
"credentialSubject.id",
|
|
108
|
+
);
|
|
109
|
+
const [scheme, method] = payerDid.split(":");
|
|
110
|
+
if (
|
|
111
|
+
scheme !== "did" ||
|
|
112
|
+
method === undefined ||
|
|
113
|
+
!ACK_DID_METHODS.includes(method)
|
|
114
|
+
)
|
|
115
|
+
throw new Error(
|
|
116
|
+
`ACK receipt: "${payerDid}" is not a did uri in a did method ACK resolves (${ACK_DID_METHODS.join(", ")})`,
|
|
117
|
+
);
|
|
118
|
+
const paymentRequestToken = requireString(
|
|
119
|
+
readAtPath(credential, "credentialSubject.paymentRequestToken"),
|
|
120
|
+
"credentialSubject.paymentRequestToken",
|
|
121
|
+
);
|
|
122
|
+
const paymentOptionId = requireString(
|
|
123
|
+
readAtPath(credential, "credentialSubject.paymentOptionId"),
|
|
124
|
+
"credentialSubject.paymentOptionId",
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const extracted = ackPlacement.extract(credential);
|
|
128
|
+
if (!("ok" in extracted))
|
|
129
|
+
throw new Error(
|
|
130
|
+
`ACK receipt carries no readable LCP reference (${extracted.haltClass}/${extracted.code})`,
|
|
131
|
+
);
|
|
132
|
+
if (extracted.value.type !== "sha256")
|
|
133
|
+
throw new Error(
|
|
134
|
+
`ACK legalContext must be a sha256 carrier, got "${extracted.value.type}" — a locator commits to nothing`,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const owned = lcpKeys();
|
|
138
|
+
// `extract` above read the LCP key out of THIS map, so the map is an object and the narrowing cannot
|
|
139
|
+
// fail. Asserted rather than branched: a defensive `typeof` ternary here would ship an else-arm no input
|
|
140
|
+
// can reach, and its silent `[]` would under-report ACK's own refs on a receipt that has them.
|
|
141
|
+
const metadata = readAtPath(
|
|
142
|
+
credential,
|
|
143
|
+
"credentialSubject.metadata",
|
|
144
|
+
) as Record<string, unknown>;
|
|
145
|
+
const siblingRefKeys = Object.keys(metadata).filter(
|
|
146
|
+
(k) => !owned.includes(k),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
atrHash: extracted.value.value as `0x${string}`,
|
|
151
|
+
paymentRequestToken,
|
|
152
|
+
paymentOptionId,
|
|
153
|
+
payerDid,
|
|
154
|
+
payerDidMethod: method,
|
|
155
|
+
issuerDid,
|
|
156
|
+
siblingRefKeys,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The receipt's contribution to a post-settlement `verify` call, with each field naming the rung it feeds.
|
|
162
|
+
*
|
|
163
|
+
* The first of ACK's two hard edges, answered in code rather than in prose: the `PaymentReceiptCredential` is
|
|
164
|
+
* **both** an evidence artifact and an identity input, and the split is here.
|
|
165
|
+
*/
|
|
166
|
+
export interface AckReceiptContribution {
|
|
167
|
+
/** Feeds `VerifyInput.placement` — the reference recovered from the host protocol's OWN document. */
|
|
168
|
+
readonly placement: { readonly extracted: unknown };
|
|
169
|
+
/**
|
|
170
|
+
* RCS-4 → one `evidence.Artifact` under the `settlement` role.
|
|
171
|
+
*
|
|
172
|
+
* `settlement` rather than `attestation`: an ACK receipt IS ACK's settlement artifact — the credential
|
|
173
|
+
* that attests the payment happened, carrying the `paymentRequestToken` it settled and, by convention,
|
|
174
|
+
* ACK's own `settlementNetwork`/`settlementReference`. `settlement` is one of `RCS4_REQUIRED_ROLES`, so
|
|
175
|
+
* filing it correctly is what lets an ACK record's evidence package be complete; filing it under
|
|
176
|
+
* `attestation` would leave the settlement role empty while double-filling one that is already occupied
|
|
177
|
+
* by the identity attestation.
|
|
178
|
+
*/
|
|
179
|
+
readonly artifact: Artifact;
|
|
180
|
+
/** IDN-1 → the payer `resolve-party` attributes the payment to. */
|
|
181
|
+
readonly payerDid: string;
|
|
182
|
+
/**
|
|
183
|
+
* The payer DID's method — and NOT an assurance level.
|
|
184
|
+
*
|
|
185
|
+
* ACK's second hard edge: `did:web` / `did:jwks` resolution reuses the existing identity path rather than
|
|
186
|
+
* minting a second one, and that path authors identity SELLER-side — the record's IDN content is
|
|
187
|
+
* seller-authored. A buyer verifying a record reads the assurance the record already states; restating it
|
|
188
|
+
* here would be a second authority on the same fact. The mapping from ACK's four DID methods to an
|
|
189
|
+
* `authority.Assurance` therefore lives once, on the seller side, and this surface stops at the method.
|
|
190
|
+
*/
|
|
191
|
+
readonly payerDidMethod: string;
|
|
192
|
+
/** The receipt service that signed. Recorded on the record; never the payer. */
|
|
193
|
+
readonly issuerDid: string;
|
|
194
|
+
/** ACK's join key — the guard against reading a receipt against a payment request it never attested. */
|
|
195
|
+
readonly paymentRequestToken: string;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Split an issued receipt into its two rungs. `receiptBytes` are the retained credential exactly as it was
|
|
200
|
+
* received — the artifact is the bytes, not a re-serialization of the parse, because what the evidence
|
|
201
|
+
* package must hold is what the issuer signed.
|
|
202
|
+
*/
|
|
203
|
+
export function ackReceiptContribution(
|
|
204
|
+
credential: unknown,
|
|
205
|
+
receiptBytes: Uint8Array,
|
|
206
|
+
): AckReceiptContribution {
|
|
207
|
+
const facts = parseAckReceipt(credential);
|
|
208
|
+
return {
|
|
209
|
+
placement: { extracted: { type: "sha256", value: facts.atrHash } },
|
|
210
|
+
artifact: { role: "settlement", bytes: receiptBytes },
|
|
211
|
+
payerDid: facts.payerDid,
|
|
212
|
+
payerDidMethod: facts.payerDidMethod,
|
|
213
|
+
issuerDid: facts.issuerDid,
|
|
214
|
+
paymentRequestToken: facts.paymentRequestToken,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { decodeLegalContextString } from "@integraledger/lcp-binding-core";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { GateProposal, ProposalContext } from "./proposal.js";
|
|
4
|
+
|
|
5
|
+
// Module-internal structural view of the ACP checkout session (NOT the seller's type — buyer ≠ seller).
|
|
6
|
+
// The session object is TOP-LEVEL and `totals` is an ARRAY (stable 2026-04-17); there is no `checkout`
|
|
7
|
+
// wrapper and no `total` string. z.object strips unknown keys, so a real session's many other fields are
|
|
8
|
+
// harmlessly ignored. Kept internal and not z.infer-exported (isolatedDeclarations), exactly as
|
|
9
|
+
// X402ChallengeSchema is in proposal.ts.
|
|
10
|
+
const AcpSessionSchema = z.object({
|
|
11
|
+
currency: z.string(),
|
|
12
|
+
totals: z.array(z.object({ type: z.string(), amount: z.number() })),
|
|
13
|
+
metadata: z.object({
|
|
14
|
+
legal_context: z.string(),
|
|
15
|
+
legal_context_url: z.string(),
|
|
16
|
+
}),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Parse an ACP checkout session into the SAME typed `GateProposal` the x402 parser produces (the LCP §12.7
|
|
21
|
+
* boundary — no prose field on the type). Fail-fast (throws) on a malformed session, a reference that is not
|
|
22
|
+
* a canonical `lcp:sha256:0x…` 32-byte carrier, a non-HTTPS terms URL, a missing `total` row, or a
|
|
23
|
+
* non-integer minor-unit amount.
|
|
24
|
+
*
|
|
25
|
+
* `legal_context_url` is required, and the ACP placement manifest DECLARES it as
|
|
26
|
+
* `termsUrlField: "metadata.legal_context_url"` — the field this parser demands is a field the placement
|
|
27
|
+
* names, which is what makes the round-trip compose. It is deliberately NOT read from ACP's native
|
|
28
|
+
* `links[type=terms_of_use]`: that link is the merchant's standing policy page, while this names the ATR
|
|
29
|
+
* terms document for this transaction, and falling back to it would substitute one for the other.
|
|
30
|
+
*
|
|
31
|
+
* `GateProposal` and `ProposalContext` are IMPORTED, never redefined — one type for every wire is the whole
|
|
32
|
+
* point of a single typed proposal, and a second copy would let the two drift.
|
|
33
|
+
*/
|
|
34
|
+
export function parseProposalFromAcpCheckout(
|
|
35
|
+
session: unknown,
|
|
36
|
+
ctx: ProposalContext,
|
|
37
|
+
): GateProposal {
|
|
38
|
+
const parsed = AcpSessionSchema.parse(session);
|
|
39
|
+
const { legal_context, legal_context_url } = parsed.metadata;
|
|
40
|
+
|
|
41
|
+
// Decoded through binding-core's codec, never by slicing a prefix. The codec enforces the canonical
|
|
42
|
+
// sha256 carrier form (0x-prefixed 32-byte hex) — a bare-digits value throws here rather than being
|
|
43
|
+
// silently re-prefixed into something that looks valid. An earlier draft did `0x${slice(...)}`, which
|
|
44
|
+
// turned a CONFORMANT 0x-carrying reference into `0x0x…` and accepted the non-canonical form instead.
|
|
45
|
+
const ref = decodeLegalContextString(legal_context);
|
|
46
|
+
if (ref === undefined)
|
|
47
|
+
throw new Error(
|
|
48
|
+
`ACP legal_context is not a parseable lcp: reference: ${legal_context}`,
|
|
49
|
+
);
|
|
50
|
+
if (ref.type !== "sha256")
|
|
51
|
+
throw new Error(
|
|
52
|
+
`ACP legal_context must be an lcp:sha256: reference, got lcp:${ref.type}:`,
|
|
53
|
+
);
|
|
54
|
+
// No 0x-32-byte re-check here. `decodeLegalContextString` runs `assertValidValue`, which for `sha256`
|
|
55
|
+
// IS `kernel.isAtrHash` — the identical `/^0x[0-9a-fA-F]{64}$/`. A second copy of that regex could not
|
|
56
|
+
// reject anything the decode admitted: it would be a branch no input reaches, permanently unkillable by
|
|
57
|
+
// any test, and it would tell a reader there is a case here that there is not. The x402 parser carries
|
|
58
|
+
// its own check because it reads a RAW `extra.atrHash` string that no codec has validated; this one
|
|
59
|
+
// does not, and that asymmetry is the point rather than an oversight.
|
|
60
|
+
|
|
61
|
+
if (!legal_context_url.startsWith("https://"))
|
|
62
|
+
throw new Error(`legalContextUrl must be HTTPS: ${legal_context_url}`);
|
|
63
|
+
|
|
64
|
+
// The `total` ROW, never the first row and never a sum. ACP's `totals` carries several typed rows
|
|
65
|
+
// (`items_base_amount`, `tax`, `fee`, `discount`, …) and only the one typed `total` is the amount the
|
|
66
|
+
// buyer is being asked to authorize. There is deliberately no fallback: a session with no `total` row is
|
|
67
|
+
// malformed, and picking another row would gate the buyer against a number nobody quoted.
|
|
68
|
+
const total = parsed.totals.find((t) => t.type === "total");
|
|
69
|
+
if (total === undefined)
|
|
70
|
+
throw new Error(
|
|
71
|
+
"ACP session carries no row typed `total` — nothing to authorize against",
|
|
72
|
+
);
|
|
73
|
+
if (!Number.isSafeInteger(total.amount) || total.amount < 0)
|
|
74
|
+
throw new Error(
|
|
75
|
+
`offer amount must be a non-negative minor-unit integer: ${total.amount}`,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
advertisedAtrHash: ref.value as `0x${string}`,
|
|
80
|
+
legalContextUrl: legal_context_url,
|
|
81
|
+
level: ctx.level,
|
|
82
|
+
offer: {
|
|
83
|
+
amount: String(total.amount),
|
|
84
|
+
// ACP settles in a fiat currency code, not a network:asset pair — the unit string says which.
|
|
85
|
+
unit: parsed.currency,
|
|
86
|
+
},
|
|
87
|
+
sellerAssurance: ctx.sellerAssurance,
|
|
88
|
+
};
|
|
89
|
+
}
|