diff --git a/conformance/manifest_version_rule_test.go b/conformance/manifest_version_rule_test.go new file mode 100644 index 00000000..cb12e359 --- /dev/null +++ b/conformance/manifest_version_rule_test.go @@ -0,0 +1,182 @@ +package conformance + +// Drift guard: the manifest-version rule the SDKs enforce IS the rule the contract +// states. +// +// WellKnownManifest.ver carries a normative receive-side rule — accept a recognised +// MAJOR, reject an unrecognised MAJOR, a malformed value, and an absent field — and +// it deliberately carries no protovalidate rule: a pattern would make the field +// structurally required on every message that embeds the manifest, and an exact +// match would refuse the additive minor revisions the rule exists to admit. So the +// contract's whole statement of the rule is prose in the field's comment, and the +// enforcement is code in three SDK endpoint resolvers. +// +// Between them sits the shared corpus, which pins the three implementations to each +// other. This file pins both to the prose, the same construction as +// endpoint_rule_test.go and for the same reason: both halves are DATA READS, and +// nothing here imports sdk/go. + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +// manifestVersionVectors is the SDK's committed verdict set for the rule, read as data. +const manifestVersionVectors = "../sdk/go/helpers/testdata/manifest-version-vectors.json" + +type manifestVersionCase struct { + Name string `json:"name"` + Ver string `json:"ver"` + Present bool `json:"present"` + Accepted bool `json:"accepted"` +} + +var errNoManifestVersionVectors = errors.New( + "the manifest-version corpus is missing or empty — regenerate it with " + + "RAMP_UPDATE_VECTORS=1 go test ./sdk/go/helpers/ -run TestGenerateManifestVersionVectors") + +var sdkManifestVersionCases = sync.OnceValues(func() ([]manifestVersionCase, error) { + b, err := os.ReadFile(manifestVersionVectors) + if err != nil { + return nil, err + } + var doc struct { + Cases []manifestVersionCase `json:"manifest_version"` + } + if err := json.Unmarshal(b, &doc); err != nil { + return nil, err + } + if len(doc.Cases) == 0 { + return nil, errNoManifestVersionVectors + } + return doc.Cases, nil +}) + +func mustManifestVersionCases(t *testing.T) []manifestVersionCase { + t.Helper() + cases, err := sdkManifestVersionCases() + if err != nil { + t.Fatalf("read %s: %v", manifestVersionVectors, err) + } + return cases +} + +// manifestVerComment is the comment block above WellKnownManifest.ver specifically. +// `string ver = 1` appears on every message, so the shared fieldComment extractor +// (first match in file order) would return some other message's envelope comment; +// this walk is message-scoped, the same way ver_field_contract_test.go attributes +// each `ver` to its enclosing message. +func manifestVerComment(t *testing.T) string { + t.Helper() + for _, f := range collectVerFields(t) { + if f.message == "WellKnownManifest" { + // collectVerFields joins the raw lines, `//` markers included, so a + // clause that wraps across two source lines carries a marker in the + // middle. Strip them so phrases are matched against prose. + return strings.TrimPrefix(strings.ReplaceAll(f.comment, " // ", " "), "// ") + } + } + t.Fatal("WellKnownManifest.ver not found in the contract protos") + return "" +} + +// TestManifestVersionCommentStatesEveryClauseTheSDKEnforces reads the rule out of +// the contract and holds it to the clauses the SDK actually applies. +func TestManifestVersionCommentStatesEveryClauseTheSDKEnforces(t *testing.T) { + comment := manifestVerComment(t) + + // Guard the guard: pin the extraction to the block it is supposed to have read. + const opening = "Version of THIS MANIFEST DOCUMENT's layout" + if !strings.HasPrefix(comment, opening) { + t.Fatalf("the extracted comment does not begin with the manifest ver's own first line "+ + "(%q) — this guard is reading the wrong block and would assert nothing.\n read: %.120s…", + opening, comment) + } + + for _, clause := range []struct { + name string + phrase string + why string + }{ + {"separate namespace", "separate from the RPC envelope", + "the manifest version is not the protocol version and is never stamped from it"}, + {"own constant", "WellKnownManifestVersion", + "the value has one owner, and it is not ProtocolVersion"}, + {"coupling direction", "bumps both numbers", + "a manifest change moves the protocol version; the reverse does not hold"}, + {"read first", "before any other member", + "the gate runs before the endpoint is so much as looked at"}, + {"recognised major accepted", "ACCEPT a recognised MAJOR version whatever the MINOR", + "a minor revision of the manifest is additive"}, + {"unrecognised major rejected", "REJECT an unrecognised MAJOR", + "a layout the reader cannot classify must not supply the endpoint"}, + {"malformed rejected", "not MAJOR.MINOR", + "a value the rule cannot parse is not a version it recognises"}, + {"absent rejected", "an ABSENT `ver`", + "a document with no version is one whose layout the reader cannot classify"}, + } { + if !strings.Contains(comment, clause.phrase) { + t.Errorf("the WellKnownManifest.ver comment no longer states %q (%s).\n"+ + "The SDKs still enforce it and the corpus still pins it, so the contract and the "+ + "implementations have drifted — restore the clause or change all four together.", + clause.name, clause.why) + } + } +} + +// TestManifestVersionCorpusCoversEveryStatedClause holds the other direction: each +// clause the contract states must be exercised by at least one vector, so three +// SDKs cannot quietly stop enforcing it while the comment still reads correctly. +func TestManifestVersionCorpusCoversEveryStatedClause(t *testing.T) { + cases := mustManifestVersionCases(t) + var sameMajorHigherMinor, unrecognisedMajor, malformed, absent bool + for _, c := range cases { + switch { + case !c.Present: + absent = absent || !c.Accepted + case c.Accepted && strings.HasPrefix(c.Ver, "1.") && c.Ver != "1.0": + sameMajorHigherMinor = true + case !c.Accepted && isMajorMinor(c.Ver) && !strings.HasPrefix(c.Ver, "1."): + unrecognisedMajor = true + case !c.Accepted && !isMajorMinor(c.Ver): + malformed = true + } + } + for name, ok := range map[string]bool{ + "a same-major higher-minor version accepted": sameMajorHigherMinor, + "an unrecognised major refused": unrecognisedMajor, + "a value that is not MAJOR.MINOR refused": malformed, + "an absent ver refused": absent, + } { + if !ok { + t.Errorf("the corpus at %s has no vector for %s — the contract states the clause, so the SDKs must be pinned to it", + filepath.Base(manifestVersionVectors), name) + } + } +} + +// isMajorMinor is the corpus-side classifier used only to sort vectors into the +// clauses above. It is deliberately a loose re-statement, not the SDK's parser: +// the SDK's verdict is what the corpus records, and this only decides which +// clause a recorded verdict exercises. +func isMajorMinor(ver string) bool { + major, minor, found := strings.Cut(ver, ".") + return found && digitsOnly(major) && digitsOnly(minor) +} + +func digitsOnly(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true +} diff --git a/conformance/ver_field_contract_test.go b/conformance/ver_field_contract_test.go index 7dc6d4a4..bb8cd01b 100644 --- a/conformance/ver_field_contract_test.go +++ b/conformance/ver_field_contract_test.go @@ -37,7 +37,7 @@ package conformance import ( "encoding/json" - "errors" + "fmt" "os" "path/filepath" "regexp" @@ -59,8 +59,9 @@ var ( // stale, and an entry whose comment has since adopted the standard advisory // wording fails as a no-longer-needed exemption. var verExemptMessages = map[string]string{ - "WellKnownManifest": "the /.well-known/ramp.json document's own schema version — a separate " + - "namespace from the RPC envelope, stated MUST-equal rather than advisory", + "WellKnownManifest": "the /.well-known/ramp.json document's own layout version — a separate " + + "namespace from the RPC envelope, gated on its MAJOR by consumers rather than advisory; " + + "its expected value is the SDK's WellKnownManifestVersion, not ProtocolVersion", } // wireConstantsVectors is the committed cross-language oracle for the SDK's wire @@ -68,14 +69,13 @@ var verExemptMessages = map[string]string{ // generator. Read as data (see the file header on why this is not an import). const wireConstantsVectors = "../sdk/go/helpers/testdata/wire-constants-vectors.json" -// protocolVersion returns the RAMP protocol version the SDK exports, read from the -// committed vector so this guard never restates the literal. Errors are fatal -// rather than skipped: a missing file or entry means the guard has lost its -// anchor, and silently passing would be worse than failing. -var protocolVersion = sync.OnceValues(func() (string, error) { +// wireVectors loads the committed vector once. Errors are fatal rather than +// skipped: a missing file means the guard has lost its anchor, and silently +// passing would be worse than failing. +var wireVectors = sync.OnceValues(func() (map[string]string, error) { b, err := os.ReadFile(wireConstantsVectors) if err != nil { - return "", err + return nil, err } var doc struct { Vectors []struct { @@ -84,18 +84,42 @@ var protocolVersion = sync.OnceValues(func() (string, error) { } `json:"vectors"` } if err := json.Unmarshal(b, &doc); err != nil { - return "", err + return nil, err } + byName := make(map[string]string, len(doc.Vectors)) for _, v := range doc.Vectors { - if v.Name == "ProtocolVersion" { - return v.Value, nil - } + byName[v.Name] = v.Value } - return "", errNoProtocolVersionVector + return byName, nil }) -var errNoProtocolVersionVector = errors.New( - wireConstantsVectors + " carries no ProtocolVersion entry — this guard reads the expected `ver` value from there") +// wireConstant returns a reader for one named entry of the vector, so this guard +// never restates a literal. A missing entry is an error for the same reason a +// missing file is. +func wireConstant(name string) func() (string, error) { + return func() (string, error) { + byName, err := wireVectors() + if err != nil { + return "", err + } + v, ok := byName[name] + if !ok { + return "", fmt.Errorf("%s carries no %s entry — this guard reads the expected `ver` value from there", + wireConstantsVectors, name) + } + return v, nil + } +} + +var ( + // protocolVersion is the RAMP protocol version the SDK exports. + protocolVersion = wireConstant("ProtocolVersion") + // manifestVersion is the SDK's WellKnownManifestVersion. It is a separate + // lookup on purpose: the two constants are separate namespaces, and a guard + // that checked the manifest comment against ProtocolVersion would be the + // coupling the contract forbids, written into the thing that guards it. + manifestVersion = wireConstant("WellKnownManifestVersion") +) // expectedVersionToken is the quoted form a `ver` comment must contain, e.g. `"1.0"`. func expectedVersionToken(t *testing.T) string { @@ -107,6 +131,18 @@ func expectedVersionToken(t *testing.T) string { return strconv.Quote(v) } +// expectedManifestVersionToken is the quoted form the MANIFEST's `ver` comment must +// contain. Read from its own constant, so a bump of either namespace alone goes +// red on exactly the comment it invalidates. +func expectedManifestVersionToken(t *testing.T) string { + t.Helper() + v, err := manifestVersion() + if err != nil { + t.Fatalf("read the SDK manifest version: %v", err) + } + return strconv.Quote(v) +} + // statesExpectedValue is the obligation on EVERY `ver` comment: an integrator // reading the field must learn what to stamp. Required of exempt fields too — // the exemption is from the advisory rule, not from naming the value. `token` is @@ -176,17 +212,23 @@ func TestVerFieldsDocumentTheirContract(t *testing.T) { } token := expectedVersionToken(t) + manifestToken := expectedManifestVersionToken(t) for _, f := range fields { if f.comment == "" { t.Errorf("%s:%d %s.ver carries no doc comment — every `ver` must state the expected value and its receive-side rule", f.file, f.line, f.message) continue } - if !statesExpectedValue(f.comment, token) { - t.Errorf("%s:%d %s.ver does not state the expected value — the comment must name %s (the SDK's ProtocolVersion, per %s) so an integrator learns what to stamp", - f.file, f.line, f.message, token, wireConstantsVectors) + _, exempt := verExemptMessages[f.message] + want, owner := token, "ProtocolVersion" + if exempt { + want, owner = manifestToken, "WellKnownManifestVersion" + } + if !statesExpectedValue(f.comment, want) { + t.Errorf("%s:%d %s.ver does not state the expected value — the comment must name %s (the SDK's %s, per %s) so an integrator learns what to stamp", + f.file, f.line, f.message, want, owner, wireConstantsVectors) } - if _, exempt := verExemptMessages[f.message]; exempt { + if exempt { continue } if !statesAdvisoryContract(f.comment) { @@ -234,12 +276,23 @@ func TestVerContractDetectors_MetaPositive(t *testing.T) { if !statesAdvisoryContract(std) { t.Error("detector missed the advisory rule in the standard comment") } - manifest := "// RAMP protocol version of THIS MANIFEST DOCUMENT's schema. MUST equal " + token + ";" - if !statesExpectedValue(manifest, token) { + mtoken := expectedManifestVersionToken(t) + manifest := "// Version of THIS MANIFEST DOCUMENT's layout — " + mtoken + ", stamped from WellKnownManifestVersion." + if !statesExpectedValue(manifest, mtoken) { t.Error("detector missed the expected value in the manifest comment") } } +// TestManifestVersionTokenIsReadSeparately pins that the manifest's expected +// value has its own source. The two read the same today, so a guard that quietly +// used ProtocolVersion for both would pass — this test fails the day the vector +// entry goes missing, which is the day the guard would silently start coupling. +func TestManifestVersionTokenIsReadSeparately(t *testing.T) { + if _, err := manifestVersion(); err != nil { + t.Fatalf("the manifest version must be readable from its own vector entry: %v", err) + } +} + // TestVerExpectedValueComesFromTheSDK pins the anchor itself: the token is READ // from the wire-constants vector, not restated here. A comment naming some other // version must fail — which is what makes a half-finished bump (constant moved, @@ -274,8 +327,8 @@ func TestVerContractDetectors_MetaNegative(t *testing.T) { } // The manifest comment states a value but NOT the advisory rule — that // asymmetry is exactly what makes its exemption necessary. - if statesAdvisoryContract("// MUST equal " + token + "; consumers REJECT unrecognised major versions.") { - t.Error("detector treated the MUST-equal manifest rule as the advisory contract") + if statesAdvisoryContract("// They REJECT an unrecognised MAJOR, a value that is not MAJOR.MINOR, and an ABSENT `ver`.") { + t.Error("detector treated the manifest's major-version gate as the advisory contract") } } diff --git a/docs/sdk-parity-matrix.md b/docs/sdk-parity-matrix.md index 109ea63b..7e607813 100644 --- a/docs/sdk-parity-matrix.md +++ b/docs/sdk-parity-matrix.md @@ -12,7 +12,7 @@ Go is the oracle (`sdk/go/{helpers,resolvers,core,connect,connectserver}`); Python and TS mirror it. This document is **generated** from the same two artifacts CI already enforces against the code, so it cannot drift from the real surface — a mismatch fails the API-surface gate or the corpus-completeness gate before it can reach this file. -**At a glance:** 134 symbols at cross-language parity · 16 documented divergences · 183 Go-idiomatic exclusions · 35 conformance corpora, each tri-replayed. +**At a glance:** 138 symbols at cross-language parity · 16 documented divergences · 183 Go-idiomatic exclusions · 36 conformance corpora, each tri-replayed. Layering (L1 pure trust core vs L2 I/O resolvers), the SSRF transport-wiring invariant, and naming conventions are recorded in [`design-history.md`](./design-history.md). @@ -38,6 +38,7 @@ Legend: a name = the public face in that language · `—` = intentionally none | `CatalogRejectionDetail` | `catalog_rejection_detail` | `catalogRejectionDetail` | | `CheckAudience` | `check_audience` | `checkAudience` | | `CheckRegistrationData` | `check_registration_data` | `checkRegistrationData` | +| `CheckWellKnownManifestVersion` | `manifest_version_refusal` | `manifestVersionRefusal` | | `CompileRegistrationSchema` | `compile_registration_schema` | `compileRegistrationSchema` | | `ConnectProtocolVersion` | `ConnectProtocolVersion` | `ConnectProtocolVersion` | | `ConnectProtocolVersionHeader` | `ConnectProtocolVersionHeader` | `ConnectProtocolVersionHeader` | @@ -47,6 +48,7 @@ Legend: a name = the public face in that language · `—` = intentionally none | `DisputeFailureDetail` | `dispute_failure_detail` | `disputeFailureDetail` | | `DomainVerificationFailureDetail` | `domain_verification_failure_detail` | `domainVerificationFailureDetail` | | `EntryVerdict` | `EntryVerdict` | `EntryVerdict` | +| `ErrManifestVersionRefused` | `ManifestVersionRefusedError` | `ManifestVersionRefused` | | `ErrUnknownKey` | `UnknownKeyError` | `UnknownKey` | | `FormatMoney` | `format_money` | `formatMoney` | | `HashURL` | `hash_url` | `hashUrl` | @@ -111,6 +113,7 @@ Legend: a name = the public face in that language · `—` = intentionally none | `VerifyRequest` | `verify_request` | `verifyRequestServer` | | `VerifyRequestAcceptance` | `verify_request_acceptance_jcs` | `verifyRequestAcceptance` | | `VerifyURLEd25519` | `verify_ed25519_signed_url` | `verifyEd25519SignedUrl` | +| `WellKnownManifestVersion` | `WellKnownManifestVersion` | `WellKnownManifestVersion` | ### resolvers — L2 I/O (key/endpoint resolution, active-key, SSRF-guarded fetch) @@ -128,6 +131,7 @@ Legend: a name = the public face in that language · `—` = intentionally none | `ErrEndpointRefused` | `EndpointRefusedError` | `EndpointRefused` | | `ErrKeyExpired` | `KeyExpiredError` | `KeyExpired` | | `ErrKeyRevoked` | `KeyRevokedError` | `KeyRevoked` | +| `ErrManifestVersionRefused` | `ManifestVersionRefusedError` | `ManifestVersionRefused` | | `ErrNoEndpoint` | `NoEndpointError` | `NoEndpoint` | | `ErrRevocationUnevaluated` | `RevocationUnevaluatedError` | `RevocationUnevaluated` | | `ErrUnknownKey` | `UnknownKeyError` | `UnknownKey` | @@ -411,6 +415,7 @@ Go emits each `*-vectors.json` oracle; Python and TS replay it. The completeness | `helpers/testdata/host-rule-vectors.json` | ✅ | ✅ | ✅ | | `helpers/testdata/idempotency-validate-vectors.json` | ✅ | ✅ | ✅ | | `helpers/testdata/licenseterm-vectors.json` | ✅ | ✅ | ✅ | +| `helpers/testdata/manifest-version-vectors.json` | ✅ | ✅ | ✅ | | `helpers/testdata/money-vectors.json` | ✅ | ✅ | ✅ | | `helpers/testdata/multisig-chain-vectors.json` | ✅ | ✅ | ✅ | | `helpers/testdata/offer-verify-vectors.json` | ✅ | ✅ | ✅ | diff --git a/gen/descriptor.binpb b/gen/descriptor.binpb index 4fc4136a..c21256ef 100644 Binary files a/gen/descriptor.binpb and b/gen/descriptor.binpb differ diff --git a/gen/go/ramp/v1/ramp.pb.go b/gen/go/ramp/v1/ramp.pb.go index 2d195386..bc658c3a 100644 --- a/gen/go/ramp/v1/ramp.pb.go +++ b/gen/go/ramp/v1/ramp.pb.go @@ -7349,9 +7349,21 @@ func (x *AccountRegistration) GetDataSchema() *structpb.Struct { // MUST ignore non-applicable fields based on `role`. type WellKnownManifest struct { state protoimpl.MessageState `protogen:"open.v1"` - // RAMP protocol version of THIS MANIFEST DOCUMENT's schema — a namespace - // separate from the RPC envelope `ver`, deliberately not coupled to it. - // MUST equal "1.0"; consumers REJECT unrecognised major versions. + // Version of THIS MANIFEST DOCUMENT's layout — "1.0", stamped by the party + // that serves the document from the SDK's WellKnownManifestVersion, never from + // ProtocolVersion. A namespace separate from the RPC envelope `ver`: a change + // to this document's layout bumps both numbers, a protocol change that leaves + // the document untouched bumps only the envelope's, so a reader that parses + // only manifests upgrades when the manifest changes and at no other time. + // + // Consumers read `ver` before any other member. They ACCEPT a recognised + // MAJOR version whatever the MINOR — a minor revision of the manifest is + // additive, and a reader ignores members it does not know. They REJECT an + // unrecognised MAJOR, a value that is not MAJOR.MINOR, and an ABSENT `ver`: + // the document sits at a fixed, unversioned path and is read before any + // signature is checked, so a layout the reader cannot classify must not + // supply the endpoint a signed call is then sent to. The SDK endpoint + // resolvers apply this rule in all three languages, pinned to one corpus. Ver string `protobuf:"bytes,1,opt,name=ver,proto3" json:"ver,omitempty"` // Role this manifest describes. Role Role `protobuf:"varint,2,opt,name=role,proto3,enum=ramp.v1.Role" json:"role,omitempty"` diff --git a/gen/python/wire/models.py b/gen/python/wire/models.py index 8897e863..78af8fe8 100644 --- a/gen/python/wire/models.py +++ b/gen/python/wire/models.py @@ -1744,7 +1744,7 @@ class WellKnownManifest(WireModel): ) ver: str | None = Field( '', - description='RAMP protocol version of THIS MANIFEST DOCUMENT\'s schema — a namespace\n separate from the RPC envelope `ver`, deliberately not coupled to it.\n MUST equal "1.0"; consumers REJECT unrecognised major versions.', + description='Version of THIS MANIFEST DOCUMENT\'s layout — "1.0", stamped by the party\n that serves the document from the SDK\'s WellKnownManifestVersion, never from\n ProtocolVersion. A namespace separate from the RPC envelope `ver`: a change\n to this document\'s layout bumps both numbers, a protocol change that leaves\n the document untouched bumps only the envelope\'s, so a reader that parses\n only manifests upgrades when the manifest changes and at no other time.\n\nConsumers read `ver` before any other member. They ACCEPT a recognised\n MAJOR version whatever the MINOR — a minor revision of the manifest is\n additive, and a reader ignores members it does not know. They REJECT an\n unrecognised MAJOR, a value that is not MAJOR.MINOR, and an ABSENT `ver`:\n the document sits at a fixed, unversioned path and is read before any\n signature is checked, so a layout the reader cannot classify must not\n supply the endpoint a signed call is then sent to. The SDK endpoint\n resolvers apply this rule in all three languages, pinned to one corpus.', ) diff --git a/gen/ts/wire/schemas.ts b/gen/ts/wire/schemas.ts index 823c9dba..fc59c8df 100644 --- a/gen/ts/wire/schemas.ts +++ b/gen/ts/wire/schemas.ts @@ -212,5 +212,5 @@ export const UsageReportResponseSchema = wire(z.object({ "ext": z.record(z.strin export const WBAFileSchema = wire(z.object({ "keys": z.array(z.object({ "alg": z.string().describe("Signing algorithm. RAMP v1.0: MUST be \"EdDSA\".").default(""), "crv": z.string().describe("Curve. RAMP v1.0: MUST be \"Ed25519\".").default(""), "kty": z.string().describe("Key type. RAMP v1.0: MUST be \"OKP\".").default(""), "not_after": z.string().describe("RFC3339 timestamp. Key is invalid at and after this instant\n (strict upper bound).").default(""), "not_before": z.string().describe("RFC3339 timestamp. Key is invalid before this instant.").default(""), "use": z.string().describe("Intended key use. RAMP v1.0: MUST be \"sig\".").default(""), "x": z.string().describe("base64url-encoded 32-byte Ed25519 public key.").default("") }).describe("JsonWebKey — Inline RFC 7517 JWK object.\n\nRAMP v1.0 supports Ed25519 only: kty=\"OKP\", crv=\"Ed25519\", alg=\"EdDSA\".\n Additional curves are a later concern.\n\n Time bounds are RFC3339 strings (sortable, ops-debuggable, avoids the\n JWT nbf/exp collision). At least one key in the served key set (WBAFile.keys)\n MUST have `not_before <= now < not_after`. Verification MUST reject\n signatures whose key falls outside its window.\n\n Keys carry no `kid`: the RFC 9421 keyid is the RFC 7638 JWK Thumbprint,\n computed locally by the verifier. Carrying a kid alongside the thumbprint\n created a drift surface and is removed.")).describe("RFC 7517 JWK Set \"keys\" member. RAMP v1: Ed25519 (OKP) keys, each with\n not_before/not_after RAMP extension members.").optional(), "revocation_url": z.string().describe("Directory-level emergency revocation channel. One per directory; the list\n it points to enumerates revoked key thumbprints. Consumers poll on a 300s\n cadence (±10% jitter) and replace their local revoked set with the response.").optional() }).describe("WBAFile — Pure Web Bot Auth directory served at the WBA-canonical well-known\n path (/.well-known/http-message-signatures-directory). A JOSE JWK Set per\n RFC 7517 §5 plus a directory-level revocation pointer. JWKs carry no kid; the\n RFC 9421 keyid is the RFC 7638 JWK Thumbprint. Off-the-shelf WBA verifiers\n read the `keys` array and ignore RAMP's extra members (per-key\n not_before/not_after, and revocation_url) per RFC 7517 §5.")); -export const WellKnownManifestSchema = wire(z.object({ "accepted_verifiers": z.array(z.string()).describe("Exchange-only. Trusted attestation verification vendors (domains).").optional(), "account_registration": z.object({ "data_schema": z.record(z.string(), z.any()).describe("JSON Schema (draft 2020-12) describing the RegisterRequest.registration_data\n object this Exchange expects. This field is the single home of the\n enforce/pass-through contract, and publishing it IS the enforcement switch.\n Present: this Exchange validates registration_data against the schema and\n refuses a non-conforming payload with\n REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA, naming the offending\n members in RegistrationFailure.field_errors. Absent: registration_data is\n passed through to the system of record uninspected, so an Exchange that\n publishes no schema needs no change to stay conformant.\n\nThe gate above runs on ACCOUNT CREATION ONLY. A repeat registration, for an\n agent that already holds an account, is answered from the stored record and\n runs no schema check at all — it discards registration_data rather than\n validating it. That exception is stated here rather than left to the section\n above, because this field calls itself the single home of the contract and a\n reader who comes here for the whole rule would otherwise leave with the wrong\n one. See \"Repeat registration\" in the Agent Account Registration section for\n why, and for the other gates it applies to.\n\n Absent means the field carries no bytes, or only JSON whitespace — space, tab,\n carriage return and line feed, RFC 8259's four and no others. Nothing else\n counts, and the distinction is load-bearing rather than pedantic: this is the\n enforcement switch, so a byte sequence read as absent is one that turns\n validation OFF. A consumer that asked its own language what \"blank\" means got\n three different answers to the same document — U+00A0 and U+3000 are whitespace\n to some runtimes and not others, and a decoder that strips a byte order mark\n makes a mark followed by a space look like nothing at all. A document that is\n not empty and not JSON is malformed, which is a refusal; it is never silence.\n\n Safety rules, because a consumer reads this schema out of a THIRD PARTY's\n manifest and validates against it before any signature has been checked. A\n publisher MUST satisfy every rule below and a consumer MUST refuse a schema\n that does not. The bounds are stated here as numbers rather than left to each\n implementation on purpose: a schema is validated at both ends of the same\n registration, and a limit chosen privately by one side refuses payloads the\n other accepts.\n\n Self-contained. Every $ref, $dynamicRef and $recursiveRef MUST be a\n same-document reference — it begins with \"#\". A consumer MUST NOT resolve a\n reference that leaves the document: doing so turns every reader into an\n SSRF vector aimed at a URL the schema's author chose.\n\n One dialect. $schema, wherever it appears in the document, MUST name\n https://json-schema.org/draft/2020-12/schema (an empty fragment on the end\n is the same value). A document declaring none is read as that dialect. An\n older draft is refused rather than validated under semantics its author did\n not intend.\n\n Data is not schema. `const`, `enum`, `default` and `examples` hold arbitrary\n JSON VALUES, and their contents are never read as keywords: a `const` whose\n value happens to carry a \"$ref\" or \"$schema\" member states a value a payload\n may equal, not a reference to resolve or a dialect to honour. Both rules\n above therefore stop at those four keywords, and so does the pattern\n alphabet. Their nesting still counts against the depth cap. Likewise the\n child keys of `properties`, `patternProperties`, `$defs`, `definitions` and\n `dependentSchemas` are NAMES rather than keywords, so a property called\n \"$ref\" is a property.\n\n Bounded size: 16KB, measured as the UTF-8 bytes of this member AS SERVED in\n ramp.json.\n\n One encoding. The bytes MUST be well-formed UTF-8 (RFC 8259 requires it for\n interchange) and MUST NOT begin with a byte order mark. RFC 8259 forbids\n ADDING a mark and lets a parser ignore one, so both policies conform and the\n choice is made here rather than left to each implementation: parsers differ,\n and one that strips a mark validates a different document — and counts three\n bytes against the size cap that the schema does not contain. A consumer MUST\n NOT repair ill-formed bytes either; substituting U+FFFD silently enforces a\n schema nobody published. A mark is in any case only valid at the start of a\n JSON text, and this is a member inside one.\n\n Bounded depth: 32 nested JSON containers, counting the schema itself as the\n first.\n\n Bounded WORK: 10000 evaluations, counted statically over the SCHEMA — each\n anyOf/oneOf/allOf branch and prefixItems entry costs its own subschema, and a\n $ref costs its target. It is the cost of applying the schema at ONE location\n in a payload, not of a whole payload: a subschema under `items` is counted\n once here and evaluated once per element at runtime. The size and depth caps\n do not bound it and are not a substitute for it: branches multiply along a\n reference chain, so a 1.6KB schema five containers deep can cost tens of\n millions of evaluations and tens of seconds against a two-member payload. A\n definition nobody references costs nothing, so a document may carry a library\n of them.\n\n Bounded reference chains: 100 hops, counted as the longest path of $ref hops\n rather than as the number of references the document contains. This is a\n THIRD axis, and a flat chain of definitions shows why it has to be: each one\n referring to the next is three JSON containers deep however long it is, so\n the depth cap never sees it, and it costs one evaluation per link, so the\n work cap does not either. What it does reach is the recursion a validator\n performs while resolving the chain — a chain of a few hundred exhausted one\n implementation's stack outright, on a document every other rule had passed.\n A schema describing a business entity chains one or two references.\n\n No reference cycles. A $ref chain MUST NOT return to a schema already on it.\n The construct is legal JSON Schema and is how a recursive structure is\n written, but its evaluation cost has no static bound and it is what makes a\n validator recurse until it aborts. Registration data describes a business\n entity, which is not a recursive shape.\n\n A portable `pattern` alphabet, stated as what a pattern MAY contain rather\n than as what it may not. A group MUST open with \"(\" or \"(?:\" and nothing\n else; only the escapes \\$, \\(, \\), \\*, \\+, \\., \\/, \\?, \\D, \\W, \\[, \\\\, \\],\n \\^, \\d, \\f, \\n, \\r, \\t, \\v, \\w, \\{, \\| and \\} MAY appear, together with \\xHH\n carrying exactly two hexadecimal digits (spelled out rather than ranged, so a\n conformance guard can check the set character by character); a counted repeat\n MUST NOT exceed 1000 and MUST state its first bound, so \"a{2,}\" is admitted\n and \"a{,5}\" is not; a \"}\" outside a bracket expression MUST close a counted\n repeat, so a literal brace is written \"\\}\"; \"[:\" MUST NOT appear inside a\n bracket expression; a bracket expression MUST close and MUST NOT open with\n \"]\"; and a range endpoint MUST NOT be one of the shorthand classes (\"[\\w-x]\").\n\n The two brace rules are there for the same reason as the bracket ones, and\n were found the same way. \"a{,5}\" is five literal characters to RE2 and a\n repeat of zero to five to Python, so both engines compile it and then\n disagree about which payloads match — the silent kind. An unmatched \"}\" is a\n literal to RE2 and to Python and a syntax error to ECMA-262 under the `u`\n flag, which is the loud kind, and exactly what the unmatched \"]\" rule above\n already refuses.\n\n The direction of that rule is the rule. Draft 2020-12 patterns are ECMA-262,\n but the engines implementations run do not agree on it, and the set they\n disagree about has no end: it grows with every dialect and every library\n version, so a list of forbidden escapes needs a new entry each time somebody\n finds one, and until then the gap is open. The portable set is small and\n closed, and an author who wants a construct outside it writes the characters\n out instead.\n\n Some of the disagreement is loud — RE2 refuses the lookaround, atomic groups\n and backreferences ECMA allows, so a schema using them compiles for one\n implementation and fails for another. The rest is SILENT, and is why this is\n a MUST rather than a SHOULD: inline flags, Unicode property classes, text\n anchors and POSIX bracket names are accepted by one engine and either refused\n or read DIFFERENTLY by the next, so two conformant validators both compile\n the pattern and then disagree about which payloads match it, with nothing\n logged. `\\s` and `\\B` are the plainest examples — RE2 reads `\\s` as\n [\\t\\n\\f\\r ], Python adds the vertical tab, and ECMA-262 adds that plus every\n Unicode space separator; `\\B` finds no word boundary in the empty string for\n RE2 and ECMA-262 and finds one for Python. An explicit character class says\n what was meant and means it everywhere.\n\n Where a pattern may appear. `pattern` states its regex as a value and\n `patternProperties` states its regexes as KEYS; both are patterns and both\n are held to the alphabet above. They are the only two keywords in the dialect\n that carry one — `propertyNames` reaches the rule through the subschema it\n applies.\n\n No nested quantifiers. A quantified group MUST NOT have a body that can\n itself repeat or branch: `(a+)+`, `(a|a)*` and `([a-z]+)*` are refused, while\n `(?:ab)+` is admitted. This is the denial-of-service half of the pattern\n rule, and it is deliberately SEPARATE from the alphabet above, because\n excluding lookaround and backreferences does not cover it — catastrophic\n backtracking needs neither. It has to be a publishing rule rather than a\n runtime bound: a regex spin holds its interpreter, so a consumer cannot\n reliably interrupt one it has already started.\n\n Annotations stay annotations. A consumer MUST NOT assert format,\n contentEncoding or contentMediaType. Libraries default differently on each,\n and a schema whose verdict depends on which library read it has no single\n answer.\n\n The value MUST be a JSON object. Draft 2020-12 also admits a bare boolean as a\n schema, but this field is a Struct and cannot carry one.\n\n A consumer that finds a schema breaking any of these MUST refuse it, and MUST\n then SKIP its local pre-check rather than repair or truncate the schema —\n leaving the Exchange's own enforcement the deciding check, exactly as when no\n schema is published. Refusing locally and declining to send would turn a rule\n about reading a third party's document into a denial of service against the\n consumer's own user. That is the CLIENT side. An Exchange that cannot compile\n its OWN configured schema is looking at a misconfiguration of its deployment,\n and MUST NOT advertise a schema it is not itself enforcing.").optional() }).describe("Exchange-only. How to open an account here — see AccountRegistration, which\n owns the contract. Absent: registration_data is accepted uninspected,\n exactly as before this field existed.").optional(), "base_currency": z.string().describe("Exchange-only. Base currency for pricing (ISO 4217). All unit_cost\n values from this Exchange are denominated in this currency.").optional(), "catalog_contributors": z.array(z.object({ "domain": z.string().describe("Canonical domain of the authorized contributor (e.g., \"doubleverify.com\").").default(""), "relationship": z.string().describe("Relationship of this contributor to the provider.\n Examples: \"verifier\" (resource intelligence vendor that attests to resource\n properties), \"exchange\" (an Exchange that enriches catalog entries).").default("") }).describe("CatalogContributor — A third party authorized to push catalog metadata\n (including attestations) on behalf of a provider.")).describe("Publisher-only. Authorized third-party catalog contributors.\n MUST be empty for non-publisher roles.").optional(), "catalog_endpoint": z.string().describe("Exchange-only. CatalogService endpoint URL (if exposed). It carries the\n same binding as endpoint: it MUST be on the same host AND PORT that serve\n this manifest, or on a subdomain of that host on that port, and MUST NOT\n carry userinfo. A consumer refuses a catalog endpoint anywhere else — a\n publisher's push is a signed call, and a manifest naming an unrelated host\n would redirect it to a party the signature never covered. The host match is\n on a full dot-delimited label boundary, and a port equal to the scheme's\n default and an omitted port are the SAME port. Absent means this Exchange\n does not expose CatalogService; a consumer does not fall back to endpoint.").optional(), "contact": z.string().describe("Contact email (licensing, integration, security).").optional(), "delivery_methods_supported": z.array(z.enum(["DELIVERY_METHOD_DIRECT","DELIVERY_METHOD_INSTRUCTIONS","DELIVERY_METHOD_STREAMING"])).describe("Exchange-only. Supported delivery methods.").optional(), "domain": z.string().describe("Canonical domain serving this manifest.").default(""), "endpoint": z.string().describe("Exchange-only. ExchangeService endpoint URL. MUST be on the same host AND\n PORT that serve this manifest, or on a subdomain of that host on that port,\n and MUST NOT carry userinfo. A consumer refuses an endpoint anywhere else:\n this document is only as trustworthy as the host that served it, so an\n endpoint naming an unrelated host would let whoever answers for the manifest\n redirect a signed call to a party the signature never covered, and another\n port is another service the publisher of the manifest need not control. The\n host match is on a full dot-delimited label boundary, so evil-a.com is not a\n subdomain of a.com. A port equal to the scheme's default and an omitted port\n are the SAME port, so https://x, https://x:443 and x all match. An Exchange\n reachable on a non-default port names that port on both sides.").optional(), "exchanges": z.array(z.object({ "domain": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("Canonical domain of the Exchange, in the shape \"Request recipient\" defines\n in the file header."), "endpoint": z.string().describe("RAMP ExchangeService endpoint URL.").default(""), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "relationship": z.enum(["PROVIDER_RELATIONSHIP_DIRECT","PROVIDER_RELATIONSHIP_RESELLER"]).describe("Relationship type (mirrors ads.txt DIRECT/RESELLER).") }).describe("AuthorizedExchange — A Exchange authorized to sell this provider's resources.")).describe("Publisher-only. Authorized exchanges for this publisher's resources.\n Like ads.txt — declares who may sell. MUST be empty for non-publisher\n roles.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052). Lists keys\n within ext that the consumer MUST understand. Unknown values reject\n with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → ignore-unknown.").optional(), "gnap_grant_endpoint": z.string().describe("Exchange-only. GNAP grant endpoint when GNAP is supported.").optional(), "hash_methods_supported": z.array(z.string()).describe("Exchange-only. Accepted resource hash methods for attestation\n verification.").optional(), "health_endpoint": z.string().describe("Exchange-only. Health check endpoint URL.").optional(), "max_intermediary_hops": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Exchange-only. Maximum forwarding hops this Exchange tolerates on an inbound\n request (Agent → Broker → … → Exchange), counted as RFC 9421 HTTP Message\n Signatures. A request carrying more SHOULD be rejected. Lets Exchanges\n publish their chain-depth tolerance so Brokers prune before forwarding.\n Absent = no published limit (Exchange applies its own default policy).").optional(), "name": z.string().describe("Exchange-only. Human-readable Exchange name.").optional(), "oidc_issuer": z.string().describe("Exchange-only. OIDC Discovery URL when OAuth methods are supported.").optional(), "operator": z.string().describe("Exchange-only. Organization operating this Exchange.").optional(), "operator_domain": z.string().describe("Exchange-only. Operator's corporate domain (may differ from domain).").optional(), "pricing_models_supported": z.array(z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"])).describe("Exchange-only. Supported pricing models.").optional(), "privacy_uri": z.string().describe("Exchange-only. Privacy policy URL.").optional(), "protocol_versions_supported": z.array(z.string()).describe("Exchange-only. Supported RAMP protocol versions (e.g. [\"1.0\"]).").optional(), "role": z.enum(["ROLE_AGENT","ROLE_EXCHANGE","ROLE_BROKER","ROLE_PUBLISHER"]).describe("Role this manifest describes."), "supported_auth_methods": z.array(z.enum(["AUTH_METHOD_GNAP","AUTH_METHOD_OAUTH_DPOP","AUTH_METHOD_OAUTH_BEARER","AUTH_METHOD_OAUTH_MTLS"])).describe("Exchange-only. Authorization methods this Exchange supports\n (ordered by preference).").optional(), "supported_profiles": z.array(z.string()).describe("Exchange-only. Domain extension profiles this Exchange conforms to.\n See standards-layering docs.").optional(), "terms_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("Exchange-only. Digest of the document served at `terms_uri`, in\n \"method:hexdigest\" form (e.g. \"sha256:9f86d081...\"), pinning WHICH terms\n document this manifest is currently offering. `terms_uri` alone cannot\n answer that: it is a URL, and its content changes, so after the first\n revision every earlier registration points at a document that no longer says\n what was agreed. RegisterRequest.terms_digest echoes this value, the request\n signature covers that echo, and the Exchange records the accepted digest\n with the account — which is what makes \"which terms did this operator\n accept\" answerable later, through GetAccountStatusResponse.terms_digest, which\n is where the accepted value is read back. Because a digest identifies a\n document only while\n a copy of it still exists, keeping the historical terms documents\n retrievable is the Exchange's obligation. It sits at the top level rather\n than inside account_registration on purpose: an Exchange with pass-through\n registration publishes no block yet still needs to pin its terms version,\n and coupling \"I enforce a schema\" to \"I version my terms\" would tie together\n two independent decisions. Operator note: publishing this field for the\n first time refuses every client that does not yet echo it, so it is a\n coordinated change rather than a safe addition.").optional(), "terms_uri": z.string().describe("Exchange-only. Terms of service URL.").optional(), "ver": z.string().describe("RAMP protocol version of THIS MANIFEST DOCUMENT's schema — a namespace\n separate from the RPC envelope `ver`, deliberately not coupled to it.\n MUST equal \"1.0\"; consumers REJECT unrecognised major versions.").default("") }).describe("WellKnownManifest — RAMP commercial overlay, served at /.well-known/ramp.json\n by every RAMP participant (agent, exchange, broker, publisher).\n\nCommercial graph only: role, authorized exchanges/contributors, and exchange\n capability fields. Identity keys are NOT here — they live in the WBA directory\n (WBAFile) served at /.well-known/http-message-signatures-directory and are\n referenced by RFC 7638 thumbprint, never republished here.\n Per-role fields are populated only when that role applies; consumers\n MUST ignore non-applicable fields based on `role`.")); +export const WellKnownManifestSchema = wire(z.object({ "accepted_verifiers": z.array(z.string()).describe("Exchange-only. Trusted attestation verification vendors (domains).").optional(), "account_registration": z.object({ "data_schema": z.record(z.string(), z.any()).describe("JSON Schema (draft 2020-12) describing the RegisterRequest.registration_data\n object this Exchange expects. This field is the single home of the\n enforce/pass-through contract, and publishing it IS the enforcement switch.\n Present: this Exchange validates registration_data against the schema and\n refuses a non-conforming payload with\n REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA, naming the offending\n members in RegistrationFailure.field_errors. Absent: registration_data is\n passed through to the system of record uninspected, so an Exchange that\n publishes no schema needs no change to stay conformant.\n\nThe gate above runs on ACCOUNT CREATION ONLY. A repeat registration, for an\n agent that already holds an account, is answered from the stored record and\n runs no schema check at all — it discards registration_data rather than\n validating it. That exception is stated here rather than left to the section\n above, because this field calls itself the single home of the contract and a\n reader who comes here for the whole rule would otherwise leave with the wrong\n one. See \"Repeat registration\" in the Agent Account Registration section for\n why, and for the other gates it applies to.\n\n Absent means the field carries no bytes, or only JSON whitespace — space, tab,\n carriage return and line feed, RFC 8259's four and no others. Nothing else\n counts, and the distinction is load-bearing rather than pedantic: this is the\n enforcement switch, so a byte sequence read as absent is one that turns\n validation OFF. A consumer that asked its own language what \"blank\" means got\n three different answers to the same document — U+00A0 and U+3000 are whitespace\n to some runtimes and not others, and a decoder that strips a byte order mark\n makes a mark followed by a space look like nothing at all. A document that is\n not empty and not JSON is malformed, which is a refusal; it is never silence.\n\n Safety rules, because a consumer reads this schema out of a THIRD PARTY's\n manifest and validates against it before any signature has been checked. A\n publisher MUST satisfy every rule below and a consumer MUST refuse a schema\n that does not. The bounds are stated here as numbers rather than left to each\n implementation on purpose: a schema is validated at both ends of the same\n registration, and a limit chosen privately by one side refuses payloads the\n other accepts.\n\n Self-contained. Every $ref, $dynamicRef and $recursiveRef MUST be a\n same-document reference — it begins with \"#\". A consumer MUST NOT resolve a\n reference that leaves the document: doing so turns every reader into an\n SSRF vector aimed at a URL the schema's author chose.\n\n One dialect. $schema, wherever it appears in the document, MUST name\n https://json-schema.org/draft/2020-12/schema (an empty fragment on the end\n is the same value). A document declaring none is read as that dialect. An\n older draft is refused rather than validated under semantics its author did\n not intend.\n\n Data is not schema. `const`, `enum`, `default` and `examples` hold arbitrary\n JSON VALUES, and their contents are never read as keywords: a `const` whose\n value happens to carry a \"$ref\" or \"$schema\" member states a value a payload\n may equal, not a reference to resolve or a dialect to honour. Both rules\n above therefore stop at those four keywords, and so does the pattern\n alphabet. Their nesting still counts against the depth cap. Likewise the\n child keys of `properties`, `patternProperties`, `$defs`, `definitions` and\n `dependentSchemas` are NAMES rather than keywords, so a property called\n \"$ref\" is a property.\n\n Bounded size: 16KB, measured as the UTF-8 bytes of this member AS SERVED in\n ramp.json.\n\n One encoding. The bytes MUST be well-formed UTF-8 (RFC 8259 requires it for\n interchange) and MUST NOT begin with a byte order mark. RFC 8259 forbids\n ADDING a mark and lets a parser ignore one, so both policies conform and the\n choice is made here rather than left to each implementation: parsers differ,\n and one that strips a mark validates a different document — and counts three\n bytes against the size cap that the schema does not contain. A consumer MUST\n NOT repair ill-formed bytes either; substituting U+FFFD silently enforces a\n schema nobody published. A mark is in any case only valid at the start of a\n JSON text, and this is a member inside one.\n\n Bounded depth: 32 nested JSON containers, counting the schema itself as the\n first.\n\n Bounded WORK: 10000 evaluations, counted statically over the SCHEMA — each\n anyOf/oneOf/allOf branch and prefixItems entry costs its own subschema, and a\n $ref costs its target. It is the cost of applying the schema at ONE location\n in a payload, not of a whole payload: a subschema under `items` is counted\n once here and evaluated once per element at runtime. The size and depth caps\n do not bound it and are not a substitute for it: branches multiply along a\n reference chain, so a 1.6KB schema five containers deep can cost tens of\n millions of evaluations and tens of seconds against a two-member payload. A\n definition nobody references costs nothing, so a document may carry a library\n of them.\n\n Bounded reference chains: 100 hops, counted as the longest path of $ref hops\n rather than as the number of references the document contains. This is a\n THIRD axis, and a flat chain of definitions shows why it has to be: each one\n referring to the next is three JSON containers deep however long it is, so\n the depth cap never sees it, and it costs one evaluation per link, so the\n work cap does not either. What it does reach is the recursion a validator\n performs while resolving the chain — a chain of a few hundred exhausted one\n implementation's stack outright, on a document every other rule had passed.\n A schema describing a business entity chains one or two references.\n\n No reference cycles. A $ref chain MUST NOT return to a schema already on it.\n The construct is legal JSON Schema and is how a recursive structure is\n written, but its evaluation cost has no static bound and it is what makes a\n validator recurse until it aborts. Registration data describes a business\n entity, which is not a recursive shape.\n\n A portable `pattern` alphabet, stated as what a pattern MAY contain rather\n than as what it may not. A group MUST open with \"(\" or \"(?:\" and nothing\n else; only the escapes \\$, \\(, \\), \\*, \\+, \\., \\/, \\?, \\D, \\W, \\[, \\\\, \\],\n \\^, \\d, \\f, \\n, \\r, \\t, \\v, \\w, \\{, \\| and \\} MAY appear, together with \\xHH\n carrying exactly two hexadecimal digits (spelled out rather than ranged, so a\n conformance guard can check the set character by character); a counted repeat\n MUST NOT exceed 1000 and MUST state its first bound, so \"a{2,}\" is admitted\n and \"a{,5}\" is not; a \"}\" outside a bracket expression MUST close a counted\n repeat, so a literal brace is written \"\\}\"; \"[:\" MUST NOT appear inside a\n bracket expression; a bracket expression MUST close and MUST NOT open with\n \"]\"; and a range endpoint MUST NOT be one of the shorthand classes (\"[\\w-x]\").\n\n The two brace rules are there for the same reason as the bracket ones, and\n were found the same way. \"a{,5}\" is five literal characters to RE2 and a\n repeat of zero to five to Python, so both engines compile it and then\n disagree about which payloads match — the silent kind. An unmatched \"}\" is a\n literal to RE2 and to Python and a syntax error to ECMA-262 under the `u`\n flag, which is the loud kind, and exactly what the unmatched \"]\" rule above\n already refuses.\n\n The direction of that rule is the rule. Draft 2020-12 patterns are ECMA-262,\n but the engines implementations run do not agree on it, and the set they\n disagree about has no end: it grows with every dialect and every library\n version, so a list of forbidden escapes needs a new entry each time somebody\n finds one, and until then the gap is open. The portable set is small and\n closed, and an author who wants a construct outside it writes the characters\n out instead.\n\n Some of the disagreement is loud — RE2 refuses the lookaround, atomic groups\n and backreferences ECMA allows, so a schema using them compiles for one\n implementation and fails for another. The rest is SILENT, and is why this is\n a MUST rather than a SHOULD: inline flags, Unicode property classes, text\n anchors and POSIX bracket names are accepted by one engine and either refused\n or read DIFFERENTLY by the next, so two conformant validators both compile\n the pattern and then disagree about which payloads match it, with nothing\n logged. `\\s` and `\\B` are the plainest examples — RE2 reads `\\s` as\n [\\t\\n\\f\\r ], Python adds the vertical tab, and ECMA-262 adds that plus every\n Unicode space separator; `\\B` finds no word boundary in the empty string for\n RE2 and ECMA-262 and finds one for Python. An explicit character class says\n what was meant and means it everywhere.\n\n Where a pattern may appear. `pattern` states its regex as a value and\n `patternProperties` states its regexes as KEYS; both are patterns and both\n are held to the alphabet above. They are the only two keywords in the dialect\n that carry one — `propertyNames` reaches the rule through the subschema it\n applies.\n\n No nested quantifiers. A quantified group MUST NOT have a body that can\n itself repeat or branch: `(a+)+`, `(a|a)*` and `([a-z]+)*` are refused, while\n `(?:ab)+` is admitted. This is the denial-of-service half of the pattern\n rule, and it is deliberately SEPARATE from the alphabet above, because\n excluding lookaround and backreferences does not cover it — catastrophic\n backtracking needs neither. It has to be a publishing rule rather than a\n runtime bound: a regex spin holds its interpreter, so a consumer cannot\n reliably interrupt one it has already started.\n\n Annotations stay annotations. A consumer MUST NOT assert format,\n contentEncoding or contentMediaType. Libraries default differently on each,\n and a schema whose verdict depends on which library read it has no single\n answer.\n\n The value MUST be a JSON object. Draft 2020-12 also admits a bare boolean as a\n schema, but this field is a Struct and cannot carry one.\n\n A consumer that finds a schema breaking any of these MUST refuse it, and MUST\n then SKIP its local pre-check rather than repair or truncate the schema —\n leaving the Exchange's own enforcement the deciding check, exactly as when no\n schema is published. Refusing locally and declining to send would turn a rule\n about reading a third party's document into a denial of service against the\n consumer's own user. That is the CLIENT side. An Exchange that cannot compile\n its OWN configured schema is looking at a misconfiguration of its deployment,\n and MUST NOT advertise a schema it is not itself enforcing.").optional() }).describe("Exchange-only. How to open an account here — see AccountRegistration, which\n owns the contract. Absent: registration_data is accepted uninspected,\n exactly as before this field existed.").optional(), "base_currency": z.string().describe("Exchange-only. Base currency for pricing (ISO 4217). All unit_cost\n values from this Exchange are denominated in this currency.").optional(), "catalog_contributors": z.array(z.object({ "domain": z.string().describe("Canonical domain of the authorized contributor (e.g., \"doubleverify.com\").").default(""), "relationship": z.string().describe("Relationship of this contributor to the provider.\n Examples: \"verifier\" (resource intelligence vendor that attests to resource\n properties), \"exchange\" (an Exchange that enriches catalog entries).").default("") }).describe("CatalogContributor — A third party authorized to push catalog metadata\n (including attestations) on behalf of a provider.")).describe("Publisher-only. Authorized third-party catalog contributors.\n MUST be empty for non-publisher roles.").optional(), "catalog_endpoint": z.string().describe("Exchange-only. CatalogService endpoint URL (if exposed). It carries the\n same binding as endpoint: it MUST be on the same host AND PORT that serve\n this manifest, or on a subdomain of that host on that port, and MUST NOT\n carry userinfo. A consumer refuses a catalog endpoint anywhere else — a\n publisher's push is a signed call, and a manifest naming an unrelated host\n would redirect it to a party the signature never covered. The host match is\n on a full dot-delimited label boundary, and a port equal to the scheme's\n default and an omitted port are the SAME port. Absent means this Exchange\n does not expose CatalogService; a consumer does not fall back to endpoint.").optional(), "contact": z.string().describe("Contact email (licensing, integration, security).").optional(), "delivery_methods_supported": z.array(z.enum(["DELIVERY_METHOD_DIRECT","DELIVERY_METHOD_INSTRUCTIONS","DELIVERY_METHOD_STREAMING"])).describe("Exchange-only. Supported delivery methods.").optional(), "domain": z.string().describe("Canonical domain serving this manifest.").default(""), "endpoint": z.string().describe("Exchange-only. ExchangeService endpoint URL. MUST be on the same host AND\n PORT that serve this manifest, or on a subdomain of that host on that port,\n and MUST NOT carry userinfo. A consumer refuses an endpoint anywhere else:\n this document is only as trustworthy as the host that served it, so an\n endpoint naming an unrelated host would let whoever answers for the manifest\n redirect a signed call to a party the signature never covered, and another\n port is another service the publisher of the manifest need not control. The\n host match is on a full dot-delimited label boundary, so evil-a.com is not a\n subdomain of a.com. A port equal to the scheme's default and an omitted port\n are the SAME port, so https://x, https://x:443 and x all match. An Exchange\n reachable on a non-default port names that port on both sides.").optional(), "exchanges": z.array(z.object({ "domain": z.string().regex(new RegExp("^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*(:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}))?$")).max(260).describe("Canonical domain of the Exchange, in the shape \"Request recipient\" defines\n in the file header."), "endpoint": z.string().describe("RAMP ExchangeService endpoint URL.").default(""), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "relationship": z.enum(["PROVIDER_RELATIONSHIP_DIRECT","PROVIDER_RELATIONSHIP_RESELLER"]).describe("Relationship type (mirrors ads.txt DIRECT/RESELLER).") }).describe("AuthorizedExchange — A Exchange authorized to sell this provider's resources.")).describe("Publisher-only. Authorized exchanges for this publisher's resources.\n Like ads.txt — declares who may sell. MUST be empty for non-publisher\n roles.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052). Lists keys\n within ext that the consumer MUST understand. Unknown values reject\n with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → ignore-unknown.").optional(), "gnap_grant_endpoint": z.string().describe("Exchange-only. GNAP grant endpoint when GNAP is supported.").optional(), "hash_methods_supported": z.array(z.string()).describe("Exchange-only. Accepted resource hash methods for attestation\n verification.").optional(), "health_endpoint": z.string().describe("Exchange-only. Health check endpoint URL.").optional(), "max_intermediary_hops": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Exchange-only. Maximum forwarding hops this Exchange tolerates on an inbound\n request (Agent → Broker → … → Exchange), counted as RFC 9421 HTTP Message\n Signatures. A request carrying more SHOULD be rejected. Lets Exchanges\n publish their chain-depth tolerance so Brokers prune before forwarding.\n Absent = no published limit (Exchange applies its own default policy).").optional(), "name": z.string().describe("Exchange-only. Human-readable Exchange name.").optional(), "oidc_issuer": z.string().describe("Exchange-only. OIDC Discovery URL when OAuth methods are supported.").optional(), "operator": z.string().describe("Exchange-only. Organization operating this Exchange.").optional(), "operator_domain": z.string().describe("Exchange-only. Operator's corporate domain (may differ from domain).").optional(), "pricing_models_supported": z.array(z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"])).describe("Exchange-only. Supported pricing models.").optional(), "privacy_uri": z.string().describe("Exchange-only. Privacy policy URL.").optional(), "protocol_versions_supported": z.array(z.string()).describe("Exchange-only. Supported RAMP protocol versions (e.g. [\"1.0\"]).").optional(), "role": z.enum(["ROLE_AGENT","ROLE_EXCHANGE","ROLE_BROKER","ROLE_PUBLISHER"]).describe("Role this manifest describes."), "supported_auth_methods": z.array(z.enum(["AUTH_METHOD_GNAP","AUTH_METHOD_OAUTH_DPOP","AUTH_METHOD_OAUTH_BEARER","AUTH_METHOD_OAUTH_MTLS"])).describe("Exchange-only. Authorization methods this Exchange supports\n (ordered by preference).").optional(), "supported_profiles": z.array(z.string()).describe("Exchange-only. Domain extension profiles this Exchange conforms to.\n See standards-layering docs.").optional(), "terms_digest": z.string().regex(new RegExp("^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$")).describe("Exchange-only. Digest of the document served at `terms_uri`, in\n \"method:hexdigest\" form (e.g. \"sha256:9f86d081...\"), pinning WHICH terms\n document this manifest is currently offering. `terms_uri` alone cannot\n answer that: it is a URL, and its content changes, so after the first\n revision every earlier registration points at a document that no longer says\n what was agreed. RegisterRequest.terms_digest echoes this value, the request\n signature covers that echo, and the Exchange records the accepted digest\n with the account — which is what makes \"which terms did this operator\n accept\" answerable later, through GetAccountStatusResponse.terms_digest, which\n is where the accepted value is read back. Because a digest identifies a\n document only while\n a copy of it still exists, keeping the historical terms documents\n retrievable is the Exchange's obligation. It sits at the top level rather\n than inside account_registration on purpose: an Exchange with pass-through\n registration publishes no block yet still needs to pin its terms version,\n and coupling \"I enforce a schema\" to \"I version my terms\" would tie together\n two independent decisions. Operator note: publishing this field for the\n first time refuses every client that does not yet echo it, so it is a\n coordinated change rather than a safe addition.").optional(), "terms_uri": z.string().describe("Exchange-only. Terms of service URL.").optional(), "ver": z.string().describe("Version of THIS MANIFEST DOCUMENT's layout — \"1.0\", stamped by the party\n that serves the document from the SDK's WellKnownManifestVersion, never from\n ProtocolVersion. A namespace separate from the RPC envelope `ver`: a change\n to this document's layout bumps both numbers, a protocol change that leaves\n the document untouched bumps only the envelope's, so a reader that parses\n only manifests upgrades when the manifest changes and at no other time.\n\nConsumers read `ver` before any other member. They ACCEPT a recognised\n MAJOR version whatever the MINOR — a minor revision of the manifest is\n additive, and a reader ignores members it does not know. They REJECT an\n unrecognised MAJOR, a value that is not MAJOR.MINOR, and an ABSENT `ver`:\n the document sits at a fixed, unversioned path and is read before any\n signature is checked, so a layout the reader cannot classify must not\n supply the endpoint a signed call is then sent to. The SDK endpoint\n resolvers apply this rule in all three languages, pinned to one corpus.").default("") }).describe("WellKnownManifest — RAMP commercial overlay, served at /.well-known/ramp.json\n by every RAMP participant (agent, exchange, broker, publisher).\n\nCommercial graph only: role, authorized exchanges/contributors, and exchange\n capability fields. Identity keys are NOT here — they live in the WBA directory\n (WBAFile) served at /.well-known/http-message-signatures-directory and are\n referenced by RFC 7638 thumbprint, never republished here.\n Per-role fields are populated only when that role applies; consumers\n MUST ignore non-applicable fields based on `role`.")); diff --git a/proto/CHANGELOG.md b/proto/CHANGELOG.md index 6497f2df..d4ff66be 100644 --- a/proto/CHANGELOG.md +++ b/proto/CHANGELOG.md @@ -2,6 +2,32 @@ ## Unreleased +**SDK endpoint resolvers enforce `WellKnownManifest.ver`; `WellKnownManifestVersion` +exported (SDK behaviour change; no wire change).** The manifest's `ver` comment now +states the full receive-side rule — read before any other member, accept a +recognised MAJOR whatever the MINOR, refuse an unrecognised MAJOR, a non +`MAJOR.MINOR` value and an absent `ver` — and the direction of coupling to the +protocol version (a manifest layout change bumps both; a protocol change alone +bumps only `ProtocolVersion`). Go `helpers.CheckWellKnownManifestVersion` / +`helpers.ErrManifestVersionRefused`, Python `manifest_version_refusal` / +`ManifestVersionRefusedError`, TS `manifestVersionRefusal` / `ManifestVersionRefused` +carry the pure rule; the endpoint resolvers apply it on the endpoint face only and +wrap the refusal as `resolvers.ErrManifestVersionRefused` (same name in each +port), which the client tier classifies as not-sent. A `ver` that is not a JSON +string is refused as absent — a verdict, not a decode failure to retry — and the +value a refusal echoes is clipped to 64 characters, in all three languages. The +key resolvers are unchanged — they read JWK Set documents, not manifests — and a +test in each language pins that a key document carrying a `ver` still resolves. New corpus +`sdk/go/helpers/testdata/manifest-version-vectors.json` (`manifest_version` +list; columns `name`, `ver`, `present`, `accepted`), replayed in all three +languages and registered in the parity matrix. `conformance/manifest_version_rule_test.go` +pins the proto comment to the corpus in both directions; +`ver_field_contract_test.go` now reads the manifest's expected value from the +`WellKnownManifestVersion` vector entry rather than `ProtocolVersion`, so the guard +no longer couples the two namespaces. Test fixtures that serve a manifest to the +real resolver now carry `ver`. Compatibility: a manifest without `ver` was accepted +before and is refused now. + **`TransactionRequest.agent_request_acceptance` adds an agent-signed complete ordered request-set proof (additive wire change).** The proof signs ordered `(offer_sig, exchange)` references plus requester and idempotency key using the diff --git a/proto/ramp/v1/ramp.proto b/proto/ramp/v1/ramp.proto index e8384c24..2e6a98e1 100644 --- a/proto/ramp/v1/ramp.proto +++ b/proto/ramp/v1/ramp.proto @@ -121,9 +121,12 @@ import "ramp/v1/vocab.proto"; // That guard binds what this project emits; it cannot bind a third party, which // is the accepted limit of the advisory model. // -// WellKnownManifest.ver is a SEPARATE namespace — the schema version of the -// /.well-known/ramp.json document, not of the RPC envelope. It is stated -// MUST-equal there and is NOT stamped from ProtocolVersion. Do not couple them. +// WellKnownManifest.ver is a SEPARATE namespace — the version of the +// /.well-known/ramp.json document's layout, not of the RPC envelope. It is +// stamped from the SDK's WellKnownManifestVersion, never from ProtocolVersion, +// and consumers gate on its MAJOR rather than treating it as advisory. The +// coupling runs one way: a manifest change bumps both numbers, a protocol change +// that leaves the manifest untouched bumps only ProtocolVersion. // ============================================================================ // ============================================================================ @@ -3215,9 +3218,21 @@ message WellKnownManifest { expression: "this.terms_digest == '' || this.terms_uri != ''" }; - // RAMP protocol version of THIS MANIFEST DOCUMENT's schema — a namespace - // separate from the RPC envelope `ver`, deliberately not coupled to it. - // MUST equal "1.0"; consumers REJECT unrecognised major versions. + // Version of THIS MANIFEST DOCUMENT's layout — "1.0", stamped by the party + // that serves the document from the SDK's WellKnownManifestVersion, never from + // ProtocolVersion. A namespace separate from the RPC envelope `ver`: a change + // to this document's layout bumps both numbers, a protocol change that leaves + // the document untouched bumps only the envelope's, so a reader that parses + // only manifests upgrades when the manifest changes and at no other time. + // + // Consumers read `ver` before any other member. They ACCEPT a recognised + // MAJOR version whatever the MINOR — a minor revision of the manifest is + // additive, and a reader ignores members it does not know. They REJECT an + // unrecognised MAJOR, a value that is not MAJOR.MINOR, and an ABSENT `ver`: + // the document sits at a fixed, unversioned path and is read before any + // signature is checked, so a layout the reader cannot classify must not + // supply the endpoint a signed call is then sent to. The SDK endpoint + // resolvers apply this rule in all three languages, pinned to one corpus. string ver = 1; // Role this manifest describes. diff --git a/sdk/go/README.md b/sdk/go/README.md index c1cddc18..c816cb31 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -220,12 +220,13 @@ import "github.com/RAMP-Protocol/protocol/sdk/go/resolvers" `NewWBAKeyResolver` (WBA directory, revocation/expiry-aware, with a `Run` poller). - **Endpoint resolver** — `NewWellKnownEndpointResolver` discovers an Exchange's own service endpoint (`WellKnownManifest.endpoint`) from `/.well-known/ramp.json`, - host-keyed and cached per host. Two sentinels, and the difference decides whether - a caller should retry: `ErrNoEndpoint` when the manifest was read and advertises - none, `ErrEndpointRefused` when it advertises one this resolver will not hand back - — on a host unrelated to the one that served the manifest, or carrying userinfo. - Both are verdicts, so both are final; anything else is a transport failure and - worth retrying. The key is an offer-supplied host, so the cache evicts + host-keyed and cached per host. Three sentinels, and the difference decides whether + a caller should retry: `ErrManifestVersionRefused` when the manifest was read and + carries a `ver` this reader does not accept (or none at all), `ErrNoEndpoint` when + it advertises no endpoint, `ErrEndpointRefused` when it advertises one this resolver + will not hand back — on a host unrelated to the one that served the manifest, or + carrying userinfo. All three are verdicts, so all three are final; anything else is + a transport failure and worth retrying. The key is an offer-supplied host, so the cache evicts least-recently-used at a fixed cap and concurrent lookups for one host coalesce to a single fetch. - **Active-key selection** — `ActiveEd25519Key` / `ActiveEd25519KeyWithExpiry` pick diff --git a/sdk/go/connect/route.go b/sdk/go/connect/route.go index 93559e85..87586230 100644 --- a/sdk/go/connect/route.go +++ b/sdk/go/connect/route.go @@ -36,10 +36,12 @@ import ( // // An implementation's ERROR decides how a caller is told to react, so it is part // of the contract rather than an implementation detail. A failure that is a -// VERDICT — the host is unusable, the host is not allowed, the manifest advertises -// no endpoint, or it advertises one that must not be used — MUST wrap -// helpers.ErrInvalidHost, resolvers.ErrNoEndpoint or resolvers.ErrEndpointRefused; -// those three surface as CallNotSent, which tells the caller not to retry. +// VERDICT — the host is unusable, the host is not allowed, the manifest carries a +// version this reader does not accept, advertises no endpoint, or advertises one +// that must not be used — MUST wrap helpers.ErrInvalidHost, +// resolvers.ErrManifestVersionRefused, resolvers.ErrNoEndpoint or +// resolvers.ErrEndpointRefused; those four surface as CallNotSent, which tells +// the caller not to retry. // Anything else is read as a transport failure and reported as CallUnreachable, // i.e. worth retrying. An implementation that returns a bare error for a refusal // therefore has its final answer retried indefinitely. @@ -93,6 +95,7 @@ func vetExchangeEndpoint(ctx context.Context, resolver EndpointResolver, exchang // reaches here that way — an injected one can. kind := CallUnreachable if errors.Is(err, helpers.ErrInvalidHost) || + errors.Is(err, resolvers.ErrManifestVersionRefused) || errors.Is(err, resolvers.ErrNoEndpoint) || errors.Is(err, resolvers.ErrEndpointRefused) { kind = CallNotSent diff --git a/sdk/go/connect/taxonomy_test.go b/sdk/go/connect/taxonomy_test.go index 065dd258..023952ea 100644 --- a/sdk/go/connect/taxonomy_test.go +++ b/sdk/go/connect/taxonomy_test.go @@ -13,6 +13,7 @@ import ( rampv1 "github.com/RAMP-Protocol/protocol/gen/go/ramp/v1" rampconnect "github.com/RAMP-Protocol/protocol/sdk/go/connect" + "github.com/RAMP-Protocol/protocol/sdk/go/helpers" "github.com/RAMP-Protocol/protocol/sdk/go/resolvers" ) @@ -88,7 +89,7 @@ func TestReportUsage_TransientResolveFailureIsUnreachable(t *testing.T) { // opposite branch of the same classification. func TestReportUsage_NoAdvertisedEndpointIsNotSent(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte(`{}`)) // a manifest with no endpoint + _, _ = w.Write([]byte(`{"ver":"` + helpers.WellKnownManifestVersion + `"}`)) // a manifest with no endpoint })) defer srv.Close() @@ -112,6 +113,35 @@ func TestReportUsage_NoAdvertisedEndpointIsNotSent(t *testing.T) { } } +// An Exchange whose manifest carries a document version this reader does not +// accept is a VERDICT too: the manifest was fetched and parsed, and the reader +// refuses to act on it. Nothing about a retry changes the version served. +func TestReportUsage_UnacceptedManifestVersionIsNotSent(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"ver":"2.0","endpoint":"` + "http://" + "exchange.invalid" + `"}`)) + })) + defer srv.Close() + + sig := newSigningFixture(t) + client := rampconnect.NewClient("http://home.invalid", + append(allowLoopback(t), rampconnect.WithSigner(sig.signer))...) + + _, err := client.ReportUsage(context.Background(), &rampv1.UsageReport{ + Exchange: strings.TrimPrefix(srv.URL, "http://"), + TransactionId: "txn-1", + }) + var cerr *rampconnect.CallError + if !errors.As(err, &cerr) { + t.Fatalf("error = %v, want a CallError", err) + } + if cerr.Kind != rampconnect.CallNotSent { + t.Errorf("kind = %v, want CallNotSent — the manifest was read and its version refused", cerr.Kind) + } + if !errors.Is(err, resolvers.ErrManifestVersionRefused) { + t.Error("the resolver's version sentinel must stay reachable through the classification") + } +} + // A peer that refuses because the response exceeds the read cap surfaces as // CallTooLarge rather than as a generic refusal, so a caller can tell "raise your // budget" from "the Exchange said no". diff --git a/sdk/go/connect/testsupport_test.go b/sdk/go/connect/testsupport_test.go index 83121228..f1114c05 100644 --- a/sdk/go/connect/testsupport_test.go +++ b/sdk/go/connect/testsupport_test.go @@ -21,6 +21,7 @@ import ( rampv1 "github.com/RAMP-Protocol/protocol/gen/go/ramp/v1" "github.com/RAMP-Protocol/protocol/sdk/go/core" + "github.com/RAMP-Protocol/protocol/sdk/go/helpers" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -46,7 +47,7 @@ func loopbackManifestServer(t *testing.T, rest http.Handler) (string, *atomic.In mux := http.NewServeMux() mux.HandleFunc("/.well-known/ramp.json", func(w http.ResponseWriter, _ *http.Request) { hits.Add(1) - _ = json.NewEncoder(w).Encode(map[string]any{"endpoint": origin}) + _ = json.NewEncoder(w).Encode(map[string]any{"ver": helpers.WellKnownManifestVersion, "endpoint": origin}) }) mux.Handle("/", rest) srv := httptest.NewServer(mux) diff --git a/sdk/go/connect/verbs_test.go b/sdk/go/connect/verbs_test.go index b1d990f0..ad182631 100644 --- a/sdk/go/connect/verbs_test.go +++ b/sdk/go/connect/verbs_test.go @@ -487,7 +487,7 @@ func crossHost(t *testing.T, endpoint string) string { w.WriteHeader(http.StatusNotFound) return } - _ = json.NewEncoder(w).Encode(map[string]any{"endpoint": endpoint}) + _ = json.NewEncoder(w).Encode(map[string]any{"ver": helpers.WellKnownManifestVersion, "endpoint": endpoint}) })) t.Cleanup(srv.Close) return strings.TrimPrefix(srv.URL, "http://") diff --git a/sdk/go/helpers/constants.go b/sdk/go/helpers/constants.go index 851e98a6..d882aefd 100644 --- a/sdk/go/helpers/constants.go +++ b/sdk/go/helpers/constants.go @@ -18,9 +18,19 @@ const ( // rather than a literal hunt across every message builder. Senders MUST stamp // it; receivers treat `ver` as advisory — see "Protocol version" in ramp.proto // for the receive-side rule, which is stated once, there. The - // /.well-known/ramp.json document carries its own schema version in a - // separate namespace, which this constant does NOT supply. + // /.well-known/ramp.json document carries its own document version in a + // separate namespace, which this constant does NOT supply — that is + // WellKnownManifestVersion below. ProtocolVersion = "1.0" + // WellKnownManifestVersion is the version of the /.well-known/ramp.json + // DOCUMENT layout, stamped on WellKnownManifest.ver by every party that serves + // one. It is a namespace separate from ProtocolVersion and is never derived + // from it: a change to the manifest layout bumps both numbers, a protocol + // change that leaves the manifest untouched bumps only ProtocolVersion. Both + // read "1.0" today because neither has moved yet; neither is derived from + // the other. The receive-side check a manifest reader applies is + // CheckWellKnownManifestVersion. + WellKnownManifestVersion = "1.0" // RequestIDHeader correlates a request across services and the edge. RequestIDHeader = "X-Request-ID" // SignatureAgentHeader carries the signer's Web Bot Auth key-directory URL diff --git a/sdk/go/helpers/gen_manifest_version_vectors_test.go b/sdk/go/helpers/gen_manifest_version_vectors_test.go new file mode 100644 index 00000000..25e65852 --- /dev/null +++ b/sdk/go/helpers/gen_manifest_version_vectors_test.go @@ -0,0 +1,82 @@ +package helpers + +// Manifest-version golden-vector emitter. +// +// The rule this locks is normative — WellKnownManifest.ver states it — and has +// no protovalidate rule, so the only thing pinning the three SDKs to one verdict +// is this corpus. Each row is run through the real CheckWellKnownManifestVersion +// and the emitted verdict is the oracle's, never the table's: a row whose intent +// disagrees with the oracle fails the emitter rather than publishing a wrong +// expectation. +// +// `present` is a column of its own because "absent" is not a string value — a +// replay in a language whose JSON decoder yields null or undefined for a missing +// member must drive the ABSENT case through that path, not through "". +// +// Verification no-op by default (asserts the committed file matches a fresh +// emit); (re)writes under RAMP_UPDATE_VECTORS=1. TEST INFRASTRUCTURE. + +import ( + "os" + "path/filepath" + "testing" +) + +// manifestVersionVector is one CheckWellKnownManifestVersion case: the ver the +// manifest carried (meaningless when Present is false), whether the member was +// present at all, and whether the rule accepts the document. Accepted carries no +// reason string on purpose — the three languages phrase their errors in their own +// vocabularies. +type manifestVersionVector struct { + Name string `json:"name"` + Ver string `json:"ver"` + Present bool `json:"present"` + Accepted bool `json:"accepted"` +} + +func buildManifestVersionVectors(t *testing.T) []manifestVersionVector { + t.Helper() + rows := []struct { + name string + ver string + present bool + want bool + }{ + {"current", WellKnownManifestVersion, true, true}, + {"same_major_next_minor", "1.1", true, true}, + {"same_major_large_minor", "1.99", true, true}, + {"next_major", "2.0", true, false}, + {"prerelease_major", "0.3", true, false}, + {"two_digit_major", "10.0", true, false}, + {"absent", "", false, false}, + {"empty", "", true, false}, + {"major_only", "1", true, false}, + {"patch_component", "1.0.0", true, false}, + {"leading_v", "v1.0", true, false}, + {"non_digit_minor", "1.a", true, false}, + {"leading_space", " 1.0", true, false}, + {"trailing_dot", "1.", true, false}, + {"leading_zero_major", "01.0", true, false}, + } + out := make([]manifestVersionVector, 0, len(rows)) + for _, r := range rows { + got := CheckWellKnownManifestVersion(r.ver) == nil + if got != r.want { + t.Fatalf("%s: CheckWellKnownManifestVersion(%q) accepted=%v, table says %v; fix the table or the rule", + r.name, r.ver, got, r.want) + } + out = append(out, manifestVersionVector{Name: r.name, Ver: r.ver, Present: r.present, Accepted: got}) + } + return out +} + +// TestGenerateManifestVersionVectors emits testdata/manifest-version-vectors.json. +func TestGenerateManifestVersionVectors(t *testing.T) { + doc := map[string]any{"manifest_version": buildManifestVersionVectors(t)} + path := filepath.Join("testdata", "manifest-version-vectors.json") + if os.Getenv("RAMP_UPDATE_VECTORS") == "1" { + writeJSON(t, path, doc) + return + } + assertMatches(t, path, doc) +} diff --git a/sdk/go/helpers/gen_util_vectors_test.go b/sdk/go/helpers/gen_util_vectors_test.go index a6f7e8a4..c9be3c76 100644 --- a/sdk/go/helpers/gen_util_vectors_test.go +++ b/sdk/go/helpers/gen_util_vectors_test.go @@ -239,7 +239,7 @@ func buildHashURLVectors(t *testing.T) []hashURLVector { return out } -// buildWireConstantsVectors emits the seven wire constants by REFERENCING the real +// buildWireConstantsVectors emits the eight wire constants by REFERENCING the real // exported constants — never by re-typing their string values. func buildWireConstantsVectors() []wireConstantVector { return []wireConstantVector{ @@ -248,6 +248,7 @@ func buildWireConstantsVectors() []wireConstantVector { {"ConnectProtocolVersionHeader", ConnectProtocolVersionHeader}, {"ConnectProtocolVersion", ConnectProtocolVersion}, {"ProtocolVersion", ProtocolVersion}, + {"WellKnownManifestVersion", WellKnownManifestVersion}, {"RequestIDHeader", RequestIDHeader}, {"SignatureAgentHeader", SignatureAgentHeader}, } diff --git a/sdk/go/helpers/manifestversion.go b/sdk/go/helpers/manifestversion.go new file mode 100644 index 00000000..0f016ebe --- /dev/null +++ b/sdk/go/helpers/manifestversion.go @@ -0,0 +1,98 @@ +package helpers + +import ( + "errors" + "fmt" + "strings" +) + +// ErrManifestVersionRefused signals that a /.well-known/ramp.json document +// carries a WellKnownManifest.ver this reader does not accept: a major version +// it does not implement, a value that is not MAJOR.MINOR, or no version at all. +// +// It is a VERDICT on the document, distinct from a transport or decode failure: +// the manifest was fetched and parsed, and the reader refuses to act on any +// other member of it. A caller that classifies retryability reads this as final. +var ErrManifestVersionRefused = errors.New("helpers: well-known manifest version not accepted") + +// CheckWellKnownManifestVersion applies the receive-side rule for +// WellKnownManifest.ver. It accepts a version whose MAJOR equals the major of +// WellKnownManifestVersion, whatever the MINOR — a minor revision of the manifest +// is additive by definition, so a reader ignores members it does not know and +// keeps reading. It refuses an unrecognised major, a value that is not of the form +// MAJOR.MINOR (two runs of ASCII digits joined by one dot), and the empty string, +// which is how an absent field arrives. +// +// Absent is refused rather than tolerated: the field is required by the wire +// shape, and a document with no version is one whose layout the reader cannot +// classify. Why the gate runs before any other member is read, and fails closed, +// is stated once on WellKnownManifest.ver in the proto. +// +// The returned error wraps ErrManifestVersionRefused and names the value found, +// so an operator can tell a version mismatch from a network failure. The echo is +// clipped to maxEchoedVer bytes: the document body is read up to 1 MiB and a +// refusal is never cached, so an unclipped echo would let a hostile origin size +// every error a resolve produces. The function is pure: the same input always +// yields the same verdict, and the three SDK languages pin that verdict to a +// shared corpus. +func CheckWellKnownManifestVersion(ver string) error { + acceptMajor := majorOf(WellKnownManifestVersion) + if ver == "" { + return fmt.Errorf("%w: ver is absent, accept major %s", ErrManifestVersionRefused, acceptMajor) + } + major, ok := parseMajor(ver) + if !ok { + return fmt.Errorf("%w: ver %q is not MAJOR.MINOR, accept major %s", ErrManifestVersionRefused, echoVer(ver), acceptMajor) + } + if major != acceptMajor { + return fmt.Errorf("%w: ver %q has major %s, accept major %s", ErrManifestVersionRefused, echoVer(ver), major, acceptMajor) + } + return nil +} + +// maxEchoedVer bounds how much of a refused `ver` the error message repeats. A +// real version is a few bytes; anything longer is only ever going to be shown. +const maxEchoedVer = 64 + +// echoVer is ver clipped for an error message. Python and TS clip at the same +// length, so an operator reading three SDKs' logs sees the same prefix. +func echoVer(ver string) string { + if len(ver) <= maxEchoedVer { + return ver + } + return ver[:maxEchoedVer] + "..." +} + +// majorOf is parseMajor over a value this package owns, so a malformed constant +// is a programming error rather than a runtime verdict. +func majorOf(ver string) string { + major, ok := parseMajor(ver) + if !ok { + panic("helpers: WellKnownManifestVersion is not MAJOR.MINOR: " + ver) + } + return major +} + +// parseMajor returns the MAJOR run of a MAJOR.MINOR string. Both runs must be +// non-empty ASCII digits and exactly one dot may separate them; anything else — +// a missing minor, a patch component, a leading "v", surrounding whitespace, a +// non-digit — is not a version this rule recognises. +func parseMajor(ver string) (string, bool) { + major, minor, found := strings.Cut(ver, ".") + if !found || !allASCIIDigits(major) || !allASCIIDigits(minor) { + return "", false + } + return major, true +} + +func allASCIIDigits(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true +} diff --git a/sdk/go/helpers/manifestversion_vectors_test.go b/sdk/go/helpers/manifestversion_vectors_test.go new file mode 100644 index 00000000..8c3092ba --- /dev/null +++ b/sdk/go/helpers/manifestversion_vectors_test.go @@ -0,0 +1,55 @@ +package helpers + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" +) + +// TestManifestVersionCorpusReplays drives every row of the shared corpus through +// CheckWellKnownManifestVersion and asserts the committed verdict. The emitter +// proves the file matches the oracle at emit time; this replay proves the oracle +// still agrees with the file, which is what the Python and TypeScript replays +// assert against their own ports. +func TestManifestVersionCorpusReplays(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("testdata", "manifest-version-vectors.json")) + if err != nil { + t.Fatalf("read corpus: %v", err) + } + var doc struct { + Cases []manifestVersionVector `json:"manifest_version"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatalf("decode corpus: %v", err) + } + if len(doc.Cases) == 0 { + t.Fatal("corpus is empty; the replay would be vacuous") + } + var accepted, refused, absent int + for _, c := range doc.Cases { + ver := c.Ver + if !c.Present { + ver = "" // an absent member arrives as the zero value in Go + absent++ + } + err := CheckWellKnownManifestVersion(ver) + if c.Accepted { + accepted++ + if err != nil { + t.Errorf("%s: ver=%q present=%v: refused (%v), corpus says accepted", c.Name, c.Ver, c.Present, err) + } + continue + } + refused++ + if err == nil { + t.Errorf("%s: ver=%q present=%v: accepted, corpus says refused", c.Name, c.Ver, c.Present) + } else if !errors.Is(err, ErrManifestVersionRefused) { + t.Errorf("%s: error %v does not carry ErrManifestVersionRefused", c.Name, err) + } + } + if accepted == 0 || refused == 0 || absent == 0 { + t.Fatalf("corpus must carry accepted, refused and absent rows; got %d/%d/%d", accepted, refused, absent) + } +} diff --git a/sdk/go/helpers/protocol_version_ssot_test.go b/sdk/go/helpers/protocol_version_ssot_test.go index bc1e374e..0115003e 100644 --- a/sdk/go/helpers/protocol_version_ssot_test.go +++ b/sdk/go/helpers/protocol_version_ssot_test.go @@ -41,11 +41,11 @@ import ( // `Ver: req.GetVer()` and the constant form `Ver: helpers.ProtocolVersion` do not. // // The struct-literal form is the whole scope, and widening it is not free: a -// matcher that also read `x.Ver = "…"` would flag WellKnownManifest.Ver, whose -// quoted literal is CORRECT — that field versions the /.well-known/ramp.json -// document schema, a separate namespace stated MUST-equal and deliberately not -// stamped from ProtocolVersion. Widening therefore needs a message-level -// carve-out, not a looser regex. +// matcher that also read `x.Ver = "…"` would flag WellKnownManifest.Ver, which +// is stamped from a DIFFERENT constant — WellKnownManifestVersion, the version +// of the /.well-known/ramp.json document layout, a separate namespace never +// derived from ProtocolVersion. Widening therefore needs a message-level +// carve-out that knows which constant each message stamps, not a looser regex. var bareVerLiteralRe = regexp.MustCompile(`\bVer:\s*"[^"]*"`) // stampsBareVerLiteral is the pure predicate, extracted so the meta-tests can diff --git a/sdk/go/helpers/testdata/manifest-version-vectors.json b/sdk/go/helpers/testdata/manifest-version-vectors.json new file mode 100644 index 00000000..5f0da7ea --- /dev/null +++ b/sdk/go/helpers/testdata/manifest-version-vectors.json @@ -0,0 +1,94 @@ +{ + "manifest_version": [ + { + "name": "current", + "ver": "1.0", + "present": true, + "accepted": true + }, + { + "name": "same_major_next_minor", + "ver": "1.1", + "present": true, + "accepted": true + }, + { + "name": "same_major_large_minor", + "ver": "1.99", + "present": true, + "accepted": true + }, + { + "name": "next_major", + "ver": "2.0", + "present": true, + "accepted": false + }, + { + "name": "prerelease_major", + "ver": "0.3", + "present": true, + "accepted": false + }, + { + "name": "two_digit_major", + "ver": "10.0", + "present": true, + "accepted": false + }, + { + "name": "absent", + "ver": "", + "present": false, + "accepted": false + }, + { + "name": "empty", + "ver": "", + "present": true, + "accepted": false + }, + { + "name": "major_only", + "ver": "1", + "present": true, + "accepted": false + }, + { + "name": "patch_component", + "ver": "1.0.0", + "present": true, + "accepted": false + }, + { + "name": "leading_v", + "ver": "v1.0", + "present": true, + "accepted": false + }, + { + "name": "non_digit_minor", + "ver": "1.a", + "present": true, + "accepted": false + }, + { + "name": "leading_space", + "ver": " 1.0", + "present": true, + "accepted": false + }, + { + "name": "trailing_dot", + "ver": "1.", + "present": true, + "accepted": false + }, + { + "name": "leading_zero_major", + "ver": "01.0", + "present": true, + "accepted": false + } + ] +} diff --git a/sdk/go/helpers/testdata/wire-constants-vectors.json b/sdk/go/helpers/testdata/wire-constants-vectors.json index 51479686..0059b690 100644 --- a/sdk/go/helpers/testdata/wire-constants-vectors.json +++ b/sdk/go/helpers/testdata/wire-constants-vectors.json @@ -20,6 +20,10 @@ "name": "ProtocolVersion", "value": "1.0" }, + { + "name": "WellKnownManifestVersion", + "value": "1.0" + }, { "name": "RequestIDHeader", "value": "X-Request-ID" diff --git a/sdk/go/resolvers/endpointresolver.go b/sdk/go/resolvers/endpointresolver.go index 50fa95dc..a81a7731 100644 --- a/sdk/go/resolvers/endpointresolver.go +++ b/sdk/go/resolvers/endpointresolver.go @@ -41,20 +41,54 @@ var ErrNoEndpoint = errors.New("resolvers: well-known manifest has no endpoint") // again in a moment. var ErrEndpointRefused = errors.New("resolvers: well-known manifest advertises an unusable endpoint") -// wellKnownDoc is the JSON projection of the subset of WellKnownManifest the SDK -// resolvers read: the RFC 7517 key set (field 5) and the self-advertised -// ExchangeService endpoint (field 12). One fetch decodes the whole document so a -// single body serves both the key face (WellKnownKeyResolver) and the endpoint -// face (WellKnownEndpointResolver). +// ErrManifestVersionRefused re-exports helpers.ErrManifestVersionRefused — the +// verdict CheckWellKnownManifestVersion returns for a /.well-known/ramp.json +// whose WellKnownManifest.ver this reader does not accept — so the endpoint +// resolver's refusal is checkable from this package beside ErrNoEndpoint and +// ErrEndpointRefused without a caller importing helpers directly. One sentinel +// under two names: an injected resolver that wraps either is classified the +// same way by the client tier. +// +// Like ErrEndpointRefused it is a VERDICT — final, not a transport failure to +// retry — and it is never cached. The rule, and why the gate runs before any +// other member is read, are stated once on WellKnownManifest.ver in the proto. +var ErrManifestVersionRefused = helpers.ErrManifestVersionRefused + +// wellKnownDoc is the JSON projection the two well-known resolvers decode +// through: one decoder for two document shapes, each face reading only its own +// members. The key face (WellKnownKeyResolver) fetches a plain RFC 7517 JWK Set +// from an operator-chosen URL and reads `keys`. The endpoint face +// (WellKnownEndpointResolver) fetches /.well-known/ramp.json and reads the +// WellKnownManifest projection — the document version (field 1) and the +// self-advertised ExchangeService endpoint (field 12). A JWK Set carries no +// manifest version, which is why the version gate is the endpoint face's alone. +// +// Ver is held raw rather than as a string so a document whose `ver` is not a +// JSON string still decodes: the gate then refuses it as a verdict — the answer +// Python and TS give — instead of the decode failing as a transport error the +// client would retry. See manifestVer. // // The embedded jose.JSONWebKeySet promotes the "keys" member, so go-jose (the // canonical JOSE library) parses each JWK — decoding the OKP/Ed25519 `x` member // into an ed25519.PublicKey on JSONWebKey.Key — rather than the SDK hand-rolling -// the kty/crv match and the base64url `x` decode. The endpoint face still reads -// the promoted-alongside Endpoint field from the same body. +// the kty/crv match and the base64url `x` decode. type wellKnownDoc struct { jose.JSONWebKeySet - Endpoint string `json:"endpoint"` + Ver json.RawMessage `json:"ver"` + Endpoint string `json:"endpoint"` +} + +// manifestVer is the string the version rule reads out of the raw `ver` member. +// A member that is absent, null, or not a JSON string yields "" — the value an +// absent field arrives as — so the rule refuses it as absent. Python and TS make +// the same choice (any non-string is absent), so the three languages return the +// same verdict for the same document. +func manifestVer(raw json.RawMessage) string { + var s string + if len(raw) == 0 || json.Unmarshal(raw, &s) != nil { + return "" + } + return s } // fetchWellKnownDoc GETs url and decodes the well-known manifest. It is the one @@ -239,6 +273,12 @@ func (r *WellKnownEndpointResolver) ResolveEndpoint(ctx context.Context, host st if ferr != nil { return "", ferr } + // The document version gate runs before the endpoint is so much as looked + // at, and only on this face: the key face reads a JWKS document that + // carries no manifest version. + if verr := helpers.CheckWellKnownManifestVersion(manifestVer(doc.Ver)); verr != nil { + return "", fmt.Errorf("resolvers: %w", verr) + } if doc.Endpoint == "" { return "", fmt.Errorf("%w: host=%q", ErrNoEndpoint, host) } diff --git a/sdk/go/resolvers/endpointresolver_test.go b/sdk/go/resolvers/endpointresolver_test.go index 58e6311c..3d7184cf 100644 --- a/sdk/go/resolvers/endpointresolver_test.go +++ b/sdk/go/resolvers/endpointresolver_test.go @@ -25,7 +25,7 @@ func manifestHandler(endpoint *string, hits *int) http.Handler { if hits != nil { *hits++ } - doc := map[string]any{"role": "ROLE_EXCHANGE"} + doc := map[string]any{"ver": helpers.WellKnownManifestVersion, "role": "ROLE_EXCHANGE"} if endpoint != nil { doc["endpoint"] = *endpoint } @@ -375,7 +375,7 @@ func TestWellKnownEndpointResolver_leaderCancellationDoesNotPoisonWaiters(t *tes srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { atomic.AddInt32(&hits, 1) <-gate // hold the fetch open until both callers are queued - _ = json.NewEncoder(w).Encode(map[string]any{"endpoint": ep}) + _ = json.NewEncoder(w).Encode(map[string]any{"ver": helpers.WellKnownManifestVersion, "endpoint": ep}) })) defer srv.Close() ep = srv.URL + "/ramp.v1.ExchangeService" diff --git a/sdk/go/resolvers/endpointresolver_version_test.go b/sdk/go/resolvers/endpointresolver_version_test.go new file mode 100644 index 00000000..ab3b8c43 --- /dev/null +++ b/sdk/go/resolvers/endpointresolver_version_test.go @@ -0,0 +1,156 @@ +package resolvers_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/RAMP-Protocol/protocol/sdk/go/helpers" + "github.com/RAMP-Protocol/protocol/sdk/go/resolvers" +) + +// versionedManifestHandler serves a manifest with a caller-chosen `ver`. A nil +// ver omits the member entirely, modeling a manifest with no version at all. +func versionedManifestHandler(ver *string, endpoint string, hits *int) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if hits != nil { + *hits++ + } + doc := map[string]any{"role": "ROLE_EXCHANGE", "endpoint": endpoint} + if ver != nil { + doc["ver"] = *ver + } + _ = json.NewEncoder(w).Encode(doc) + }) +} + +func newVersionTestResolver() *resolvers.WellKnownEndpointResolver { + return resolvers.NewWellKnownEndpointResolver(resolvers.WellKnownOptions{ + TTL: time.Hour, Scheme: "http", HTTP: http.DefaultClient, + }) +} + +// A manifest at the same major and a higher minor is accepted: a minor revision +// of the manifest is additive, so a reader keeps reading. +func TestWellKnownEndpointResolver_acceptsASameMajorHigherMinor(t *testing.T) { + ver := "1.1" + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + versionedManifestHandler(&ver, srv.URL+"/ramp.v1.ExchangeService", nil).ServeHTTP(w, r) + })) + defer srv.Close() + got, err := newVersionTestResolver().ResolveEndpoint(context.Background(), hostOf(t, srv)) + if err != nil { + t.Fatalf("resolve failed: %v; a same-major manifest must be accepted", err) + } + if want := srv.URL + "/ramp.v1.ExchangeService"; got != want { + t.Errorf("resolve = %q, want %q", got, want) + } +} + +// An unrecognised major is refused with the version verdict, and the refusal is +// not cached: once the origin serves an acceptable document, the next resolve +// succeeds without waiting out a TTL. +func TestWellKnownEndpointResolver_refusesAnUnrecognisedMajorAndDoesNotCacheIt(t *testing.T) { + ver := "2.0" + var hits int + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + versionedManifestHandler(&ver, srv.URL+"/ramp.v1.ExchangeService", &hits).ServeHTTP(w, r) + })) + defer srv.Close() + r := newVersionTestResolver() + host := hostOf(t, srv) + + got, err := r.ResolveEndpoint(context.Background(), host) + if err == nil { + t.Fatalf("resolve returned %q; a 2.0 manifest must be refused", got) + } + if !errors.Is(err, resolvers.ErrManifestVersionRefused) { + t.Errorf("error = %v, want it to carry resolvers.ErrManifestVersionRefused", err) + } + if !errors.Is(err, helpers.ErrManifestVersionRefused) { + t.Errorf("error = %v, want it to carry helpers.ErrManifestVersionRefused too", err) + } + + ver = helpers.WellKnownManifestVersion + if _, err := r.ResolveEndpoint(context.Background(), host); err != nil { + t.Fatalf("resolve after the origin recovered failed: %v; a refusal must not be cached", err) + } + if hits != 2 { + t.Errorf("origin hits = %d, want 2 (the refusal was fetched again, not served from cache)", hits) + } +} + +// A manifest with no `ver` at all is refused before its endpoint is read. The +// endpoint is a perfectly usable one, so the refusal can only be the version gate. +func TestWellKnownEndpointResolver_refusesAManifestWithNoVersion(t *testing.T) { + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + versionedManifestHandler(nil, srv.URL+"/ramp.v1.ExchangeService", nil).ServeHTTP(w, r) + })) + defer srv.Close() + got, err := newVersionTestResolver().ResolveEndpoint(context.Background(), hostOf(t, srv)) + if err == nil { + t.Fatalf("resolve returned %q; a manifest with no ver must be refused", got) + } + if !errors.Is(err, resolvers.ErrManifestVersionRefused) { + t.Errorf("error = %v, want it to carry ErrManifestVersionRefused", err) + } + if errors.Is(err, resolvers.ErrNoEndpoint) || errors.Is(err, resolvers.ErrEndpointRefused) { + t.Errorf("error = %v; the version gate must fire before the endpoint is examined", err) + } +} + +// The version gate precedes the endpoint gate: a wrong-version manifest that ALSO +// advertises no endpoint reports the version, because no other member is read. +func TestWellKnownEndpointResolver_versionGatePrecedesEndpointGate(t *testing.T) { + ver := "2.0" + srv := httptest.NewServer(versionedManifestHandler(&ver, "", nil)) + defer srv.Close() + _, err := newVersionTestResolver().ResolveEndpoint(context.Background(), hostOf(t, srv)) + if !errors.Is(err, resolvers.ErrManifestVersionRefused) { + t.Fatalf("error = %v, want ErrManifestVersionRefused", err) + } + if errors.Is(err, resolvers.ErrNoEndpoint) { + t.Errorf("error = %v; ErrNoEndpoint must not be reached when the version is refused", err) + } +} + +// A `ver` that is not a JSON string is refused as absent — a VERDICT, the answer +// Python and TS give — rather than failing the decode, which the client tier +// would classify as unreachable and retry forever against a document that will +// not change. The refusal is one sentinel under two names, so either matches. +func TestWellKnownEndpointResolver_refusesANonStringVersionAsAVerdict(t *testing.T) { + for _, tc := range []struct{ name, doc string }{ + {"number", `{"ver":1,"role":"ROLE_EXCHANGE","endpoint":%q}`}, + {"null", `{"ver":null,"role":"ROLE_EXCHANGE","endpoint":%q}`}, + {"object", `{"ver":{"major":1},"role":"ROLE_EXCHANGE","endpoint":%q}`}, + } { + t.Run(tc.name, func(t *testing.T) { + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = fmt.Fprintf(w, tc.doc, srv.URL+"/ramp.v1.ExchangeService") + })) + defer srv.Close() + _, err := newVersionTestResolver().ResolveEndpoint(context.Background(), hostOf(t, srv)) + if !errors.Is(err, resolvers.ErrManifestVersionRefused) { + t.Fatalf("err = %v, want the version verdict: a non-string ver is absent, not a decode failure", err) + } + if !errors.Is(err, helpers.ErrManifestVersionRefused) { + t.Error("the helpers sentinel must match too: it is the same sentinel under two names") + } + // Refused AS ABSENT, the wording Python and TS produce for the same + // document — not as a malformed string spelled from the raw bytes. + if !strings.Contains(err.Error(), "ver is absent") { + t.Errorf("err = %v, want a non-string ver reported as absent", err) + } + }) + } +} diff --git a/sdk/go/resolvers/library_adoption_guard_test.go b/sdk/go/resolvers/library_adoption_guard_test.go index f66186c9..7b9b5407 100644 --- a/sdk/go/resolvers/library_adoption_guard_test.go +++ b/sdk/go/resolvers/library_adoption_guard_test.go @@ -37,8 +37,8 @@ func TestLibraryAdoptionGuard_KeyResolverUsesGoJoseJWKS(t *testing.T) { } // The hand-rolled JWK struct the key face decodes lives in the shared - // wellKnownDoc.Keys in endpointresolver.go — it must keep only the Endpoint - // field and promote the go-jose key set. + // wellKnownDoc.Keys in endpointresolver.go — it must keep only the scalar + // manifest members (Ver, Endpoint) and promote the go-jose key set. epSrc := readResolverSource(t, "endpointresolver.go") for _, forbidden := range []string{ "Kty string `json:\"kty\"`", @@ -47,7 +47,7 @@ func TestLibraryAdoptionGuard_KeyResolverUsesGoJoseJWKS(t *testing.T) { } { if strings.Contains(epSrc, forbidden) { t.Errorf("endpointresolver.go still declares the hand-rolled JWK struct field %q; "+ - "the key face must decode via go-jose jose.JSONWebKeySet, keeping only the Endpoint field", forbidden) + "the key face must decode via go-jose jose.JSONWebKeySet, keeping only the scalar manifest members", forbidden) } } } diff --git a/sdk/go/resolvers/wellknownkeyresolver.go b/sdk/go/resolvers/wellknownkeyresolver.go index 385a14e3..7012eeb9 100644 --- a/sdk/go/resolvers/wellknownkeyresolver.go +++ b/sdk/go/resolvers/wellknownkeyresolver.go @@ -13,9 +13,11 @@ import ( "github.com/RAMP-Protocol/protocol/sdk/go/helpers" ) -// WellKnownKeyResolver fetches a JWKS-shaped document from a URL (the publisher's -// /.well-known/ramp.json keys) and caches resolved keys with a TTL. The wire form -// is the RFC 7517 subset: +// WellKnownKeyResolver fetches a JWKS-shaped document from one fixed, +// operator-chosen URL and caches resolved keys with a TTL. The document is a +// plain RFC 7517 JWK Set, not a WellKnownManifest — a manifest carries no keys +// member, and a JWK Set carries no manifest version, so the endpoint face's +// version gate does not apply here. The wire form is the RFC 7517 subset: // // {"keys":[{"kid":"agent1.v1","kty":"OKP","crv":"Ed25519","x":""}]} // diff --git a/sdk/go/resolvers/wellknownkeyresolver_test.go b/sdk/go/resolvers/wellknownkeyresolver_test.go index 897d906a..96493770 100644 --- a/sdk/go/resolvers/wellknownkeyresolver_test.go +++ b/sdk/go/resolvers/wellknownkeyresolver_test.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "testing" @@ -100,3 +101,29 @@ func TestWellKnownKeyResolver_allowlist(t *testing.T) { t.Errorf("disallowed key err = %v, want ErrUnknownKey", err) } } + +// The manifest version gate is the endpoint face's alone. A key document is a +// plain JWK Set, not a manifest: it carries no WellKnownManifest.ver, and one that +// happens to carry a `ver` the endpoint face would refuse still resolves keys. +// This pins that decision — moving the gate into the shared fetch fails here. +func TestWellKnownKeyResolver_isNotGatedOnAManifestVersion(t *testing.T) { + pub, _, _ := ed25519.GenerateKey(nil) + x := base64.RawURLEncoding.EncodeToString(pub) + for _, tc := range []struct{ name, verMember string }{ + {"no ver", ""}, + {"unrecognised major", `"ver":"2.0",`}, + {"non-string ver", `"ver":1,`}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = fmt.Fprintf(w, `{%s"keys":[{"kid":"ex.v1","kty":"OKP","crv":"Ed25519","x":%q}]}`, tc.verMember, x) + })) + defer srv.Close() + r := resolvers.NewWellKnownKeyResolver(srv.URL, resolvers.WellKnownOptions{TTL: time.Hour}) + got, err := r.Resolve(context.Background(), "ex.v1") + if err != nil || !got.Equal(pub) { + t.Fatalf("resolve = %v, %v; a key document is not gated on a manifest version", got, err) + } + }) + } +} diff --git a/sdk/parity/symbol-map.json b/sdk/parity/symbol-map.json index c452a43c..561130d8 100644 --- a/sdk/parity/symbol-map.json +++ b/sdk/parity/symbol-map.json @@ -599,11 +599,26 @@ "python": "parse_money", "ts": "parseMoney" }, + "helpers.CheckWellKnownManifestVersion": { + "allowlist_reason": null, + "python": "manifest_version_refusal", + "ts": "manifestVersionRefusal" + }, + "helpers.ErrManifestVersionRefused": { + "allowlist_reason": null, + "python": "ManifestVersionRefusedError", + "ts": "ManifestVersionRefused" + }, "helpers.ProtocolVersion": { "allowlist_reason": null, "python": "ProtocolVersion", "ts": "ProtocolVersion" }, + "helpers.WellKnownManifestVersion": { + "allowlist_reason": null, + "python": "WellKnownManifestVersion", + "ts": "WellKnownManifestVersion" + }, "helpers.Reason": { "allowlist_reason": null, "python": "reason", @@ -849,6 +864,11 @@ "python": "KeyRevokedError", "ts": "KeyRevoked" }, + "resolvers.ErrManifestVersionRefused": { + "allowlist_reason": null, + "python": "ManifestVersionRefusedError", + "ts": "ManifestVersionRefused" + }, "resolvers.ErrNoEndpoint": { "allowlist_reason": null, "python": "NoEndpointError", diff --git a/sdk/python/ramp_sdk/__init__.py b/sdk/python/ramp_sdk/__init__.py index e59522a9..80f5aa06 100644 --- a/sdk/python/ramp_sdk/__init__.py +++ b/sdk/python/ramp_sdk/__init__.py @@ -138,6 +138,7 @@ EndpointRefusedError, KeyExpiredError, KeyRevokedError, + ManifestVersionRefusedError, NoEndpointError, ResolverError, UnknownKeyError, @@ -159,6 +160,8 @@ ProtocolVersion, RequestIDHeader, SignatureAgentHeader, + WellKnownManifestVersion, + manifest_version_refusal, ) __all__ = [ @@ -197,6 +200,7 @@ "MAX_REGISTRATION_SCHEMA_DEPTH", "MAX_REGISTRATION_SCHEMA_EVALUATIONS", "MAX_REGISTRATION_SCHEMA_REF_HOPS", + "ManifestVersionRefusedError", "Mode", "MultisigVerdict", "NoEndpointError", @@ -232,6 +236,7 @@ "Verifier", "WBAKeyResolver", "WBA_DIRECTORY_PATH", + "WellKnownManifestVersion", "WellKnownEndpointResolver", "WellKnownKeyResolver", "Window", @@ -261,6 +266,7 @@ "is_bare_domain", "is_bare_host", "is_safe_schema_pattern", + "manifest_version_refusal", "jcs_acceptance_payload", "jcs_request_acceptance_payload", "known_restriction_token", diff --git a/sdk/python/ramp_sdk/client/route.py b/sdk/python/ramp_sdk/client/route.py index eb69a577..1d991f31 100644 --- a/sdk/python/ramp_sdk/client/route.py +++ b/sdk/python/ramp_sdk/client/route.py @@ -18,7 +18,11 @@ from ramp_sdk._endpoint_rule import endpoint_refusal from ramp_sdk._hostref import _redact_userinfo as redact_userinfo from ramp_sdk.hosts import is_bare_host -from ramp_sdk.resolvers.errors import EndpointRefusedError, NoEndpointError +from ramp_sdk.resolvers.errors import ( + EndpointRefusedError, + ManifestVersionRefusedError, + NoEndpointError, +) from .errors import CallError, CallErrorKind, not_sent @@ -33,8 +37,9 @@ class EndpointResolver(Protocol): An implementation's FAILURE decides how a caller is told to react, so it is part of the contract rather than an implementation detail. A failure that is a VERDICT — the host - is unusable, the host is not allowed, the manifest advertises no endpoint, or it - advertises one that must not be used — MUST raise + is unusable, the host is not allowed, the manifest carries a version this reader does + not accept, advertises no endpoint, or advertises one that must not be used — MUST + raise :class:`~ramp_sdk.resolvers.errors.ManifestVersionRefusedError`, :class:`~ramp_sdk.resolvers.errors.NoEndpointError`, :class:`~ramp_sdk.resolvers.errors.EndpointRefusedError`, or the ``ValueError`` ``is_bare_host`` raises; those surface as ``NOT_SENT``, which tells the caller not to @@ -98,7 +103,10 @@ def vet_exchange_endpoint( # way — an injected one can. kind = ( CallErrorKind.NOT_SENT - if isinstance(exc, NoEndpointError | EndpointRefusedError | ValueError) + if isinstance( + exc, + ManifestVersionRefusedError | NoEndpointError | EndpointRefusedError | ValueError, + ) else CallErrorKind.UNREACHABLE ) raise CallError( diff --git a/sdk/python/ramp_sdk/resolvers/__init__.py b/sdk/python/ramp_sdk/resolvers/__init__.py index 08c61dab..72d26460 100644 --- a/sdk/python/ramp_sdk/resolvers/__init__.py +++ b/sdk/python/ramp_sdk/resolvers/__init__.py @@ -23,6 +23,7 @@ EndpointRefusedError, KeyExpiredError, KeyRevokedError, + ManifestVersionRefusedError, NoEndpointError, ResolverError, RevocationUnevaluatedError, @@ -48,6 +49,7 @@ "EndpointRefusedError", "KeyExpiredError", "KeyRevokedError", + "ManifestVersionRefusedError", "NoEndpointError", "ResolverError", "RevocationUnevaluatedError", diff --git a/sdk/python/ramp_sdk/resolvers/errors.py b/sdk/python/ramp_sdk/resolvers/errors.py index af1e02e5..8d6a00bf 100644 --- a/sdk/python/ramp_sdk/resolvers/errors.py +++ b/sdk/python/ramp_sdk/resolvers/errors.py @@ -64,3 +64,16 @@ class EndpointRefusedError(ResolverError): answered, and the answer is not usable. A caller that classifies retryability reads this as final rather than as something to try again in a moment. """ + + +class ManifestVersionRefusedError(ResolverError): + """A ``/.well-known/ramp.json`` was fetched and parsed but carries a + ``WellKnownManifest.ver`` this resolver does not accept: an unrecognised major + version, a value that is not ``MAJOR.MINOR``, or no version at all. The rule is + :func:`ramp_sdk.wire.manifest_version_refusal`. + + Like :class:`EndpointRefusedError` it is a VERDICT — final, not a transport + failure to retry — and it is never cached. The gate runs before any other + member of the document is read, for the reason stated once on + ``WellKnownManifest.ver`` in the proto. + """ diff --git a/sdk/python/ramp_sdk/resolvers/wellknown.py b/sdk/python/ramp_sdk/resolvers/wellknown.py index 958ef4d5..60d7537c 100644 --- a/sdk/python/ramp_sdk/resolvers/wellknown.py +++ b/sdk/python/ramp_sdk/resolvers/wellknown.py @@ -8,8 +8,11 @@ wrong-length ``x`` is skipped and one bad key never fails the whole set. ``WellKnownEndpointResolver`` is host-keyed: it fetches -``{scheme}://{host}/.well-known/ramp.json`` and reads the ``endpoint`` field -(ignoring unknown fields). Both port the Go oracle's lazy-fetch + TTL cache + +``{scheme}://{host}/.well-known/ramp.json``, refuses the document unless its +``ver`` carries a recognised major (``ManifestVersionRefusedError``), then reads +the ``endpoint`` field (ignoring unknown fields). The version gate is this +face's alone — the key face reads a JWKS document that carries no manifest +version. Both port the Go oracle's lazy-fetch + TTL cache + single-flight coalescing and its fail-closed taxonomy. """ @@ -29,8 +32,10 @@ from ramp_sdk.resolvers.errors import ( DirectoryUnavailableError, EndpointRefusedError, + ManifestVersionRefusedError, NoEndpointError, ) +from ramp_sdk.wire import manifest_version_refusal if TYPE_CHECKING: import httpx @@ -42,7 +47,6 @@ AllowFn = Callable[[str], bool] - def _now_utc() -> datetime: return datetime.now(UTC) @@ -162,13 +166,14 @@ def __init__( def resolve_endpoint(self, host: str) -> str: """Return the endpoint ``host`` advertises in its well-known manifest. - Four exits, and a caller classifying retryability needs all four. Three are + Five exits, and a caller classifying retryability needs all five. Four are verdicts and FINAL: ``ValueError`` when ``host`` is not a bare host, raised - before anything is fetched; ``NoEndpointError`` when the manifest is - reachable but advertises nothing; ``EndpointRefusedError`` when it advertises - an endpoint the rule will not hand back. The fourth, - ``DirectoryUnavailableError``, is a transport failure — unreachable or - undecodable — and is the only one worth retrying. + before anything is fetched; ``ManifestVersionRefusedError`` when the manifest + carries a ``ver`` this reader does not accept, or none at all; + ``NoEndpointError`` when the manifest is reachable but advertises nothing; + ``EndpointRefusedError`` when it advertises an endpoint the rule will not + hand back. The fifth, ``DirectoryUnavailableError``, is a transport failure + — unreachable or undecodable — and is the only one worth retrying. """ # Checked BEFORE the allow overlay and before the cache. The fetch URL is # built by concatenation, so a value carrying a path or a query would @@ -206,6 +211,11 @@ def _fetch_endpoint(self, host: str) -> str: doc = json.loads(body) except ValueError as exc: raise DirectoryUnavailableError(f"manifest decode {url}") from exc + # The document version gate runs before the endpoint is so much as looked + # at, and never caches: the next resolve fetches again. + refusal = manifest_version_refusal(doc.get("ver") if isinstance(doc, dict) else None) + if refusal is not None: + raise ManifestVersionRefusedError(refusal) endpoint = doc.get("endpoint") if isinstance(doc, dict) else None if not isinstance(endpoint, str) or endpoint == "": raise NoEndpointError(f"host={host}") diff --git a/sdk/python/ramp_sdk/wire.py b/sdk/python/ramp_sdk/wire.py index 0488838f..22f2fc85 100644 --- a/sdk/python/ramp_sdk/wire.py +++ b/sdk/python/ramp_sdk/wire.py @@ -3,7 +3,10 @@ Content-Type (ADR-020): application/proto for binary, application/json for canonical proto-JSON. The Go layer splits RequestIDHeader across helpers/constants.go and core/requestid.go; the single Python module exposes all -seven values once. Pinned to wire-constants-vectors.json. +eight values once. Pinned to wire-constants-vectors.json. + +Also home to the pure receive-side rule for ``WellKnownManifest.ver``, so the +constant and the check that reads it sit together. """ from __future__ import annotations @@ -18,9 +21,77 @@ ConnectProtocolVersion = "1" #: The RAMP protocol version stamped on the ``ver`` field of every RAMP message #: — NOT the Connect transport version above. Senders stamp it from here so a -#: protocol bump is a single edit; receivers treat ``ver`` as advisory. +#: protocol bump is a single edit; receivers treat ``ver`` as advisory. The +#: ``/.well-known/ramp.json`` document carries its own version in a separate +#: namespace, which this constant does NOT supply — that is +#: :data:`WellKnownManifestVersion`. ProtocolVersion = "1.0" +#: The version of the ``/.well-known/ramp.json`` DOCUMENT layout, stamped on +#: ``WellKnownManifest.ver`` by every party that serves one. A namespace separate +#: from :data:`ProtocolVersion` and never derived from it: a change to the +#: manifest layout bumps both numbers, a protocol change that leaves the manifest +#: untouched bumps only ``ProtocolVersion``. Both read ``"1.0"`` today because +#: neither has moved yet; neither is derived from the other. The receive-side +#: check a manifest reader applies is :func:`manifest_version_refusal`. +WellKnownManifestVersion = "1.0" #: Header correlating a request across services and the edge. RequestIDHeader = "X-Request-ID" #: Header carrying the signer's Web Bot Auth key-directory URL. SignatureAgentHeader = "Signature-Agent" + + +def _parse_major(ver: str) -> str | None: + """The MAJOR run of a MAJOR.MINOR string, or None when ``ver`` is not one. + + Both runs must be non-empty ASCII digits joined by exactly one dot; a missing + minor, a patch component, a leading ``v``, surrounding whitespace or a + non-digit is not a version this rule recognises. + """ + major, dot, minor = ver.partition(".") + if not dot or not major.isascii() or not major.isdigit(): + return None + if not minor.isascii() or not minor.isdigit(): + return None + return major + + +def manifest_version_refusal(ver: object) -> str | None: + """The receive-side rule for ``WellKnownManifest.ver``, as a pure verdict. + + Returns ``None`` when the document is accepted — its MAJOR equals the major of + :data:`WellKnownManifestVersion`, whatever the MINOR. Otherwise returns the + reason: an unrecognised major, a value that is not ``MAJOR.MINOR``, or an + absent member (``None``, or any non-string, is how a missing ``ver`` arrives + from ``json.loads``). Absent is refused because a document with no version is + one whose layout the reader cannot classify. Why a minor is accepted, and why + the gate runs before any other member is read and fails closed, is stated once + on ``WellKnownManifest.ver`` in the proto. + + The message names the value found so an operator can tell a version mismatch + from a network failure, clipped to :data:`_MAX_ECHOED_VER` characters: the body + is read up to 1 MiB and a refusal is never cached, so an unclipped echo would + let a hostile origin size every error. The three SDK languages pin this verdict + to a shared corpus. The exception a resolver raises for a refusal is + :class:`ramp_sdk.resolvers.errors.ManifestVersionRefusedError`. + """ + accept_major = _parse_major(WellKnownManifestVersion) + if accept_major is None: # pragma: no cover - a malformed constant is a bug + msg = f"WellKnownManifestVersion is not MAJOR.MINOR: {WellKnownManifestVersion!r}" + raise RuntimeError(msg) + if not isinstance(ver, str) or ver == "": + return f"ver is absent, accept major {accept_major}" + major = _parse_major(ver) + if major is None: + return f"ver {_echo_ver(ver)!r} is not MAJOR.MINOR, accept major {accept_major}" + if major != accept_major: + return f"ver {_echo_ver(ver)!r} has major {major}, accept major {accept_major}" + return None + + +#: How much of a refused ``ver`` an error message repeats. Go and TS clip at the +#: same length, so an operator reading three SDKs' logs sees the same prefix. +_MAX_ECHOED_VER = 64 + + +def _echo_ver(ver: str) -> str: + return ver if len(ver) <= _MAX_ECHOED_VER else ver[:_MAX_ECHOED_VER] + "..." diff --git a/sdk/python/tests/resolvers_harness.py b/sdk/python/tests/resolvers_harness.py index d1954dd2..0e3c6d1b 100644 --- a/sdk/python/tests/resolvers_harness.py +++ b/sdk/python/tests/resolvers_harness.py @@ -28,6 +28,8 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat +from ramp_sdk.wire import WellKnownManifestVersion + from ramp_sdk.b64 import b64url_nopad from ramp_sdk.thumbprint import thumbprint @@ -121,10 +123,15 @@ def jwks_key_doc_json(entries: list[dict[str, Any]]) -> str: return json.dumps({"keys": entries}) -def manifest_json(endpoint: str | None = None) -> str: - """Serialize a WellKnownManifest projection ({role, endpoint}); omit endpoint - to model a valid-but-inert manifest.""" +def manifest_json( + endpoint: str | None = None, *, ver: str | None = WellKnownManifestVersion +) -> str: + """Serialize a WellKnownManifest projection ({ver, role, endpoint}); omit + endpoint to model a valid-but-inert manifest, and pass ``ver=None`` to omit + the version member and model a manifest with no version at all.""" doc: dict[str, Any] = {"role": "ROLE_EXCHANGE"} + if ver is not None: + doc["ver"] = ver if endpoint is not None: doc["endpoint"] = endpoint return json.dumps(doc) diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index f53babf3..0890dd18 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -25,7 +25,7 @@ from ramp_sdk.client import BrokerClient from ramp_sdk.client.errors import CallError, CallErrorKind from ramp_sdk.core import Mode, StaticOfferKeyResolver, Verifier, sign_offer_jcs -from ramp_sdk.resolvers.errors import NoEndpointError +from ramp_sdk.resolvers.errors import ManifestVersionRefusedError, NoEndpointError from ramp_sdk.signing_transport import SigningTransport REQUESTER = {"id": "agent-1", "domain": "agent.test", "type": "REQUESTER_TYPE_AGENT"} @@ -570,6 +570,28 @@ def resolve_endpoint(self, host: str) -> str: # noqa: ARG002 assert flaky.value.kind is CallErrorKind.UNREACHABLE +@pytest.mark.parametrize("face", FACES, ids=_IDS) +def test_an_unaccepted_manifest_version_is_not_sent(face: Face) -> None: + """A manifest whose version the reader refuses is a VERDICT: the manifest was + fetched and parsed, and nothing about a retry changes the version served. + Mirrors the Go taxonomy test for the same refusal.""" + + class Refusing: + def resolve_endpoint(self, host: str) -> str: # noqa: ARG002 + raise ManifestVersionRefusedError("ver '2.0' has major 2, accept major 1") + + rec = Recorder({}) + with pytest.raises(CallError) as excinfo: + face.run( + face.client(_config(endpoint_resolver=Refusing()), rec).report_usage( + {"exchange": "issuer.test"} + ) + ) + assert excinfo.value.kind is CallErrorKind.NOT_SENT + assert isinstance(excinfo.value.__cause__, ManifestVersionRefusedError) + assert rec.seen == [] + + # --------------------------------------------------------------------------- # the broker face, outbound validation, fetch # --------------------------------------------------------------------------- diff --git a/sdk/python/tests/test_endpoint_refused_public_home.py b/sdk/python/tests/test_endpoint_refused_public_home.py index fd08bfe1..2034c475 100644 --- a/sdk/python/tests/test_endpoint_refused_public_home.py +++ b/sdk/python/tests/test_endpoint_refused_public_home.py @@ -27,6 +27,7 @@ def test_it_sits_beside_its_siblings() -> None: for sibling in ( "ResolverError", "NoEndpointError", + "ManifestVersionRefusedError", "DirectoryUnavailableError", "UnknownKeyError", "KeyRevokedError", diff --git a/sdk/python/tests/test_endpoint_vet_parity.py b/sdk/python/tests/test_endpoint_vet_parity.py index 61cd8bd3..e20095b9 100644 --- a/sdk/python/tests/test_endpoint_vet_parity.py +++ b/sdk/python/tests/test_endpoint_vet_parity.py @@ -26,6 +26,7 @@ from conftest import GO_RESOLVERS_TESTDATA, load_json from ramp_sdk.hosts import is_bare_host from ramp_sdk.resolvers import EndpointRefusedError, WellKnownEndpointResolver +from ramp_sdk.wire import WellKnownManifestVersion _DOC = load_json(GO_RESOLVERS_TESTDATA / "endpoint-vet-vectors.json") _REFUSED = [v for v in _DOC["endpoint_vet"] if v["refused"]] @@ -51,7 +52,10 @@ def _serving_manifest(endpoint: str) -> httpx.Client: """A manifest server that always answers with the endpoint under test.""" def handler(_request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={"role": "ROLE_EXCHANGE", "endpoint": endpoint}) + return httpx.Response( + 200, + json={"ver": WellKnownManifestVersion, "role": "ROLE_EXCHANGE", "endpoint": endpoint}, + ) return httpx.Client(transport=httpx.MockTransport(handler)) diff --git a/sdk/python/tests/test_hosts_credential_redaction.py b/sdk/python/tests/test_hosts_credential_redaction.py index 76d50040..f2be81c7 100644 --- a/sdk/python/tests/test_hosts_credential_redaction.py +++ b/sdk/python/tests/test_hosts_credential_redaction.py @@ -25,6 +25,7 @@ from ramp_sdk.hosts import host_anchored, host_of from ramp_sdk.resolvers import EndpointRefusedError, WellKnownEndpointResolver +from ramp_sdk.wire import WellKnownManifestVersion _SECRET = "s3cr3t" # noqa: S105 - a test fixture, not a credential @@ -58,7 +59,10 @@ def _serving(endpoint: str) -> httpx.Client: """A manifest server that always answers with the endpoint under test.""" def handler(_request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={"role": "ROLE_EXCHANGE", "endpoint": endpoint}) + return httpx.Response( + 200, + json={"ver": WellKnownManifestVersion, "role": "ROLE_EXCHANGE", "endpoint": endpoint}, + ) return httpx.Client(transport=httpx.MockTransport(handler)) @@ -96,9 +100,7 @@ def test_a_well_formed_credential_still_gets_the_rules_own_message() -> None: r = WellKnownEndpointResolver(http=_serving(f"https://u:{_SECRET}@exchange.example/v1")) with pytest.raises(EndpointRefusedError) as caught: r.resolve_endpoint("exchange.example") - assert str(caught.value) == ( - "host='exchange.example' advertises an endpoint carrying userinfo" - ) + assert str(caught.value) == ("host='exchange.example' advertises an endpoint carrying userinfo") # The SERVING HOST, not the advertised endpoint. Every case above feeds the diff --git a/sdk/python/tests/test_manifest_version_parity.py b/sdk/python/tests/test_manifest_version_parity.py new file mode 100644 index 00000000..4dee57ee --- /dev/null +++ b/sdk/python/tests/test_manifest_version_parity.py @@ -0,0 +1,111 @@ +"""Replay of the shared manifest-version corpus, driven two ways. + +The pure rule (``manifest_version_refusal``) is replayed row by row against the +Go emitter's verdicts. Then the same rows are driven through the REAL endpoint +resolver against a mock manifest server, so the test proves the resolver applies +the rule before it reads the endpoint — a refused document's endpoint is a +perfectly usable one, so the refusal can only be the version gate. + +``present`` is its own column because an absent member is not a string: the +resolver reads ``doc.get("ver")`` and gets ``None``, which is the path the +``absent`` row must exercise. Pinned to manifest-version-vectors.json. +""" + +from __future__ import annotations + +from datetime import timedelta + +import httpx +import pytest + +from conftest import GO_TESTDATA, load_json +from resolvers_harness import manifest_json + +from ramp_sdk.resolvers import ManifestVersionRefusedError, WellKnownEndpointResolver +from ramp_sdk.wire import WellKnownManifestVersion, manifest_version_refusal + +_VECTORS = load_json(GO_TESTDATA / "manifest-version-vectors.json")["manifest_version"] +_ACCEPTED = [v for v in _VECTORS if v["accepted"]] +_REFUSED = [v for v in _VECTORS if not v["accepted"]] +_ABSENT = [v for v in _VECTORS if not v["present"]] + +_HOST = "exchange.example" +_ENDPOINT = f"https://{_HOST}/ramp.v1.ExchangeService" + + +def _ver_of(vector: dict) -> str | None: + """The value the resolver sees: the string when present, None when absent.""" + return vector["ver"] if vector["present"] else None + + +def _serving(vector: dict) -> httpx.Client: + """A manifest server that answers with the vector's ver and a usable endpoint.""" + body = manifest_json(_ENDPOINT, ver=_ver_of(vector)) + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text=body) + + return httpx.Client(transport=httpx.MockTransport(handler)) + + +def _resolver(client: httpx.Client) -> WellKnownEndpointResolver: + return WellKnownEndpointResolver(http=client, ttl=timedelta(hours=1), scheme="https") + + +def test_corpus_partitions_are_nonempty() -> None: + assert len(_ACCEPTED) > 0 + assert len(_REFUSED) > 0 + assert len(_ABSENT) > 0 + + +def test_the_current_constant_is_in_the_accepted_partition() -> None: + assert any(v["ver"] == WellKnownManifestVersion and v["present"] for v in _ACCEPTED) + + +@pytest.mark.parametrize("vector", _VECTORS, ids=[v["name"] for v in _VECTORS]) +def test_pure_rule_matches_go_oracle(vector: dict) -> None: + refusal = manifest_version_refusal(_ver_of(vector)) + assert (refusal is None) == vector["accepted"], refusal + + +@pytest.mark.parametrize("vector", _ACCEPTED, ids=[v["name"] for v in _ACCEPTED]) +def test_resolver_accepts_and_reads_the_endpoint(vector: dict) -> None: + assert _resolver(_serving(vector)).resolve_endpoint(_HOST) == _ENDPOINT + + +@pytest.mark.parametrize("vector", _REFUSED, ids=[v["name"] for v in _REFUSED]) +def test_resolver_refuses_with_the_version_verdict(vector: dict) -> None: + # A refused document's endpoint is usable, so the refusal can only be the + # version gate — and ManifestVersionRefusedError is its own class, not a + # subclass of any other verdict, so the raises() is the whole assertion. + with pytest.raises(ManifestVersionRefusedError): + _resolver(_serving(vector)).resolve_endpoint(_HOST) + + +def test_version_gate_precedes_the_endpoint_gate() -> None: + """A wrong-version manifest that ALSO advertises nothing reports the version.""" + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text=manifest_json(None, ver="2.0")) + + r = _resolver(httpx.Client(transport=httpx.MockTransport(handler))) + with pytest.raises(ManifestVersionRefusedError): + r.resolve_endpoint(_HOST) + + +def test_a_refusal_is_not_cached() -> None: + """Once the origin serves an acceptable document the next resolve succeeds.""" + served: list[str] = ["2.0"] + hits = 0 + + def handler(_request: httpx.Request) -> httpx.Response: + nonlocal hits + hits += 1 + return httpx.Response(200, text=manifest_json(_ENDPOINT, ver=served[0])) + + r = _resolver(httpx.Client(transport=httpx.MockTransport(handler))) + with pytest.raises(ManifestVersionRefusedError): + r.resolve_endpoint(_HOST) + served[0] = WellKnownManifestVersion + assert r.resolve_endpoint(_HOST) == _ENDPOINT + assert hits == 2 diff --git a/sdk/python/tests/test_parity_corpora_nonempty.py b/sdk/python/tests/test_parity_corpora_nonempty.py index 31802942..df8d7333 100644 --- a/sdk/python/tests/test_parity_corpora_nonempty.py +++ b/sdk/python/tests/test_parity_corpora_nonempty.py @@ -89,6 +89,11 @@ def _whole(doc: Any) -> Any: (GO_TESTDATA / "host-rule-vectors.json", lambda d: d["host_of"], "host-of"), (GO_TESTDATA / "host-rule-vectors.json", lambda d: d["is_bare_host"], "is-bare-host"), (GO_TESTDATA / "host-rule-vectors.json", lambda d: d["host_anchored"], "host-anchored"), + ( + GO_TESTDATA / "manifest-version-vectors.json", + lambda d: d["manifest_version"], + "manifest-version", + ), ( GO_RESOLVERS_TESTDATA / "endpoint-vet-vectors.json", lambda d: d["endpoint_vet"], diff --git a/sdk/python/tests/test_resolvers_wellknown_integration.py b/sdk/python/tests/test_resolvers_wellknown_integration.py index 16a38c36..a4857230 100644 --- a/sdk/python/tests/test_resolvers_wellknown_integration.py +++ b/sdk/python/tests/test_resolvers_wellknown_integration.py @@ -16,6 +16,7 @@ from __future__ import annotations +import json import queue import threading from datetime import timedelta @@ -44,6 +45,7 @@ from ramp_sdk.resolvers import ( # type: ignore[import-not-found] DirectoryUnavailableError, EndpointRefusedError, + ManifestVersionRefusedError, NoEndpointError, WellKnownEndpointResolver, WellKnownKeyResolver, @@ -210,6 +212,38 @@ def test_endpoint_decode_failure_raises_directory_unavailable() -> None: origin.close() +def test_wellknown_key_is_not_gated_on_a_manifest_version() -> None: + """The manifest version gate is the endpoint face's alone. A key document is a + plain JWK Set, not a manifest: it carries no ``WellKnownManifest.ver``, and one + that happens to carry a ``ver`` the endpoint face would refuse still resolves + keys. Pins that decision — gating the key face fails here.""" + k = make_key() + for ver_member in ("", '"ver":"2.0",', '"ver":1,'): + origin = Origin() + entry = json.dumps(jwks_entry("ex.v1", k.x)) + origin.set_jwks(f'{{{ver_member}"keys":[{entry}]}}') + try: + r = WellKnownKeyResolver(f"{origin.url}/keys.json", ttl=HOUR) + assert r.resolve("ex.v1") == k.raw_pub, ver_member + finally: + origin.close() + + +def test_endpoint_non_string_version_is_refused_as_a_verdict() -> None: + """A ``ver`` that is not a JSON string is refused as absent — a VERDICT, the + answer Go and TS give — not a decode failure the client would retry.""" + for doc in ('{"ver":1,%s}', '{"ver":null,%s}', '{"ver":{"major":1},%s}'): + origin = Origin() + ep = f"http://{origin.host}/ramp.v1.ExchangeService" + origin.set_manifest(doc % f'"role":"ROLE_EXCHANGE","endpoint":"{ep}"') + try: + r = WellKnownEndpointResolver(scheme="http") + with pytest.raises(ManifestVersionRefusedError): + r.resolve_endpoint(origin.host) + finally: + origin.close() + + def test_endpoint_missing_field_raises_no_endpoint() -> None: origin = Origin() origin.set_manifest(manifest_json(None)) # valid manifest, no endpoint diff --git a/sdk/python/tests/test_wire_parity.py b/sdk/python/tests/test_wire_parity.py index c8a96c6c..aa7b7fd8 100644 --- a/sdk/python/tests/test_wire_parity.py +++ b/sdk/python/tests/test_wire_parity.py @@ -2,12 +2,12 @@ Mirrors the sdk/ts sibling sdk/ts/tests/wire.parity.test.ts. -``ramp_sdk.wire`` MUST expose the seven wire constants with the EXACT values the +``ramp_sdk.wire`` MUST expose the eight wire constants with the EXACT values the sdk/go oracle carries. The shared vectors at sdk/go/helpers/testdata/wire-constants-vectors.json carry {name, value}, referenced from the real Go exported constants (never hand-typed). The Go layer splits RequestIDHeader across helpers/constants.go and core/requestid.go; the -single Python wire module exposes all seven once. +single Python wire module exposes all eight once. RED now purely because ``ramp_sdk.wire`` does not exist yet. """ @@ -30,6 +30,7 @@ "ConnectProtocolVersionHeader": "ConnectProtocolVersionHeader", "ConnectProtocolVersion": "ConnectProtocolVersion", "ProtocolVersion": "ProtocolVersion", + "WellKnownManifestVersion": "WellKnownManifestVersion", "RequestIDHeader": "RequestIDHeader", "SignatureAgentHeader": "SignatureAgentHeader", } diff --git a/sdk/ts/client/route.ts b/sdk/ts/client/route.ts index 364dd8ae..7067d75b 100644 --- a/sdk/ts/client/route.ts +++ b/sdk/ts/client/route.ts @@ -11,7 +11,7 @@ // own manifest and then checked, rather than taken on trust or, worse, read from // configuration. -import { EndpointRefused, NoEndpoint } from "./../resolvers/errors.ts"; +import { EndpointRefused, ManifestVersionRefused, NoEndpoint } from "./../resolvers/errors.ts"; import { endpointRefusal } from "../src/endpoint-rule.ts"; import { isBareHost } from "../src/hosts.ts"; import { redactUserinfo } from "../src/host-ref.ts"; @@ -25,10 +25,11 @@ import { RampCallError, notSent } from "./errors.ts"; * * An implementation's FAILURE decides how a caller is told to react, so it is part of the * contract rather than an implementation detail. A failure that is a VERDICT — the host - * is unusable, the host is not allowed, the manifest advertises no endpoint, or it - * advertises one that must not be used — MUST throw the resolver tier's NoEndpoint or - * EndpointRefused, or the invalid-host error isBareHost raises; those surface as - * `not_sent`, which tells the caller not to retry. Anything else is read as a transport + * is unusable, the host is not allowed, the manifest carries a version this reader does + * not accept, advertises no endpoint, or advertises one that must not be used — MUST + * throw the resolver tier's ManifestVersionRefused, NoEndpoint or EndpointRefused, or + * the invalid-host error isBareHost raises; those surface as `not_sent`, which tells + * the caller not to retry. Anything else is read as a transport * failure and reported as `unreachable`, i.e. worth retrying. An implementation that * throws a bare error for a refusal therefore has its final answer retried indefinitely. */ @@ -137,7 +138,13 @@ export async function vetExchangeEndpoint( // is not a host will not become one on a later attempt. This module checks it before // resolving, so the SDK's own resolver never reaches here that way — an injected one can. function isVerdict(cause: unknown): boolean { - if (cause instanceof NoEndpoint || cause instanceof EndpointRefused) return true; + if ( + cause instanceof ManifestVersionRefused || + cause instanceof NoEndpoint || + cause instanceof EndpointRefused + ) { + return true; + } // The invalid-host refusal is matched on its wording rather than its type. The host // helpers are L1 and deliberately raise a bare error for it — a typed InvalidHost // would cross the helpers-versus-resolvers sentinel split the SDK documents — so the diff --git a/sdk/ts/resolvers/errors.ts b/sdk/ts/resolvers/errors.ts index 92a9b03e..d11a9af8 100644 --- a/sdk/ts/resolvers/errors.ts +++ b/sdk/ts/resolvers/errors.ts @@ -86,3 +86,19 @@ export class EndpointRefused extends ResolverError { this.name = "EndpointRefused"; } } + +/** A /.well-known/ramp.json was fetched and parsed but carries a + * WellKnownManifest.ver this resolver does not accept: an unrecognised major + * version, a value that is not MAJOR.MINOR, or no version at all. The rule is + * manifestVersionRefusal in src/wire.ts. + * + * Like EndpointRefused it is a VERDICT — final, not a transport failure to + * retry — and it is never cached. The gate runs before any other member of the + * document is read, for the reason stated once on WellKnownManifest.ver in the + * proto. */ +export class ManifestVersionRefused extends ResolverError { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "ManifestVersionRefused"; + } +} diff --git a/sdk/ts/resolvers/index.ts b/sdk/ts/resolvers/index.ts index 282e513c..b7c58eb1 100644 --- a/sdk/ts/resolvers/index.ts +++ b/sdk/ts/resolvers/index.ts @@ -11,6 +11,7 @@ export { EndpointRefused, KeyExpired, KeyRevoked, + ManifestVersionRefused, NoEndpoint, ResolverError, RevocationUnevaluated, diff --git a/sdk/ts/resolvers/wellknown.ts b/sdk/ts/resolvers/wellknown.ts index bb9be4dc..df29dffe 100644 --- a/sdk/ts/resolvers/wellknown.ts +++ b/sdk/ts/resolvers/wellknown.ts @@ -2,12 +2,16 @@ // kid-keyed) and WellKnownEndpointResolver (host-keyed ramp.json → endpoint). // Both port the Go oracle's lazy-fetch + TTL-cache + single-flight coalescing and // its fail-closed taxonomy: a fetch/decode failure throws DirectoryUnavailable; -// an unknown kid is `undefined`; a manifest with no endpoint throws NoEndpoint. +// an unknown kid is `undefined`; a manifest whose `ver` is not a recognised major +// (or is absent) throws ManifestVersionRefused before anything else is read — on +// the endpoint face only, the key face reads a JWKS document that carries no +// manifest version; a manifest with no endpoint throws NoEndpoint. import { endpointRefusal } from "../src/endpoint-rule.ts"; import { invalidHost } from "../src/host-ref.ts"; import { isBareHost } from "../src/hosts.ts"; -import { DirectoryUnavailable, EndpointRefused, NoEndpoint } from "./errors.ts"; +import { manifestVersionRefusal } from "../src/wire.ts"; +import { DirectoryUnavailable, EndpointRefused, ManifestVersionRefused, NoEndpoint } from "./errors.ts"; import { type FetchLike, defaultFetch, fetchStrict } from "./http.ts"; import { ed25519KeysFromJwks } from "./jwks.ts"; @@ -183,6 +187,12 @@ class EndpointResolverImpl implements WellKnownEndpointResolver { } catch (err) { throw new DirectoryUnavailable(`manifest decode ${url}`, { cause: err }); } + // The document version gate runs before the endpoint is so much as looked + // at, and never caches: the next resolve fetches again. + const versionRefusal = manifestVersionRefusal((doc as { ver?: unknown } | null)?.ver); + if (versionRefusal !== undefined) { + throw new ManifestVersionRefused(versionRefusal); + } const endpoint = (doc as { endpoint?: unknown }).endpoint; if (typeof endpoint !== "string" || endpoint === "") { throw new NoEndpoint(`host=${host}`); diff --git a/sdk/ts/src/wire.ts b/sdk/ts/src/wire.ts index 4f26a71a..097832bf 100644 --- a/sdk/ts/src/wire.ts +++ b/sdk/ts/src/wire.ts @@ -3,7 +3,10 @@ // Content-Type (ADR-020): application/proto for binary, application/json for // canonical proto-JSON. The Go layer splits RequestIDHeader across // helpers/constants.go and core/requestid.go; the single TS module exposes all -// seven values once. Pinned to wire-constants-vectors.json. +// eight values once. Pinned to wire-constants-vectors.json. +// +// Also home to the pure receive-side rule for WellKnownManifest.ver, so the +// constant and the check that reads it sit together. /** ContentTypeProto is the Content-Type for binary protobuf bodies. */ export const ContentTypeProto = "application/proto"; @@ -17,10 +20,82 @@ export const ConnectProtocolVersion = "1"; * ProtocolVersion is the RAMP protocol version stamped on the `ver` field of * every RAMP message — NOT the Connect transport version above. Senders stamp * it from here so a protocol bump is a single edit; receivers treat `ver` as - * advisory. + * advisory. The /.well-known/ramp.json document carries its own version in a + * separate namespace, which this constant does NOT supply — that is + * WellKnownManifestVersion. */ export const ProtocolVersion = "1.0"; +/** + * WellKnownManifestVersion is the version of the /.well-known/ramp.json DOCUMENT + * layout, stamped on WellKnownManifest.ver by every party that serves one. A + * namespace separate from ProtocolVersion and never derived from it: a change + * to the manifest layout bumps both numbers, a protocol change that leaves the + * manifest untouched bumps only ProtocolVersion. Both read "1.0" today because + * neither has moved yet; neither is derived from the other. The receive-side + * check a manifest reader applies is manifestVersionRefusal. + */ +export const WellKnownManifestVersion = "1.0"; /** RequestIDHeader correlates a request across services and the edge. */ export const RequestIDHeader = "X-Request-ID"; /** SignatureAgentHeader carries the signer's Web Bot Auth key-directory URL. */ export const SignatureAgentHeader = "Signature-Agent"; + +const ASCII_DIGITS = /^[0-9]+$/; + +/** The MAJOR run of a MAJOR.MINOR string, or undefined when `ver` is not one. + * Both runs must be non-empty ASCII digits joined by exactly one dot; a missing + * minor, a patch component, a leading "v", surrounding whitespace or a non-digit + * is not a version this rule recognises. */ +function parseMajor(ver: string): string | undefined { + const dot = ver.indexOf("."); + if (dot < 0) return undefined; + const major = ver.slice(0, dot); + const minor = ver.slice(dot + 1); + if (!ASCII_DIGITS.test(major) || !ASCII_DIGITS.test(minor)) return undefined; + return major; +} + +/** + * The receive-side rule for WellKnownManifest.ver, as a pure verdict. + * + * Returns undefined when the document is accepted — its MAJOR equals the major + * of WellKnownManifestVersion, whatever the MINOR. Otherwise returns the reason: + * an unrecognised major, a value that is not MAJOR.MINOR, or an absent member + * (undefined, null, or any non-string is how a missing `ver` arrives from + * JSON.parse). Absent is refused because a document with no version is one whose + * layout the reader cannot classify. Why a minor is accepted, and why the gate + * runs before any other member is read and fails closed, is stated once on + * WellKnownManifest.ver in the proto. + * + * The message names the value found so an operator can tell a version mismatch + * from a network failure, clipped to MAX_ECHOED_VER characters: the body is read + * up to 1 MiB and a refusal is never cached, so an unclipped echo would let a + * hostile origin size every error. The three SDK languages pin this verdict to a + * shared corpus. The error a resolver throws for a refusal is + * ManifestVersionRefused. + */ +export function manifestVersionRefusal(ver: unknown): string | undefined { + const acceptMajor = parseMajor(WellKnownManifestVersion); + if (acceptMajor === undefined) { + throw new Error(`WellKnownManifestVersion is not MAJOR.MINOR: ${WellKnownManifestVersion}`); + } + if (typeof ver !== "string" || ver === "") { + return `ver is absent, accept major ${acceptMajor}`; + } + const major = parseMajor(ver); + if (major === undefined) { + return `ver ${JSON.stringify(echoVer(ver))} is not MAJOR.MINOR, accept major ${acceptMajor}`; + } + if (major !== acceptMajor) { + return `ver ${JSON.stringify(echoVer(ver))} has major ${major}, accept major ${acceptMajor}`; + } + return undefined; +} + +/** How much of a refused `ver` an error message repeats. Go and Python clip at + * the same length, so an operator reading three SDKs' logs sees the same prefix. */ +const MAX_ECHOED_VER = 64; + +function echoVer(ver: string): string { + return ver.length <= MAX_ECHOED_VER ? ver : `${ver.slice(0, MAX_ECHOED_VER)}...`; +} diff --git a/sdk/ts/tests/client.test.ts b/sdk/ts/tests/client.test.ts index 01a2ded0..e464c7d5 100644 --- a/sdk/ts/tests/client.test.ts +++ b/sdk/ts/tests/client.test.ts @@ -588,6 +588,30 @@ describe("the offer-derived leg", () => { flakyClient.reportUsage({ exchange: "issuer.test" }), ).rejects.toMatchObject({ kind: "unreachable" }); }); + + it("does not send when the manifest's version is refused", async () => { + // A manifest whose version the reader refuses is a VERDICT: the manifest was + // fetched and parsed, and nothing about a retry changes the version served. + // Mirrors the Go taxonomy test for the same refusal. + const { ManifestVersionRefused } = await import("../resolvers/errors.ts"); + const { send, seen } = recordingSend({}); + const client = createClient("https://home.test", { + endpointResolver: { + resolveEndpoint: async () => { + throw new ManifestVersionRefused('ver "2.0" has major 2, accept major 1'); + }, + }, + guardedSend: send, + }); + const err = (await client + .reportUsage({ exchange: "issuer.test" }) + .catch((e: unknown) => e)) as RampCallError; + expect(err.kind).toBe("not_sent"); + // The route flattens the resolver's error into the cause message; the + // refusal's wording must survive so an operator can tell it from an outage. + expect((err.cause as Error).message).toContain("accept major 1"); + expect(seen).toHaveLength(0); + }); }); describe("the broker face", () => { diff --git a/sdk/ts/tests/endpoint-vet.parity.test.ts b/sdk/ts/tests/endpoint-vet.parity.test.ts index 7aad2388..a8462018 100644 --- a/sdk/ts/tests/endpoint-vet.parity.test.ts +++ b/sdk/ts/tests/endpoint-vet.parity.test.ts @@ -21,6 +21,7 @@ import { createWellKnownEndpointResolver, EndpointRefused } from "../resolvers/i import { isBareHost } from "../src/hosts.ts"; import type { FetchLike } from "../resolvers/http.ts"; import vectorsFile from "../../go/resolvers/testdata/endpoint-vet-vectors.json"; +import { WellKnownManifestVersion } from "../src/wire.ts"; type EndpointVetVector = { name: string; host: string; endpoint: string; refused: boolean }; type EndpointVetVectorsFile = { endpoint_vet: EndpointVetVector[] }; @@ -31,7 +32,8 @@ const doc = vectorsFile as EndpointVetVectorsFile; function servingManifest(endpoint: string): FetchLike { return async () => ({ status: 200, - text: async () => JSON.stringify({ role: "ROLE_EXCHANGE", endpoint }), + text: async () => + JSON.stringify({ ver: WellKnownManifestVersion, role: "ROLE_EXCHANGE", endpoint }), }); } diff --git a/sdk/ts/tests/hosts-credential-redaction.test.ts b/sdk/ts/tests/hosts-credential-redaction.test.ts index fdd4efcd..85cc8367 100644 --- a/sdk/ts/tests/hosts-credential-redaction.test.ts +++ b/sdk/ts/tests/hosts-credential-redaction.test.ts @@ -20,6 +20,7 @@ import { describe, expect, it } from "vitest"; import { hostAnchored, hostOf } from "../src/hosts.ts"; import { createWellKnownEndpointResolver } from "../resolvers/index.ts"; import type { FetchLike } from "../resolvers/http.ts"; +import { WellKnownManifestVersion } from "../src/wire.ts"; const SECRET = "s3cr3t"; @@ -50,7 +51,8 @@ const credentialRefs = [ const serving = (endpoint: string): FetchLike => async () => ({ status: 200, - text: async () => JSON.stringify({ role: "ROLE_EXCHANGE", endpoint }), + text: async () => + JSON.stringify({ ver: WellKnownManifestVersion, role: "ROLE_EXCHANGE", endpoint }), }); /** The message of whatever `call` throws, or undefined when it does not. */ diff --git a/sdk/ts/tests/manifest-version.parity.test.ts b/sdk/ts/tests/manifest-version.parity.test.ts new file mode 100644 index 00000000..cb446e1f --- /dev/null +++ b/sdk/ts/tests/manifest-version.parity.test.ts @@ -0,0 +1,101 @@ +// Replay of the shared manifest-version corpus, driven two ways. +// +// The pure rule (manifestVersionRefusal) is replayed row by row against the Go +// emitter's verdicts. Then the same rows are driven through the REAL endpoint +// resolver against a stub manifest server, so the test proves the resolver +// applies the rule before it reads the endpoint — a refused document's endpoint +// is a perfectly usable one, so the refusal can only be the version gate. +// +// `present` is its own column because an absent member is not a string: the +// resolver reads `doc.ver` and gets undefined, which is the path the `absent` +// row must exercise. Pinned to manifest-version-vectors.json. + +import { describe, expect, it } from "vitest"; +import vectorsFile from "../../go/helpers/testdata/manifest-version-vectors.json"; +import { ManifestVersionRefused, createWellKnownEndpointResolver } from "../resolvers/index.ts"; +import type { FetchLike } from "../resolvers/http.ts"; +import { WellKnownManifestVersion, manifestVersionRefusal } from "../src/wire.ts"; +import { manifestJson } from "./resolvers-harness.ts"; + +type ManifestVersionVector = { name: string; ver: string; present: boolean; accepted: boolean }; +type ManifestVersionVectorsFile = { manifest_version: ManifestVersionVector[] }; + +const doc = vectorsFile as ManifestVersionVectorsFile; +const vectors = doc.manifest_version; +const accepted = vectors.filter((v) => v.accepted); +const refused = vectors.filter((v) => !v.accepted); +const absent = vectors.filter((v) => !v.present); + +const HOST = "exchange.example"; +const ENDPOINT = `https://${HOST}/ramp.v1.ExchangeService`; + +/** The value the resolver sees: the string when present, undefined when absent. */ +const verOf = (v: ManifestVersionVector): string | undefined => (v.present ? v.ver : undefined); + +/** A manifest server that answers with a fixed body. */ +function serving(body: string): FetchLike { + return async () => ({ status: 200, text: async () => body }); +} + +/** The vector's manifest with a usable endpoint; an absent row omits `ver`. */ +const manifestFor = (v: ManifestVersionVector): string => manifestJson(ENDPOINT, verOf(v) ?? null); + +const resolverOver = (fetchFn: FetchLike) => + createWellKnownEndpointResolver({ fetch: fetchFn, ttlMs: 3_600_000, scheme: "https" }); + +describe("manifest-version corpus", () => { + it("every partition is non-empty", () => { + expect(accepted.length).toBeGreaterThan(0); + expect(refused.length).toBeGreaterThan(0); + expect(absent.length).toBeGreaterThan(0); + }); + + it("the current constant is in the accepted partition", () => { + expect(accepted.some((v) => v.present && v.ver === WellKnownManifestVersion)).toBe(true); + }); + + for (const v of vectors) { + it(`pure rule: ${v.name} → accepted=${v.accepted}`, () => { + const refusal = manifestVersionRefusal(verOf(v)); + expect(refusal === undefined, refusal).toBe(v.accepted); + }); + } + + for (const v of accepted) { + it(`resolver accepts and reads the endpoint: ${v.name}`, async () => { + await expect(resolverOver(serving(manifestFor(v))).resolveEndpoint(HOST)).resolves.toBe( + ENDPOINT, + ); + }); + } + + for (const v of refused) { + it(`resolver refuses with the version verdict: ${v.name}`, async () => { + // A refused document's endpoint is usable, so the refusal can only be the + // version gate — and ManifestVersionRefused is its own class, not a + // subclass of any other verdict, so the instance check is the whole assertion. + await expect(resolverOver(serving(manifestFor(v))).resolveEndpoint(HOST)).rejects.toBeInstanceOf( + ManifestVersionRefused, + ); + }); + } + + it("the version gate precedes the endpoint gate", async () => { + const r = resolverOver(serving(manifestJson(undefined, "2.0"))); + await expect(r.resolveEndpoint(HOST)).rejects.toBeInstanceOf(ManifestVersionRefused); + }); + + it("a refusal is not cached", async () => { + let served = "2.0"; + let hits = 0; + const fetchFn: FetchLike = async () => { + hits += 1; + return { status: 200, text: async () => manifestJson(ENDPOINT, served) }; + }; + const r = resolverOver(fetchFn); + await expect(r.resolveEndpoint(HOST)).rejects.toBeInstanceOf(ManifestVersionRefused); + served = WellKnownManifestVersion; + await expect(r.resolveEndpoint(HOST)).resolves.toBe(ENDPOINT); + expect(hits).toBe(2); + }); +}); diff --git a/sdk/ts/tests/resolvers-harness.ts b/sdk/ts/tests/resolvers-harness.ts index 61c2f06b..2488358f 100644 --- a/sdk/ts/tests/resolvers-harness.ts +++ b/sdk/ts/tests/resolvers-harness.ts @@ -17,6 +17,7 @@ import type { AddressInfo } from "node:net"; import { WBAFileSchema } from "../../../gen/ts/wire/schemas.ts"; import { decodeBase64Url } from "../src/base64url.ts"; import { thumbprint } from "../src/thumbprint.ts"; +import { WellKnownManifestVersion } from "../src/wire.ts"; // Well-known paths the origin serves. The WBA directory path is the fixed Web // Bot Auth path; the JWKS key doc and the endpoint manifest sit on distinct @@ -89,10 +90,12 @@ export function jwksEntry(kid: string, x: string): Record { return { kid, kty: "OKP", crv: "Ed25519", x }; } -/** Serialize a WellKnownManifest projection ({role, endpoint}); omit endpoint to - * model a valid-but-inert manifest. */ -export function manifestJson(endpoint?: string): string { +/** Serialize a WellKnownManifest projection ({ver, role, endpoint}); omit + * endpoint to model a valid-but-inert manifest, and pass `ver: null` to omit the + * version member and model a manifest with no version at all. */ +export function manifestJson(endpoint?: string, ver: string | null = WellKnownManifestVersion): string { const doc: Record = { role: "ROLE_EXCHANGE" }; + if (ver !== null) doc.ver = ver; if (endpoint !== undefined) doc.endpoint = endpoint; return JSON.stringify(doc); } diff --git a/sdk/ts/tests/resolvers-wellknown.integration.test.ts b/sdk/ts/tests/resolvers-wellknown.integration.test.ts index 143c42b3..77a8158e 100644 --- a/sdk/ts/tests/resolvers-wellknown.integration.test.ts +++ b/sdk/ts/tests/resolvers-wellknown.integration.test.ts @@ -30,6 +30,7 @@ import { import { DirectoryUnavailable, EndpointRefused, + ManifestVersionRefused, NoEndpoint, createStaticKeyResolver, createWellKnownEndpointResolver, @@ -128,6 +129,22 @@ describe("createWellKnownKeyResolver", () => { // JWKS extraction is skip-not-fail — one malformed key must not kill the // whole publisher key set; survivors still resolve, bad entries become // unknown (undefined). + it("is not gated on a manifest version", async () => { + // The manifest version gate is the endpoint face's alone. A key document is + // a plain JWK Set, not a manifest: it carries no WellKnownManifest.ver, and + // one that happens to carry a `ver` the endpoint face would refuse still + // resolves keys. Pins that decision — gating the key face fails here. + const k = await makeKey(); + for (const verMember of ["", '"ver":"2.0",', '"ver":1,']) { + origin = await startOrigin(); + origin.setJwks(`{${verMember}"keys":[${JSON.stringify(jwksEntry("ex.v1", k.x))}]}`); + const r = createWellKnownKeyResolver(`${origin.url}/keys.json`, { ttlMs: HOUR_MS, fetch: loopbackFetch }); + expect(await r.resolve("ex.v1"), verMember).toEqual(k.rawPub); + await origin.close(); + origin = undefined; + } + }); + it("skips malformed JWKS entries (missing kid / non-Ed25519 / bad-length x) and resolves survivors", async () => { const good = await makeKey(); const nonEd = await makeKey(); @@ -236,6 +253,22 @@ describe("createWellKnownEndpointResolver", () => { } }); + it("refuses a non-string ver as a verdict, not a decode failure", async () => { + // A `ver` that is not a JSON string is refused as absent — a VERDICT, the + // answer Go and Python give — not a DirectoryUnavailable the client would retry. + for (const ver of ["1", "null", '{"major":1}']) { + const origin = await startOrigin(); + const ep = `http://${origin.host}/ramp.v1.ExchangeService`; + origin.setManifest(`{"ver":${ver},"role":"ROLE_EXCHANGE","endpoint":"${ep}"}`); + try { + const r = createWellKnownEndpointResolver({ scheme: "http", fetch: loopbackFetch }); + await expect(r.resolveEndpoint(origin.host), ver).rejects.toBeInstanceOf(ManifestVersionRefused); + } finally { + await origin.close(); + } + } + }); + it("throws NoEndpoint when a valid manifest omits the endpoint field", async () => { const origin = await startOrigin(); origin.setManifest(manifestJson(undefined)); // valid manifest, no endpoint diff --git a/sdk/ts/tests/wire.parity.test.ts b/sdk/ts/tests/wire.parity.test.ts index 31fc2f90..eea187ab 100644 --- a/sdk/ts/tests/wire.parity.test.ts +++ b/sdk/ts/tests/wire.parity.test.ts @@ -1,12 +1,12 @@ -// Wire-constants parity (TypeScript side): wire.ts exposes the seven wire +// Wire-constants parity (TypeScript side): wire.ts exposes the eight wire // constants with the exact values the Go oracle carries. // -// sdk/ts `wire.ts` MUST expose the seven wire constants with the EXACT values the +// sdk/ts `wire.ts` MUST expose the eight wire constants with the EXACT values the // sdk/go oracle carries. The shared vectors at // sdk/go/helpers/testdata/wire-constants-vectors.json carry {name, value}, // referenced from the real Go exported constants (never hand-typed). The Go // layer splits RequestIDHeader across helpers/constants.go and core/requestid.go; -// the single TS wire module exposes all seven once. +// the single TS wire module exposes all eight once. // // RED now purely because sdk/ts/src/wire.ts does not exist yet. import { describe, it, expect } from "vitest"; @@ -28,6 +28,7 @@ const exportFor: Record = { ConnectProtocolVersionHeader: "ConnectProtocolVersionHeader", ConnectProtocolVersion: "ConnectProtocolVersion", ProtocolVersion: "ProtocolVersion", + WellKnownManifestVersion: "WellKnownManifestVersion", RequestIDHeader: "RequestIDHeader", SignatureAgentHeader: "SignatureAgentHeader", }; diff --git a/website/src/content/docs/protocol/exchange-manifest.mdx b/website/src/content/docs/protocol/exchange-manifest.mdx index 4df0be0e..4a6d3f3c 100644 --- a/website/src/content/docs/protocol/exchange-manifest.mdx +++ b/website/src/content/docs/protocol/exchange-manifest.mdx @@ -103,7 +103,7 @@ The Exchange's offer-signing keys are **not** carried in `ramp.json`. They live | Field | Type | Required | Description | |---|---|---|---| -| `ver` | string | Yes | Schema version of this manifest **document** — a namespace separate from the RPC envelope `ver`, not coupled to it. MUST equal `"1.0"`; consumers reject unrecognised major versions | +| `ver` | string | Yes | Version of this manifest **document**'s layout — `"1.0"`, stamped from the SDK's `WellKnownManifestVersion`, a namespace separate from the RPC envelope `ver` and never derived from it. A manifest layout change bumps both numbers; a protocol change that leaves the manifest untouched bumps only the envelope's. Consumers read it before any other member: a recognised major is accepted whatever the minor, an unrecognised major, a malformed value, or an absent `ver` is refused | | `role` | string | Yes | `ROLE_EXCHANGE` for an Exchange manifest | | `domain` | string | Yes | Canonical domain of this Exchange | | `name` | string | No | Human-readable Exchange name | diff --git a/website/src/content/docs/reference/changelog.mdx b/website/src/content/docs/reference/changelog.mdx index 9de8a564..c34be853 100644 --- a/website/src/content/docs/reference/changelog.mdx +++ b/website/src/content/docs/reference/changelog.mdx @@ -8,6 +8,37 @@ and protocol history, see [`proto/CHANGELOG.md`](https://github.com/RAMP-Protoco ## Unreleased +**SDK endpoint resolvers enforce `WellKnownManifest.ver`, and the manifest version +gets its own constant (SDK behaviour change; no wire change).** The +`/.well-known/ramp.json` document's `ver` has stated a consumer-side rule since the +manifest was unified, and no SDK applied it: the Go, Python and TypeScript endpoint +resolvers decoded the endpoint and dropped `ver` unread, so a manifest at any +version, or with no version at all, was accepted. The rule is now stated in full and +enforced. A consumer reads `ver` before any other member of the document, accepts a +recognised **major** version whatever the minor (a minor revision of the manifest is +additive, and a reader ignores members it does not know), and refuses an +unrecognised major, a value that is not `MAJOR.MINOR`, or an absent `ver` (a `ver` +that is not a JSON string is refused as absent, in all three languages) — the +document sits at a fixed, unversioned path and is read before any signature is +checked, so a layout the reader cannot classify must not supply the endpoint a +signed call is then sent to. The refusal is a verdict, surfaced as +`resolvers.ErrManifestVersionRefused` / `ManifestVersionRefusedError` / +`ManifestVersionRefused`, classified `not sent` by the client tier like the other +resolver verdicts, and never cached. It applies to the endpoint face only: the +well-known key resolver reads a JWK Set document that carries no manifest version, +and a test in each language pins that a key document carrying a `ver` still resolves. +The value has one owner, `helpers.WellKnownManifestVersion` (`WellKnownManifestVersion` +in Python and TypeScript), pinned to the wire-constants corpus beside +`ProtocolVersion` and deliberately not derived from it — the two are separate +namespaces, and the coupling runs one way: a manifest layout change bumps both +numbers, a protocol change that leaves the manifest untouched bumps only +`ProtocolVersion`. A new shared corpus, `manifest-version-vectors.json`, pins the +verdict across the three languages, and a conformance guard holds the proto +comment to every clause the corpus exercises. **Compatibility:** a manifest served +without `ver` was accepted before this change and is refused after it. The field +has been required by the wire shape since the unified manifest was introduced, so +a conforming producer already stamps it. + **`TransactionRequest.agent_request_acceptance` adds an agent-signed proof of the complete ordered request set (additive wire change).** The agent signs the ordered list of `(offer signature, exchange)` references it committed to, plus diff --git a/website/src/content/docs/reference/proto-ramp.mdx b/website/src/content/docs/reference/proto-ramp.mdx index 85c99a6e..8af5aa4e 100644 --- a/website/src/content/docs/reference/proto-ramp.mdx +++ b/website/src/content/docs/reference/proto-ramp.mdx @@ -7,7 +7,7 @@ description: "RAMP v1.0 protobuf reference -- ExchangeService, CatalogService, a Source: [`proto/ramp/v1/ramp.proto`](https://github.com/RAMP-Protocol/protocol/blob/main/proto/ramp/v1/ramp.proto) :::note[Envelope and correlation] -Every RPC request/response carries `ver` (field 1, RAMP protocol version). Its value is `"1.0"`, and senders MUST stamp it — from the SDK's `ProtocolVersion` constant rather than a literal, so a protocol bump is a single edit. On receive `ver` is **advisory**: it is not an authenticity or authorization control and MUST NOT be used as one, a receiver is not required to check it, and one that does check it MAY reject an unrecognised **major** version but MUST NOT reject an unrecognised **minor** version. Version negotiation, where it is needed, happens out of band — an Exchange publishes `WellKnownManifest.protocol_versions_supported` and a Broker filters on it before sending — which is why the in-band field need not be a rejection gate. It carries no protovalidate rule. `WellKnownManifest.ver` is a separate namespace: it versions the `/.well-known/ramp.json` document schema, is stated MUST-equal, and is not coupled to the envelope value. The external-contract messages (domain verification, catalog push) also carry `ext` (field 15) and `ext_critical` (field 90, COSE `crit`, RFC 9052). +Every RPC request/response carries `ver` (field 1, RAMP protocol version). Its value is `"1.0"`, and senders MUST stamp it — from the SDK's `ProtocolVersion` constant rather than a literal, so a protocol bump is a single edit. On receive `ver` is **advisory**: it is not an authenticity or authorization control and MUST NOT be used as one, a receiver is not required to check it, and one that does check it MAY reject an unrecognised **major** version but MUST NOT reject an unrecognised **minor** version. Version negotiation, where it is needed, happens out of band — an Exchange publishes `WellKnownManifest.protocol_versions_supported` and a Broker filters on it before sending — which is why the in-band field need not be a rejection gate. It carries no protovalidate rule. `WellKnownManifest.ver` is a separate namespace: it versions the `/.well-known/ramp.json` document's layout, is stamped from the SDK's `WellKnownManifestVersion` rather than `ProtocolVersion`, and is gated by consumers on its major version — a recognised major is accepted whatever the minor, and an unrecognised major, a malformed value or an absent `ver` is refused. The coupling runs one way: a manifest layout change bumps both numbers, a protocol change that leaves the manifest untouched bumps only the envelope's. The external-contract messages (domain verification, catalog push) also carry `ext` (field 15) and `ext_critical` (field 90, COSE `crit`, RFC 9052). Request correlation is **not** carried in the proto body. It rides on the `X-Request-ID` HTTP header, and cross-system tracing uses W3C Trace Context (`traceparent` / `tracestate`). The former `request_id` fields have been removed from every message. ::: diff --git a/website/src/content/docs/reference/ramp-json-example.mdx b/website/src/content/docs/reference/ramp-json-example.mdx index 677ea5d8..8e0d3892 100644 --- a/website/src/content/docs/reference/ramp-json-example.mdx +++ b/website/src/content/docs/reference/ramp-json-example.mdx @@ -77,7 +77,7 @@ Signing keys are **not** carried in `ramp.json`. Each participant publishes its | Field | Type | Required | Description | |---|---|---|---| -| `ver` | string | Yes | Schema version of this manifest **document** — a namespace separate from the RPC envelope `ver`, not coupled to it. MUST equal `"1.0"`; consumers reject unrecognised major versions | +| `ver` | string | Yes | Version of this manifest **document**'s layout — `"1.0"`, stamped from the SDK's `WellKnownManifestVersion`, a namespace separate from the RPC envelope `ver` and never derived from it. A manifest layout change bumps both numbers; a protocol change that leaves the manifest untouched bumps only the envelope's. Consumers read it before any other member: a recognised major is accepted whatever the minor, an unrecognised major, a malformed value, or an absent `ver` is refused | | `role` | string | Yes | Participant role — `ROLE_PUBLISHER` for a provider's manifest | | `domain` | string | Yes | Canonical domain serving this manifest | | `contact` | string | No | Contact email for licensing inquiries |