@integraledger/lcp-placement-x402 0.9.0 → 0.11.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/src/manifest.ts CHANGED
@@ -1,53 +1,75 @@
1
1
  import type { PlacementManifest } from "@integraledger/lcp-binding-core";
2
2
 
3
3
  /**
4
- * The carrier's JSON Schema, INLINED rather than referenced.
4
+ * The carrier's JSON Schema, INLINED rather than referenced — and equal, member for member, to the
5
+ * AUTHORITY document at `https://integraledger.com/lcp/x402/legal-context/v1.schema.json` minus its `$id`
6
+ * and `$defs`. That equality is load-bearing and drift-gated (the conformance suite compares this literal
7
+ * to `@integraledger/lcp-discovery`'s shipped copy of the authority file), because its absence was a
8
+ * published defect: from 0.10.1's release until this version, this schema said `required: ["type",
9
+ * "value"]` while the authority document said `required: ["type", "value", "legalContextUrl"]` — two
10
+ * definitions of the same `info` in the published ecosystem, self-consistent halves, no document valid
11
+ * against both (integra-protocol#8). Both being conformant to the schema each carried is exactly why no
12
+ * package's own tests could catch it; only comparing the two could, and now something does.
5
13
  *
6
- * x402 makes `schema` a REQUIRED member of an extension entry — "JSON Schema defining the expected
7
- * structure of info" — so whatever goes here is on the wire of every challenge. A `$ref` to a URL nobody
8
- * serves would be a required member no counterparty can resolve, and "x402 never fetches it" is a reason
9
- * that does not break, not a reason to ship it. (`https://legalcontextprotocol.org/schemas/lcp-extension.json`
10
- * returns **404**, measured 2026-08-08 — the schema is inlined instead.)
14
+ * WHY INLINE AT ALL: x402 makes `schema` a REQUIRED member of an extension entry — "JSON Schema defining
15
+ * the expected structure of info" — so whatever goes here is on the wire of every challenge. **All nine
16
+ * extensions published in the x402 repository inline a complete JSON Schema** rather than referencing an
17
+ * external document (`x402-foundation/x402` HEAD, read 2026-08-11), and one of the nine makes it a rule:
18
+ * the Bazaar extension requires a `schema`'s `$ref`/`$id` values to be "same-document JSON Pointer
19
+ * fragments (starting with `#`); external references … are not allowed", and says a facilitator "must not
20
+ * resolve external `$ref`/`$id` values … when validating an untrusted `schema`". So an external `$ref`
21
+ * here would not merely be unresolvable to a counterparty that declines to fetch — wherever Bazaar
22
+ * governs, it is rejected outright. LCP v1.38 §C.4 draws the same conclusion ("publish a resolvable
23
+ * schema or inline it — and inlining is the safer of the two"). Dropping `$id` and `$defs` from the
24
+ * inlined form is that rule applied: the authority document's `$id` is an absolute URL, and its `$defs`
25
+ * carries the RECEIPT-time definition, which is not this challenge-time `info` and would bloat every 402.
11
26
  *
12
- * **Both extensions published in the x402 repository inline a complete JSON Schema** rather than
13
- * referencing an external document, and LCP v1.38 §C.4 says to do one or the other: "Because `schema` is a
14
- * REQUIRED member, publish a resolvable schema or inline it." Inlining also removes a hosting dependency
15
- * the deployment does not currently meet — the same dependency the UCP capability still owes.
16
- *
17
- * The shape is the §8.1 reference object this placement writes into `info`, and nothing more: it describes
18
- * the carrier, not the terms behind it.
27
+ * The shape is the §8.1 reference object PLUS the locator the reference is verified through:
28
+ * `legalContextUrl` is REQUIRED here because `value` is a digest — a buyer verifies the terms by fetching
29
+ * the document and hashing it, so a challenge advertising the hash without the locator advertises
30
+ * something no counterparty who lacks the document can check. Every shipped buyer parser already refuses
31
+ * such a challenge; the schema now says on the wire what the readers always demanded. It describes the
32
+ * carrier, not the terms behind it, and asserts nothing about any agreement's lawfulness — the
33
+ * description says so in as many words because the schema travels alone.
19
34
  *
20
35
  * Changing this is a WIRE change — it appears in every challenge — so it is a frozen literal rather than a
21
- * value assembled at call time.
36
+ * value assembled at call time, and the drift gate is what keeps the frozen copy honest.
22
37
  */
23
38
  export const LEGAL_CONTEXT_SCHEMA: Readonly<Record<string, unknown>> =
24
39
  Object.freeze({
25
40
  $schema: "https://json-schema.org/draft/2020-12/schema",
26
- title: "LCP legal-context reference",
41
+ title: "legalContext — x402 extension info",
27
42
  description:
28
- "A Legal Context Protocol reference to the terms governing this transaction. The reference identifies the exact terms document; it is not the terms.",
43
+ "The `info` payload of the `legalContext` x402 extension at challenge time: a Legal Context Protocol reference to the terms governing this transaction, plus the URL the terms document can be fetched from. The reference identifies the exact terms document; it is not the terms. This describes a technology harness and asserts nothing about whether any agreement is lawful, sound or enforceable.",
29
44
  type: "object",
30
- required: ["type", "value"],
31
45
  additionalProperties: false,
46
+ required: ["type", "value", "legalContextUrl"],
32
47
  properties: {
33
48
  type: {
34
- type: "string",
35
- enum: ["sha256", "url", "ipfs", "ar"],
36
49
  description:
37
- "Carrier type. sha256, ipfs and ar are content-addressed and bear integrity; url only locates a document.",
50
+ "The digest algorithm over the terms document. `sha256` is the only value this version defines.",
51
+ type: "string",
52
+ const: "sha256",
38
53
  },
39
54
  value: {
55
+ description:
56
+ "The atrHash — SHA-256 of the terms document, lowercase hex with an 0x prefix.",
40
57
  type: "string",
41
- minLength: 1,
58
+ pattern: "^0x[0-9a-f]{64}$",
59
+ },
60
+ legalContextUrl: {
42
61
  description:
43
- "The reference itself — for sha256, a 0x-prefixed lowercase 32-byte hex digest of the complete ATR file.",
62
+ "Where the terms document this hash covers can be fetched. A reader verifies the document against `value`; the URL is a locator and never the authority.",
63
+ type: "string",
64
+ format: "uri",
44
65
  },
45
66
  },
46
67
  });
47
68
 
48
69
  /**
49
70
  * x402 reference placement — the HTTP-layer carrier, cut against the live x402 v2 specification
50
- * (`x402-foundation/x402@1fec3aa04e41`, `specs/x402-specification-v2.md`; gate discharged in the README).
71
+ * (`x402-foundation/x402@db5da2e65952`, `specs/x402-specification-v2.md`, read 2026-08-11; gate discharged
72
+ * in the README).
51
73
  *
52
74
  * **THE CANONICAL REPOSITORY IS THE FOUNDATION'S, and the citation moved on 2026-08-08.** Every
53
75
  * reference here named `coinbase/x402`, which the GitHub API reports as `"fork": true`;
@@ -90,39 +112,47 @@ export const LEGAL_CONTEXT_SCHEMA: Readonly<Record<string, unknown>> =
90
112
  * would emit something no x402 counterparty parses. This is the reason an alias declares its own `encoding`
91
113
  * at all.
92
114
  *
93
- * **The alias declares no `write`, and the live spec is the reason.** `extra` is "Scheme-specific additional
94
- * information" — the payment scheme's object, whose contents that scheme defines. An `atrHash` is READ there
95
- * because sellers put one there; writing into another party's namespace is not the same act. The
96
- * `extensions` map is the protocol's own declared extension point and is where `place` writes.
115
+ * **The alias IS WRITTEN, and this reverses a recorded stance — deliberately, on three grounds.** The
116
+ * predecessor declared no `write` on the reasoning that `extra` is "Scheme-specific additional information"
117
+ * and writing into another party's namespace is not our act. That reasoning has been overtaken. First, the
118
+ * host itself no longer treats `extra` as wholly scheme-private: §6.1 reserves `assetTransferMethod` and
119
+ * `paymentFlow` inside it as protocol-governed names, so `extra` is a host-managed extension surface with
120
+ * scheme-specific residue, not a foreign namespace. Second, LCP v1.38 §C.4's own Tier A illustration puts
121
+ * `atrHash` AND `legalContextUrl` in `accepts[].extra` — a third-party reader built from the spec's example
122
+ * reads `extra` first, and a challenge that leaves it empty is invisible to that reader. Third, the shipped
123
+ * buyer parser reads BOTH carriers and reconciles, refusing disagreement — so the mirror cannot drift
124
+ * silently: two slots either agree or the document refuses at the counterparty. The write lands only in
125
+ * `accepts[0]`, the requirement buyer parsers read (see the index-0 rule below), and never touches the
126
+ * reserved names.
97
127
  *
98
128
  * **The alias is index 0 only.** A locator names one path. `accepts[0]` is what buyer parsers read, and the
99
129
  * reason is substantive: the reference must bind to the requirement actually being paid, and searching every
100
130
  * requirement would let a seller park a second set of terms on an alternative it never expects to be chosen.
101
131
  *
102
- * **`termsUrlField` is DECLARED — this is the protocol whose wire carries both halves.** `binding-core`'s own
103
- * contract cites x402 for exactly that: a buyer-side parser may demand the URL because x402 carries it, and
104
- * emitters put `legalContextUrl` inside `info` beside `type`/`value`. Declaring the path
105
- * makes that half machine-readable instead of a second private convention; `place` never writes it, because
106
- * `place(ref, doc)` holds one reference and the terms URL is a different datum (the same division ACP draws
107
- * with `metadata.legal_context_url`). The shipped carrier repeats the URL at `accepts[0].extra.legalContextUrl`
108
- * too, which a single `termsUrlField` cannot express — recorded in the README as a known limitation rather
109
- * than half-declared here.
132
+ * **`termsUrlFields` declares BOTH slots the wire carries, and both are written.** The predecessor member
133
+ * (`termsUrlField`, singular) named only the `extensions` slot, and it was read-only in every published
134
+ * package — the write path did not exist anywhere, so a seller assembling from published parts emitted a
135
+ * challenge advertising a hash with no locator, which the published buyer refuses
136
+ * (integra-protocol#8). Declaring both slots makes the manifest state what actually lands on the wire:
137
+ * `place` writes the URL beside the reference in `info` (where the authority schema requires it) and
138
+ * mirrors it at `accepts[0].extra.legalContextUrl` (where §C.4's illustration carries it), and `extract`
139
+ * reconciles the two, refusing disagreement. The kit REQUIRES the URL of any integrity-bearing
140
+ * advertisement on this manifest — a hash no counterparty can resolve is unverifiable by construction,
141
+ * which is the defect the readers always guarded against and the emitters never did.
110
142
  *
111
- * **`carrierTypes` permits `sha256` and `url`, and the two are admitted on DIFFERENT grounds** — one reason
112
- * cannot cover both. `sha256` is the integrity carrier: §C.4's illustration carries one, emitters carry one,
113
- * and the bare alias is fixed to it. `url` is the §8.1 discovery form, admitted because the canonical
114
- * slot is a general reference-object slot and the kit puts the integrity-versus-discovery decision at the
115
- * READER — `carrierClass` plus `requireIntegrity` — not in the permission list; ACP and UCP permit it for the
116
- * same reason. `ipfs`/`ar` are excluded on a ground that does NOT apply to `url`: they are ALTERNATIVE
117
- * integrity carriers, so admitting one adds no capability `sha256` does not already discharge while
118
- * advertising a content-addressed transport no x402 counterparty resolves — a claim about the ecosystem
119
- * rather than a description of it.
120
- *
121
- * The `url` permission is nonetheless WIDER than any shipped x402 reader: a buyer parser that requires an
122
- * integrity carrier refuses `info.type !== "sha256"` outright, so a `url` placed in this slot is well-formed
123
- * against this manifest and would still be rejected at read time. Recorded in the README as a limitation
124
- * rather than narrowed away here, because narrowing the reference field to one type is a change to what the
125
- * SLOT may hold across the set, not a fact about x402.
143
+ * **`carrierTypes` is `sha256` alone, and the `url` admission is WITHDRAWN — a defect resolved, not a
144
+ * preference.** The predecessor admitted `url` as the §8.1 discovery form and recorded, in the same
145
+ * docblock, that the permission was "WIDER than any shipped x402 reader": a buyer parser that requires an
146
+ * integrity carrier refuses `info.type !== "sha256"` outright, so a `url` placed in this slot was
147
+ * well-formed against the manifest and rejected at read time — a permission no reader accepts, which is a
148
+ * claim about the ecosystem rather than a description of it. The withdrawal ground is now structural: the
149
+ * `schema` member this package puts on the wire is the AUTHORITY document's shape, whose `type` is
150
+ * `const: "sha256"`, so a `url` reference would emit a challenge that violates its own adjacent schema.
151
+ * The predecessor declined to narrow because "narrowing the reference field to one type is a change to
152
+ * what the SLOT may hold across the set" — that set-wide decision has since been made, by the authority
153
+ * document. `ipfs`/`ar` remain excluded on the original ground: alternative integrity carriers add no
154
+ * capability `sha256` does not already discharge while advertising a content-addressed transport no x402
155
+ * counterparty resolves.
126
156
  */
127
157
  export const X402_PLACEMENT: PlacementManifest = {
128
158
  protocol: "x402",
@@ -136,10 +166,14 @@ export const X402_PLACEMENT: PlacementManifest = {
136
166
  path: "accepts.0.extra.atrHash",
137
167
  encoding: "bare-value",
138
168
  bareType: "sha256",
169
+ write: true,
139
170
  },
140
171
  ],
141
- termsUrlField: "extensions.legalContext.info.legalContextUrl",
142
- carrierTypes: ["sha256", "url"],
172
+ termsUrlFields: [
173
+ "extensions.legalContext.info.legalContextUrl",
174
+ "accepts.0.extra.legalContextUrl",
175
+ ],
176
+ carrierTypes: ["sha256"],
143
177
  specRef:
144
- "x402 v2 (x402-foundation/x402@1fec3aa04e41 specs/x402-specification-v2.md, read 2026-07-30) — top-level extensions map carried on PaymentRequired/PaymentPayload/SettlementResponse, each entry {info, schema}; accepts[].extra is scheme-specific (gate discharged: see README)",
178
+ "x402 v2 (x402-foundation/x402@db5da2e65952 specs/x402-specification-v2.md, read 2026-08-11) — top-level extensions map carried on PaymentRequired/PaymentPayload/SettlementResponse, each entry {info, schema}; accepts[].extra carries the §C.4 mirror (reserved names per §6.1 untouched; gate discharged: see README)",
145
179
  };
package/src/placement.ts CHANGED
@@ -1,116 +1,68 @@
1
1
  import {
2
- CarrierError,
3
- encodeForField,
4
- type LegalContextRef,
2
+ type LegalContextAdvertisement,
5
3
  makePlacement,
6
4
  type Outcome,
7
5
  type ReferencePlacementAdapter,
6
+ readAtPath,
7
+ requireWritten,
8
+ writeToContainer,
8
9
  } from "@integraledger/lcp-binding-core";
9
10
  import { LEGAL_CONTEXT_SCHEMA, X402_PLACEMENT } from "./manifest.js";
10
11
 
11
12
  const base = makePlacement(X402_PLACEMENT);
12
13
 
13
- /** Refusals namespaced from the protocol id, exactly as the kit does — so `x402/document-malformed` means the
14
- * same thing whether it came from the override or from the generic read path. */
15
- function refuse(code: string, detail: string): Outcome<never> {
16
- return {
17
- refused: true,
18
- haltClass: "verification-failure",
19
- code: `x402/${code}`,
20
- detail,
21
- };
22
- }
23
-
24
14
  /**
25
- * The x402 reference placement — the kit's `extract`, and the ONE `place` in the set that the kit cannot
26
- * supply.
15
+ * The x402 reference placement — the kit, plus the ONE wrapper the kit cannot know.
16
+ *
17
+ * x402's slot does not hold the reference directly: it holds `{ info, schema }`, where `info` is the
18
+ * carrier payload and `schema` is the inlined JSON Schema describing it (a REQUIRED member per x402
19
+ * §5.1.2). That wrapper is a protocol fact no container kind models, so `place` is overridden — but as
20
+ * COMPOSITION over `base.place`, not as a reimplementation beside it. The kit performs the entire
21
+ * placement first: the advertisement rules (terms URL demanded of an integrity-bearing reference,
22
+ * refused where malformed), the canonical write into `extensions.legalContext.info`, the bare-hash
23
+ * mirror into `accepts[0].extra.atrHash`, the terms-URL writes into BOTH declared slots, every
24
+ * malformed-container refusal, and purity. The override then does exactly one thing: rebuilds our entry
25
+ * as `{ info, schema }` so the wrapper's second member lands beside the payload the kit just wrote.
27
26
  *
28
- * x402's slot does not hold the reference directly: it holds `{ info, schema }`, where `info` is the §8.1
29
- * reference object and `schema` is a `$ref` pointing at the carrier schema. That is a WRAPPER, and no
30
- * container kind models it. Inventing an `x402-extension` container kind would put one protocol's name inside
31
- * a generic enum — the abstraction leaking — so the write half is overridden here, in this package, where it
32
- * is reviewed like any other code. `extract` is the kit's unchanged: reading `extensions.legalContext.info` is
33
- * an ordinary object-path read, and the bare-hash alias at `accepts.0.extra.atrHash` is handled by its own
34
- * declared encoding (S2/S6). One overridden member is composition; a second would mean this package had
35
- * stopped using the kit, and the test suite says so.
27
+ * The predecessor override reimplemented the write wholesale — sibling preservation, own-property
28
+ * walks, the malformed-`extensions` refusal, all restated beside the kit's copies — which is how it
29
+ * could drift: it froze a schema and never wrote a terms URL while the kit's manifest declared one
30
+ * (integra-protocol#8). A one-member rebuild has no room to disagree with the manifest, because
31
+ * everything the manifest declares is discharged before it runs.
32
+ *
33
+ * Rebuilding the ENTRY wholesale (rather than merging `schema` into whatever sits there) is the
34
+ * predecessor's ratified behaviour, kept: our entry is the direct holder and is REPLACED, so junk a
35
+ * counterparty parked inside `extensions.legalContext` does not ride our wire; sibling entries in
36
+ * `extensions` survive untouched, per the host's own echo rule. `extract` is the kit's, unchanged —
37
+ * reading `info` is an ordinary object-path read, the alias and both terms-URL slots are declared data,
38
+ * and reconciliation lives where every protocol shares it.
36
39
  */
37
40
  export const x402Placement: ReferencePlacementAdapter = {
38
41
  ...base,
39
42
 
40
- place(ref: LegalContextRef, doc: unknown): Outcome<unknown> {
41
- if (!X402_PLACEMENT.carrierTypes.includes(ref.type))
42
- return refuse(
43
- "carrier-type-not-permitted",
44
- `${X402_PLACEMENT.field} permits ${X402_PLACEMENT.carrierTypes.join("/")}, got ${ref.type}`,
45
- );
46
- if (typeof doc !== "object" || doc === null || Array.isArray(doc))
47
- return refuse(
48
- "document-malformed",
49
- "an x402 challenge is a non-null object",
50
- );
51
-
52
- // Rendered through the codec, never by interpolation — a value that does not meet its type's rule refuses
53
- // here instead of putting an extension on the wire that a buyer's parser would reject.
54
- let encoded: unknown;
55
- try {
56
- encoded = encodeForField(ref, X402_PLACEMENT.encoding);
57
- } catch (e) {
58
- if (!(e instanceof CarrierError)) throw e; // never swallow a non-carrier bug
59
- return refuse(
60
- "reference-malformed",
61
- `not a valid carrier value for its type: ${ref.value}`,
62
- );
63
- }
64
-
65
- // OWN PROPERTY ONLY. `place`'s document is exactly as attacker-influenced as `extract`'s, so a challenge
66
- // with ZERO own properties must not walk into its prototype's `extensions` and put those entries on the
67
- // wire — `extract` reports that document as `reference-absent`, and the two halves have to agree about
68
- // what is present. binding-core's own writer states the rule and proves it for the generic path; the
69
- // override is the one write path that has to restate it. The DECLARED view rather than a
70
- // `Record<string, unknown>` is deliberate: reading `extensions` off an index signature is TS4111, and the
71
- // bracket form biome would then ask for is the fix it classes as unsafe. The document is still spread
72
- // wholesale below, so nothing is narrowed away.
73
- const ext = Object.hasOwn(doc, "extensions")
74
- ? (doc as { readonly extensions?: unknown }).extensions
75
- : undefined;
76
-
77
- // Sibling extensions are PRESERVED: the live x402 v2 spec states a client "must include at least the info
78
- // received; it may append additional info but cannot delete or overwrite existing info". That is the host
79
- // protocol's rule about its own map, and a placement that pruned a sibling would make the client
80
- // using it non-conformant.
81
- //
82
- // An `extensions` that is PRESENT but cannot be merged into REFUSES, and that is binding-core's ratified
83
- // malformed-container rule read against the manifest this package PUBLISHES rather than against the
84
- // granularity the override happens to write at: `field` is `extensions.legalContext.info`, so `legalContext`
85
- // is the field's direct holder and `extensions` sits one level ABOVE it — replaced at the holder, refused
86
- // above it, because replacing an intermediate discards everything beneath it. A stranger holding only the
87
- // manifest and the kit computes that refusal for the same document, and an override that emitted a
88
- // challenge instead would make the manifest a description rather than an artifact. ABSENT is still
89
- // created; our own entry, being the direct holder, is still replaced.
90
- const siblings =
91
- ext === undefined
92
- ? {}
93
- : typeof ext === "object" && ext !== null && !Array.isArray(ext)
94
- ? (ext as Record<string, unknown>)
95
- : undefined;
96
- if (siblings === undefined)
97
- return refuse(
98
- "document-malformed",
99
- `extensions is present and is not a map, so ${X402_PLACEMENT.field} has no holder to write into: ${JSON.stringify(ext)}`,
100
- );
101
-
102
- return {
103
- ok: true,
104
- value: {
105
- ...(doc as object),
106
- extensions: {
107
- ...siblings,
108
- legalContext: {
109
- info: encoded,
110
- schema: LEGAL_CONTEXT_SCHEMA,
111
- },
43
+ place(ad: LegalContextAdvertisement, doc: unknown): Outcome<unknown> {
44
+ const placed = base.place(ad, doc);
45
+ if ("refused" in placed) return placed;
46
+ // The kit's write just created this path on its own output; reading it back — rather than re-encoding
47
+ // the reference a second time — keeps one codepath responsible for what `info` contains.
48
+ const info = readAtPath(placed.value, X402_PLACEMENT.field);
49
+ // TOTAL by the kit's own postconditions: `place` succeeded, so `placed.value` is a record; the write's
50
+ // parent path is the one-segment `extensions`, and a direct holder that is present-but-unmergeable is
51
+ // REPLACED under the ratified container rule, never refused. `requireWritten` states that rather than
52
+ // asserting it with a cast — if the postcondition ever breaks, this throws instead of returning a
53
+ // success carrying no document.
54
+ const wrapped = requireWritten(
55
+ writeToContainer(
56
+ placed.value,
57
+ X402_PLACEMENT.container,
58
+ "extensions.legalContext",
59
+ {
60
+ info,
61
+ schema: LEGAL_CONTEXT_SCHEMA,
112
62
  },
113
- },
114
- };
63
+ ),
64
+ "x402Placement.place",
65
+ );
66
+ return { ok: true, value: wrapped };
115
67
  },
116
68
  };