@fora-protocol/sdk 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +37 -0
  3. package/gen/ts/vocab/functiontokens.js +78 -0
  4. package/gen/ts/vocab/functiontokens.ts +84 -0
  5. package/gen/ts/vocab/geographytokens.js +30 -0
  6. package/gen/ts/vocab/geographytokens.ts +37 -0
  7. package/gen/ts/vocab/pricingunits.js +56 -0
  8. package/gen/ts/vocab/pricingunits.ts +63 -0
  9. package/gen/ts/vocab/quotametrics.js +40 -0
  10. package/gen/ts/vocab/quotametrics.ts +47 -0
  11. package/gen/ts/vocab/usertypes.js +40 -0
  12. package/gen/ts/vocab/usertypes.ts +46 -0
  13. package/gen/ts/wire/base.js +245 -0
  14. package/gen/ts/wire/base.ts +255 -0
  15. package/gen/ts/wire/names.js +41 -0
  16. package/gen/ts/wire/names.ts +42 -0
  17. package/gen/ts/wire/schemas.js +111 -0
  18. package/gen/ts/wire/schemas.ts +216 -0
  19. package/package.json +164 -0
  20. package/sdk/ts/client/content.d.ts +74 -0
  21. package/sdk/ts/client/content.js +466 -0
  22. package/sdk/ts/client/errors.d.ts +79 -0
  23. package/sdk/ts/client/errors.js +148 -0
  24. package/sdk/ts/client/index.d.ts +241 -0
  25. package/sdk/ts/client/index.js +740 -0
  26. package/sdk/ts/client/route.d.ts +41 -0
  27. package/sdk/ts/client/route.js +107 -0
  28. package/sdk/ts/client/send.d.ts +64 -0
  29. package/sdk/ts/client/send.js +142 -0
  30. package/sdk/ts/client/transport.d.ts +187 -0
  31. package/sdk/ts/client/transport.js +381 -0
  32. package/sdk/ts/core/multisig-parse.d.ts +47 -0
  33. package/sdk/ts/core/multisig-parse.js +234 -0
  34. package/sdk/ts/core/sign-request.d.ts +83 -0
  35. package/sdk/ts/core/sign-request.js +146 -0
  36. package/sdk/ts/core/sign.d.ts +31 -0
  37. package/sdk/ts/core/sign.js +82 -0
  38. package/sdk/ts/core/signing-transport.d.ts +75 -0
  39. package/sdk/ts/core/signing-transport.js +172 -0
  40. package/sdk/ts/core/verifier.d.ts +177 -0
  41. package/sdk/ts/core/verifier.js +269 -0
  42. package/sdk/ts/core/verify-multisig-request.d.ts +72 -0
  43. package/sdk/ts/core/verify-multisig-request.js +112 -0
  44. package/sdk/ts/core/verify-request.d.ts +181 -0
  45. package/sdk/ts/core/verify-request.js +284 -0
  46. package/sdk/ts/core/window.d.ts +22 -0
  47. package/sdk/ts/core/window.js +43 -0
  48. package/sdk/ts/core/wire-canon.d.ts +20 -0
  49. package/sdk/ts/core/wire-canon.js +174 -0
  50. package/sdk/ts/hono/middleware.d.ts +38 -0
  51. package/sdk/ts/hono/middleware.js +64 -0
  52. package/sdk/ts/resolvers/errors.d.ts +130 -0
  53. package/sdk/ts/resolvers/errors.js +150 -0
  54. package/sdk/ts/resolvers/http.d.ts +98 -0
  55. package/sdk/ts/resolvers/http.js +274 -0
  56. package/sdk/ts/resolvers/index.d.ts +9 -0
  57. package/sdk/ts/resolvers/index.js +15 -0
  58. package/sdk/ts/resolvers/jwks.d.ts +2 -0
  59. package/sdk/ts/resolvers/jwks.js +43 -0
  60. package/sdk/ts/resolvers/offer-key-cache.d.ts +68 -0
  61. package/sdk/ts/resolvers/offer-key-cache.js +152 -0
  62. package/sdk/ts/resolvers/registration-requirements.d.ts +47 -0
  63. package/sdk/ts/resolvers/registration-requirements.js +220 -0
  64. package/sdk/ts/resolvers/ssrf.d.ts +28 -0
  65. package/sdk/ts/resolvers/ssrf.js +235 -0
  66. package/sdk/ts/resolvers/static.d.ts +7 -0
  67. package/sdk/ts/resolvers/static.js +16 -0
  68. package/sdk/ts/resolvers/wba.d.ts +134 -0
  69. package/sdk/ts/resolvers/wba.js +506 -0
  70. package/sdk/ts/resolvers/wellknown.d.ts +28 -0
  71. package/sdk/ts/resolvers/wellknown.js +169 -0
  72. package/sdk/ts/src/acceptance.d.ts +46 -0
  73. package/sdk/ts/src/acceptance.js +162 -0
  74. package/sdk/ts/src/base64url.d.ts +19 -0
  75. package/sdk/ts/src/base64url.js +55 -0
  76. package/sdk/ts/src/crossfield.d.ts +28 -0
  77. package/sdk/ts/src/crossfield.js +215 -0
  78. package/sdk/ts/src/endpoint-rule.d.ts +30 -0
  79. package/sdk/ts/src/endpoint-rule.js +63 -0
  80. package/sdk/ts/src/errordetail.d.ts +115 -0
  81. package/sdk/ts/src/errordetail.js +256 -0
  82. package/sdk/ts/src/hashurl.d.ts +5 -0
  83. package/sdk/ts/src/hashurl.js +15 -0
  84. package/sdk/ts/src/host-ref.d.ts +70 -0
  85. package/sdk/ts/src/host-ref.js +320 -0
  86. package/sdk/ts/src/hosts.d.ts +138 -0
  87. package/sdk/ts/src/hosts.js +214 -0
  88. package/sdk/ts/src/idempotency.d.ts +11 -0
  89. package/sdk/ts/src/idempotency.js +27 -0
  90. package/sdk/ts/src/jsondepth.d.ts +27 -0
  91. package/sdk/ts/src/jsondepth.js +77 -0
  92. package/sdk/ts/src/licenseterm.d.ts +113 -0
  93. package/sdk/ts/src/licenseterm.js +433 -0
  94. package/sdk/ts/src/money.d.ts +21 -0
  95. package/sdk/ts/src/money.js +60 -0
  96. package/sdk/ts/src/offer-sign.d.ts +11 -0
  97. package/sdk/ts/src/offer-sign.js +31 -0
  98. package/sdk/ts/src/opaque-url.d.ts +3 -0
  99. package/sdk/ts/src/opaque-url.js +20 -0
  100. package/sdk/ts/src/pop.d.ts +51 -0
  101. package/sdk/ts/src/pop.js +166 -0
  102. package/sdk/ts/src/regschema.d.ts +229 -0
  103. package/sdk/ts/src/regschema.js +1315 -0
  104. package/sdk/ts/src/scopes.d.ts +20 -0
  105. package/sdk/ts/src/scopes.js +45 -0
  106. package/sdk/ts/src/signurl.d.ts +23 -0
  107. package/sdk/ts/src/signurl.js +107 -0
  108. package/sdk/ts/src/thumbprint.d.ts +14 -0
  109. package/sdk/ts/src/thumbprint.js +24 -0
  110. package/sdk/ts/src/verify.d.ts +49 -0
  111. package/sdk/ts/src/verify.js +109 -0
  112. package/sdk/ts/src/wire-names.d.ts +1 -0
  113. package/sdk/ts/src/wire-names.js +16 -0
  114. package/sdk/ts/src/wire.d.ts +68 -0
  115. package/sdk/ts/src/wire.js +114 -0
@@ -0,0 +1,177 @@
1
+ export declare const OFFER_SIGNATURE_ALGORITHM = "EdDSA";
2
+ export type Mode = "strict" | "off";
3
+ /**
4
+ * OfferKeyResolver resolves an exchange identity to its raw 32-byte Ed25519
5
+ * offer-signing public key. Injected — the core owns no key state. Returns
6
+ * undefined when no key is known for the exchange (→ fail-closed reject under
7
+ * strict).
8
+ */
9
+ export interface OfferKeyResolver {
10
+ resolve(exchange: string): Promise<Uint8Array<ArrayBuffer> | undefined>;
11
+ }
12
+ /**
13
+ * Ed25519 verify primitive: (publicKey, signature, message) -> valid?. Injected
14
+ * so a non-WebCrypto runtime can supply its own without changing the byte
15
+ * contract. Defaults to WebCrypto crypto.subtle. Byte params are
16
+ * `Uint8Array<ArrayBuffer>` (never SharedArrayBuffer-backed) so the default can
17
+ * hand them to WebCrypto's BufferSource without casts.
18
+ */
19
+ export type Ed25519Verify = (publicKey: Uint8Array<ArrayBuffer>, signature: Uint8Array<ArrayBuffer>, message: Uint8Array<ArrayBuffer>) => Promise<boolean>;
20
+ declare const VERIFIED_BRAND: unique symbol;
21
+ /**
22
+ * VerifiedOffer wraps an offer that passed the core's fail-closed verification
23
+ * (genuine exchange signature, not expired) — OR was surfaced under mode "off" /
24
+ * RejectedOffer.unsafe(). The brand is module-private, so `execute(offer:
25
+ * VerifiedOffer)` accepts only offers the core minted: an app cannot construct one
26
+ * with an object literal (tsc rejects the missing brand). Reading the wrapped
27
+ * offer is fine; only MINTING one is gated.
28
+ */
29
+ export interface VerifiedOffer {
30
+ readonly [VERIFIED_BRAND]: true;
31
+ readonly offer: unknown;
32
+ }
33
+ /**
34
+ * RejectedOffer is an offer the Verifier could NOT accept: the wrapped offer plus
35
+ * the reason it failed. It is VISIBLE (the app learns which offers failed and why)
36
+ * but not directly executable — acting on it requires the explicit .unsafe()
37
+ * escape.
38
+ */
39
+ export interface RejectedOffer {
40
+ readonly offer: unknown;
41
+ readonly reason: string;
42
+ /** The single, audit-visible escape hatch: mint an executable VerifiedOffer. */
43
+ unsafe(): VerifiedOffer;
44
+ }
45
+ /**
46
+ * Result is the fail-closed {verified, rejected} contract every discover/resolve
47
+ * call returns. Neither list is silently dropped. Canonical cross-language shape
48
+ * (mirror sdk/go core.Result).
49
+ */
50
+ export interface Result {
51
+ verified: VerifiedOffer[];
52
+ rejected: RejectedOffer[];
53
+ }
54
+ /**
55
+ * OfferGroupResult is one requested URI's answer (mirror sdk/go core.OfferGroupResult).
56
+ *
57
+ * `absenceReason` is undefined when the responder stated no reason — a legitimate
58
+ * answer, not an omission: where the existence of a resource must itself stay hidden, a
59
+ * responder MAY withhold the reason rather than confirm the resource exists. The Go
60
+ * oracle keeps a pointer to tell that apart from the unspecified enum value; here the
61
+ * generated enums carry no `*_UNSPECIFIED` member at all, so a present value is always a
62
+ * real reason and undefined is the only way to say nothing.
63
+ */
64
+ export interface OfferGroupResult {
65
+ readonly uri: string;
66
+ readonly result: Result;
67
+ readonly absenceReason?: string;
68
+ readonly discoveryMethod?: string;
69
+ /**
70
+ * The restriction axes that drove a convenience pre-filter, when the absence reason
71
+ * is a restriction filter. Advisory diagnostics, not an enforcement verdict — but
72
+ * they tell an agent which axis to vary on a retry.
73
+ */
74
+ readonly restrictionFilters: string[];
75
+ }
76
+ /** DiscoveryResult is what discover and resolve return (mirror sdk/go core.DiscoveryResult). */
77
+ export interface DiscoveryResult {
78
+ /** One entry per requested URI, in the order the responder returned. */
79
+ readonly groups: OfferGroupResult[];
80
+ /**
81
+ * Why the CALL as a whole yielded nothing. Set only on that path — when any group
82
+ * carries offers it stays undefined, and the per-URI causes ride on each group
83
+ * instead. Only a Broker resolve can set it: an Exchange's own discovery response has
84
+ * no whole-call reason field.
85
+ */
86
+ readonly absenceReason?: string;
87
+ /**
88
+ * Canonical domain of the responding Exchange. Empty from a Broker resolve, whose
89
+ * response names no single Exchange — each offer carries its own issuing domain.
90
+ */
91
+ readonly exchange: string;
92
+ /**
93
+ * The caller's rate-limit standing, when the responder reported it, so an agent can
94
+ * throttle before a fan-out meets a hard limit. Undefined from a Broker resolve.
95
+ */
96
+ readonly rateLimit?: Record<string, unknown>;
97
+ }
98
+ /**
99
+ * Every verified offer across all groups, for a caller that does not care which URI an
100
+ * offer answers.
101
+ *
102
+ * A free function rather than a method, unlike the Go and Python faces: DiscoveryResult
103
+ * here is a plain record — what the client hands back after parsing — and putting
104
+ * methods on the interface would force every hand-built literal to supply them. Same
105
+ * contract, spelled the way this language spells it.
106
+ *
107
+ * A convenience over `groups`, never a substitute. A URI that was REFUSED contributes
108
+ * nothing here — it has no offer to contribute — so a caller reading only this cannot
109
+ * tell a refusal from a resource it never asked about. That is exactly the information
110
+ * `groups` exists to keep.
111
+ */
112
+ export declare function verifiedOffers(result: DiscoveryResult): VerifiedOffer[];
113
+ /**
114
+ * Every rejected offer across all groups, with the reason each failed. The same caveat as
115
+ * {@link verifiedOffers} applies: a URI that yielded no offers at all is not a rejection
116
+ * and appears only in `groups`.
117
+ */
118
+ export declare function rejectedOffers(result: DiscoveryResult): RejectedOffer[];
119
+ /** Runtime brand check — a plain object cannot carry the module-private Symbol. */
120
+ export declare function isVerifiedOffer(value: unknown): value is VerifiedOffer;
121
+ /**
122
+ * canonicalOfferPayload reproduces the signed bytes: clear signature +
123
+ * signature_algorithm from the offer's canonical proto-JSON, then apply RFC 8785
124
+ * JCS. MUST stay byte-identical to the Go oracle (helpers.CanonicalOfferBytes).
125
+ * The offer is already canonical proto-JSON (snake_case, enums-as-names,
126
+ * omit-unpopulated) — the core only clears the two signature fields and re-JCS-es.
127
+ */
128
+ export declare function canonicalOfferPayload(offer: Record<string, unknown>): Uint8Array<ArrayBuffer>;
129
+ /**
130
+ * Verifier runs the per-offer authenticity + freshness check, keyed through the
131
+ * injected KeyResolver. It is PURE apart from the resolver IO — no state, no clock
132
+ * beyond the injected now. Transport-neutral: any Fetch/WebCrypto consumer composes
133
+ * it directly, without any framework binding.
134
+ */
135
+ export declare class Verifier {
136
+ private readonly mode;
137
+ private readonly resolver;
138
+ private readonly now;
139
+ private readonly verifyEd25519;
140
+ constructor(mode: Mode, resolver: OfferKeyResolver, now: () => number, verifyEd25519: Ed25519Verify);
141
+ /**
142
+ * sort splits offers into verified and rejected per the configured mode. Under
143
+ * "off" every offer is surfaced verified with no check. Under "strict" each offer
144
+ * is verified against its resolved exchange key and its expiry — a failure of
145
+ * either lands it in rejected with the reason.
146
+ */
147
+ sort(offers: unknown[]): Promise<Result>;
148
+ /**
149
+ * sortGroups verifies every group's offers through THIS Verifier and returns the
150
+ * per-URI results, preserving each group's URI and its typed reasons (mirror sdk/go
151
+ * `Verifier.SortGroups`).
152
+ *
153
+ * One Verifier sorts every group deliberately: it is stateless apart from the
154
+ * injected resolver and clock, so a fresh one per group would mean N resolver caches
155
+ * and N clock readings for a single logical answer.
156
+ *
157
+ * A non-object element is SKIPPED rather than surfaced as an empty URI: it is not a
158
+ * group with no offers, it is nothing at all, and inventing an answer the responder
159
+ * never gave is worse than dropping a malformed one.
160
+ */
161
+ sortGroups(groups: readonly unknown[]): Promise<OfferGroupResult[]>;
162
+ private check;
163
+ private expired;
164
+ }
165
+ /** Options for createVerifier (all injected — the core owns no state). */
166
+ export interface VerifierOptions {
167
+ resolve: (exchange: string) => Promise<Uint8Array<ArrayBuffer> | undefined>;
168
+ now: () => number;
169
+ verifyEd25519?: Ed25519Verify;
170
+ }
171
+ /**
172
+ * createVerifier is the transport-neutral constructor. Mirror of sdk/go
173
+ * core.NewVerifier: the verification mode, offer KeyResolver, and clock are all
174
+ * injected; the Ed25519 primitive defaults to WebCrypto.
175
+ */
176
+ export declare function createVerifier(mode: Mode, opts: VerifierOptions): Verifier;
177
+ export {};
@@ -0,0 +1,269 @@
1
+ // sdk/ts/core — the transport-neutral L2 core (Verifier + {verified, rejected} +
2
+ // the unforgeable VerifiedOffer). Mirror of sdk/go/core, translated to idiomatic
3
+ // TS. It imposes NOTHING beyond the WHATWG Fetch/WebCrypto web standard — no Hono,
4
+ // no Connect-ES. Framework bindings (sdk/ts/hono) depend one-directionally on this
5
+ // core, never the reverse.
6
+ //
7
+ // The Verifier splits received offers into {verified, rejected} by ed25519-
8
+ // verifying the canonical offer signature. Per the JCS switch (ADR-020 §4), the
9
+ // signed payload is RFC 8785 JCS over the canonical proto-JSON of the offer with
10
+ // signature/signature_algorithm cleared:
11
+ //
12
+ // signed_payload = JCS(protojson(offer with sig+alg cleared))
13
+ //
14
+ // so the core reproduces the exact signed bytes without a protobuf binary codec.
15
+ //
16
+ // VerifiedOffer is a BRANDED/opaque type: it carries a module-private Symbol tag
17
+ // that only this core's verify path (or RejectedOffer.unsafe()) can stamp, so an
18
+ // application cannot forge one with an object literal and slip it into
19
+ // execute(offer: VerifiedOffer). That is the TS analogue of the Go compile guard
20
+ // (unexported field + no exported constructor).
21
+ import canonicalize from "canonicalize";
22
+ import { utf8Bytes } from "../src/base64url.js";
23
+ // OFFER_SIGNATURE_ALGORITHM is the JOSE/JWA algorithm identifier advertised on
24
+ // signed offers. Always
25
+ // EdDSA for Ed25519 (mirror helpers.OfferSignatureAlgorithm).
26
+ export const OFFER_SIGNATURE_ALGORITHM = "EdDSA";
27
+ // The module-private brand. Only code in this module can read/stamp it, so a
28
+ // VerifiedOffer cannot be fabricated outside the core's verify path.
29
+ const VERIFIED_BRAND = Symbol("fora.core.VerifiedOffer");
30
+ /**
31
+ * Every verified offer across all groups, for a caller that does not care which URI an
32
+ * offer answers.
33
+ *
34
+ * A free function rather than a method, unlike the Go and Python faces: DiscoveryResult
35
+ * here is a plain record — what the client hands back after parsing — and putting
36
+ * methods on the interface would force every hand-built literal to supply them. Same
37
+ * contract, spelled the way this language spells it.
38
+ *
39
+ * A convenience over `groups`, never a substitute. A URI that was REFUSED contributes
40
+ * nothing here — it has no offer to contribute — so a caller reading only this cannot
41
+ * tell a refusal from a resource it never asked about. That is exactly the information
42
+ * `groups` exists to keep.
43
+ */
44
+ export function verifiedOffers(result) {
45
+ return result.groups.flatMap((g) => g.result.verified);
46
+ }
47
+ /**
48
+ * Every rejected offer across all groups, with the reason each failed. The same caveat as
49
+ * {@link verifiedOffers} applies: a URI that yielded no offers at all is not a rejection
50
+ * and appears only in `groups`.
51
+ */
52
+ export function rejectedOffers(result) {
53
+ return result.groups.flatMap((g) => g.result.rejected);
54
+ }
55
+ // mintVerified is the SOLE constructor of a branded VerifiedOffer, module-private.
56
+ function mintVerified(offer) {
57
+ return { [VERIFIED_BRAND]: true, offer };
58
+ }
59
+ function makeRejected(offer, reason) {
60
+ return { offer, reason, unsafe: () => mintVerified(offer) };
61
+ }
62
+ /** Runtime brand check — a plain object cannot carry the module-private Symbol. */
63
+ export function isVerifiedOffer(value) {
64
+ return (typeof value === "object" &&
65
+ value !== null &&
66
+ value[VERIFIED_BRAND] === true);
67
+ }
68
+ const defaultVerifyEd25519 = async (pubkey, sig, message) => {
69
+ try {
70
+ const key = await crypto.subtle.importKey("raw", pubkey, { name: "Ed25519" }, false, ["verify"]);
71
+ return await crypto.subtle.verify("Ed25519", key, sig, message);
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ };
77
+ // stringOr returns a wire string, or the fallback. A responder that sent the wrong JSON
78
+ // type for an enum or a URI has said nothing usable, and reading it as its own text
79
+ // would put an unvalidated value where a typed reason belongs.
80
+ function stringOr(value, fallback) {
81
+ return typeof value === "string" ? value : fallback;
82
+ }
83
+ // stringList returns the string members of a wire array; anything else contributes
84
+ // nothing.
85
+ function stringList(value) {
86
+ return Array.isArray(value)
87
+ ? value.filter((v) => typeof v === "string")
88
+ : [];
89
+ }
90
+ // hexToBytes decodes a hex string (the Offer.signature encoding) to bytes.
91
+ //
92
+ // Strict about what a hex digit is. Number.parseInt accepts a sign, leading whitespace and
93
+ // a trailing tail — "-1", "+f" and " a" all produce a number — so a signature carrying any
94
+ // of them would decode to bytes rather than being refused, where Go's hex.DecodeString and
95
+ // Python's bytes.fromhex refuse it. The value comes from a peer, so the three languages
96
+ // have to agree on what is a signature and what is garbage.
97
+ const HEX_PAIR = /^[0-9a-fA-F]{2}$/;
98
+ function hexToBytes(hex) {
99
+ if (hex.length % 2 !== 0)
100
+ return undefined;
101
+ const out = new Uint8Array(hex.length / 2);
102
+ for (let i = 0; i < out.length; i += 1) {
103
+ const pair = hex.slice(i * 2, i * 2 + 2);
104
+ if (!HEX_PAIR.test(pair))
105
+ return undefined;
106
+ out[i] = Number.parseInt(pair, 16);
107
+ }
108
+ return out;
109
+ }
110
+ /**
111
+ * canonicalOfferPayload reproduces the signed bytes: clear signature +
112
+ * signature_algorithm from the offer's canonical proto-JSON, then apply RFC 8785
113
+ * JCS. MUST stay byte-identical to the Go oracle (helpers.CanonicalOfferBytes).
114
+ * The offer is already canonical proto-JSON (snake_case, enums-as-names,
115
+ * omit-unpopulated) — the core only clears the two signature fields and re-JCS-es.
116
+ */
117
+ export function canonicalOfferPayload(offer) {
118
+ const stripped = { ...offer };
119
+ delete stripped.signature;
120
+ delete stripped.signature_algorithm;
121
+ const jcs = canonicalize(stripped);
122
+ if (jcs === undefined)
123
+ throw new Error("fora/core: offer is not JSON-serializable");
124
+ return utf8Bytes(jcs);
125
+ }
126
+ /**
127
+ * Verifier runs the per-offer authenticity + freshness check, keyed through the
128
+ * injected KeyResolver. It is PURE apart from the resolver IO — no state, no clock
129
+ * beyond the injected now. Transport-neutral: any Fetch/WebCrypto consumer composes
130
+ * it directly, without any framework binding.
131
+ */
132
+ export class Verifier {
133
+ mode;
134
+ resolver;
135
+ now;
136
+ verifyEd25519;
137
+ constructor(mode, resolver, now, verifyEd25519) {
138
+ this.mode = mode;
139
+ this.resolver = resolver;
140
+ this.now = now;
141
+ this.verifyEd25519 = verifyEd25519;
142
+ }
143
+ /**
144
+ * sort splits offers into verified and rejected per the configured mode. Under
145
+ * "off" every offer is surfaced verified with no check. Under "strict" each offer
146
+ * is verified against its resolved exchange key and its expiry — a failure of
147
+ * either lands it in rejected with the reason.
148
+ */
149
+ async sort(offers) {
150
+ const result = { verified: [], rejected: [] };
151
+ for (const offer of offers) {
152
+ if (this.mode === "off") {
153
+ result.verified.push(mintVerified(offer));
154
+ continue;
155
+ }
156
+ const reason = await this.check(offer);
157
+ if (reason === undefined) {
158
+ result.verified.push(mintVerified(offer));
159
+ }
160
+ else {
161
+ result.rejected.push(makeRejected(offer, reason));
162
+ }
163
+ }
164
+ return result;
165
+ }
166
+ /**
167
+ * sortGroups verifies every group's offers through THIS Verifier and returns the
168
+ * per-URI results, preserving each group's URI and its typed reasons (mirror sdk/go
169
+ * `Verifier.SortGroups`).
170
+ *
171
+ * One Verifier sorts every group deliberately: it is stateless apart from the
172
+ * injected resolver and clock, so a fresh one per group would mean N resolver caches
173
+ * and N clock readings for a single logical answer.
174
+ *
175
+ * A non-object element is SKIPPED rather than surfaced as an empty URI: it is not a
176
+ * group with no offers, it is nothing at all, and inventing an answer the responder
177
+ * never gave is worse than dropping a malformed one.
178
+ */
179
+ async sortGroups(groups) {
180
+ const out = [];
181
+ for (const group of groups) {
182
+ if (typeof group !== "object" || group === null || Array.isArray(group)) {
183
+ continue;
184
+ }
185
+ const rec = group;
186
+ const offers = rec["offers"];
187
+ // The two optional reasons are OMITTED when the responder gave none, rather
188
+ // than set to undefined: under exactOptionalPropertyTypes those are different
189
+ // facts, and "the responder said nothing" is the one being preserved.
190
+ const absenceReason = stringOr(rec["absence_reason"], undefined);
191
+ const discoveryMethod = stringOr(rec["discovery_method"], undefined);
192
+ out.push({
193
+ uri: stringOr(rec["uri"], ""),
194
+ result: await this.sort(Array.isArray(offers) ? offers : []),
195
+ ...(absenceReason !== undefined ? { absenceReason } : {}),
196
+ ...(discoveryMethod !== undefined ? { discoveryMethod } : {}),
197
+ restrictionFilters: stringList(rec["restriction_filters"]),
198
+ });
199
+ }
200
+ return out;
201
+ }
202
+ // check verifies a single offer: resolve the exchange offer-signing key, verify
203
+ // the JCS signature, and enforce the not-in-the-past expiry. Any step failing
204
+ // rejects the offer (fail-closed) — including an unresolvable key.
205
+ async check(offer) {
206
+ if (typeof offer !== "object" || offer === null)
207
+ return "offer is not an object";
208
+ const rec = offer;
209
+ const exchange = typeof rec.exchange === "string" ? rec.exchange : "";
210
+ // A resolver that THROWS rejects this offer and nothing else. The shipped
211
+ // resolvers raise on a network failure, and on a Broker fan-out the exchange comes
212
+ // off a relayed offer — so one Exchange whose key endpoint hangs would otherwise
213
+ // deny the agent every offer in the response, as an untyped exception out of a
214
+ // call that promises a typed one. Go returns the resolver's error as this offer's
215
+ // rejection reason and moves on.
216
+ let pub;
217
+ try {
218
+ pub = await this.resolver.resolve(exchange);
219
+ }
220
+ catch (cause) {
221
+ return `no offer-signing key for exchange ${JSON.stringify(exchange)}: ${cause instanceof Error ? cause.message : String(cause)}`;
222
+ }
223
+ if (!pub)
224
+ return `no offer-signing key for exchange ${JSON.stringify(exchange)}`;
225
+ const sigHex = typeof rec.signature === "string" ? rec.signature : "";
226
+ const sig = hexToBytes(sigHex);
227
+ if (!sig)
228
+ return "offer signature is not valid hex";
229
+ const payload = canonicalOfferPayload(rec);
230
+ const valid = await this.verifyEd25519(pub, sig, payload);
231
+ if (!valid)
232
+ return "offer signature invalid";
233
+ if (this.expired(rec))
234
+ return "offer expires_at is in the past";
235
+ return undefined;
236
+ }
237
+ // expired mirrors the Go oracle (core.Verifier.expired), fail-closed: an offer
238
+ // with no expires_at, or one whose expires_at cannot be parsed, is treated as
239
+ // EXPIRED — FORA offers are minted now+TTL, so a missing/broken bound is
240
+ // malformed bearer state, never an eternal grant. A present bound is inclusive
241
+ // at now (strictly-before is expired). The wire form is UTC, so an offset-less
242
+ // instant is read as UTC, not host-local.
243
+ expired(rec) {
244
+ const expiresAt = rec.expires_at;
245
+ if (typeof expiresAt !== "string")
246
+ return true;
247
+ const ms = parseUtcMillis(expiresAt);
248
+ if (Number.isNaN(ms))
249
+ return true;
250
+ return ms < this.now();
251
+ }
252
+ }
253
+ // parseUtcMillis parses an RFC 3339 instant as UTC epoch-millis. An instant with
254
+ // no timezone designator (no trailing Z / ±hh:mm) is read as UTC — the protobuf
255
+ // Timestamp wire form is always UTC — rather than host-local, which is what
256
+ // Date.parse would silently assume and which diverges from Go/Python.
257
+ function parseUtcMillis(s) {
258
+ const hasTz = /[zZ]$|[+-]\d{2}:?\d{2}$/.test(s);
259
+ return Date.parse(hasTz ? s : `${s}Z`);
260
+ }
261
+ /**
262
+ * createVerifier is the transport-neutral constructor. Mirror of sdk/go
263
+ * core.NewVerifier: the verification mode, offer KeyResolver, and clock are all
264
+ * injected; the Ed25519 primitive defaults to WebCrypto.
265
+ */
266
+ export function createVerifier(mode, opts) {
267
+ const resolver = { resolve: opts.resolve };
268
+ return new Verifier(mode, resolver, opts.now, opts.verifyEd25519 ?? defaultVerifyEd25519);
269
+ }
@@ -0,0 +1,72 @@
1
+ import { type RequestKeyResolver } from "./verify-request.ts";
2
+ /**
3
+ * The classified multisig reject reason (mirrors the Go taxonomy): "hop_budget"
4
+ * (count exceeds the configured budget), "broken_chain" (labels non-contiguous /
5
+ * reordered / missing or wrong link), and "signature" (any per-hop
6
+ * authenticity/freshness/key/covered-set failure — the default).
7
+ */
8
+ export type MultisigRejectReason = "signature" | "broken_chain" | "hop_budget";
9
+ /** The injected keyid-keyed verifying-key resolver — every hop's key resolves
10
+ * through it (the SDK owns no keys). Structurally identical to RequestKeyResolver. */
11
+ export type MultisigKeyResolver = RequestKeyResolver;
12
+ /**
13
+ * The request headers a multisig server-verify reads.
14
+ *
15
+ * Read exactly as the single-sig sibling reads them: case-insensitively, joining
16
+ * repeated spellings of one name. Declared as a type alias for the same reason —
17
+ * a real request carries names beyond the ones spelled out below.
18
+ */
19
+ export type MultisigVerifyHeaders = {
20
+ "content-digest": string;
21
+ "signature-input": string;
22
+ signature: string;
23
+ /**
24
+ * The two covered headers whose value may legitimately be EMPTY. Optional so an
25
+ * ABSENT one is distinguishable from an empty one, which is the whole reason an
26
+ * empty one is put on the wire: the base is rebuilt from the request that ARRIVED,
27
+ * so a name the signature covers and the request does not carry cannot be
28
+ * reconstructed, and defaulting it to "" would invent a value the signer may never
29
+ * have bound. The oracle draws the same line by reading these with Values rather
30
+ * than Get. See docs/design-history.md, "A covered header the peer never receives
31
+ * is not bound".
32
+ */
33
+ authorization?: string;
34
+ "signature-agent"?: string;
35
+ /**
36
+ * The entitlement-token header (mirrors Go entitlementHeaderLower). When
37
+ * present, EVERY hop's covered set MUST commit to it (enforceEntitlementCoverage
38
+ * runs per hop in Go's verifySingleSignature, which the multisig loop calls per
39
+ * hop); omit/empty when the request carries no entitlement.
40
+ */
41
+ "x-entitlement-token"?: string;
42
+ };
43
+ /** Inputs for verifyMultisigRequestServer. */
44
+ export interface VerifyMultisigRequestInput {
45
+ method: string;
46
+ url: string;
47
+ body: Uint8Array<ArrayBuffer>;
48
+ headers: MultisigVerifyHeaders;
49
+ resolve: MultisigKeyResolver;
50
+ /** Injected clock returning unix seconds — verify reads time ONLY through this. */
51
+ now: () => number;
52
+ /** The Exchange hop bound: reject a chain longer than this BEFORE any crypto.
53
+ * 0 / omitted means unbounded (mirrors Go opts.MaxSignatures). */
54
+ maxSignatures?: number;
55
+ /** The per-hop signature-lifetime clamp in SECONDS (mirrors Go
56
+ * VerifyOptions.MaxSignatureAge), enforced on EVERY hop exactly like the
57
+ * single-sig path. 0 / omitted means unbounded; the bound is inclusive. */
58
+ maxSignatureAge?: number;
59
+ }
60
+ /** The returned verdict: valid with the verified keyids in chain order, or invalid
61
+ * with the classified reason. Never thrown — always returned. */
62
+ export interface MultisigVerifyVerdict {
63
+ valid: boolean;
64
+ reason?: MultisigRejectReason;
65
+ keyids?: string[];
66
+ }
67
+ /**
68
+ * Verify an inbound MULTISIG forwarding-chain FORA request; return a reason-tagged
69
+ * verdict carrying the verified keyids in chain order. Enforces the hop budget,
70
+ * then the structural chain, then every hop's signature — in that precedence.
71
+ */
72
+ export declare function verifyMultisigRequestServer(input: VerifyMultisigRequestInput): Promise<MultisigVerifyVerdict>;
@@ -0,0 +1,112 @@
1
+ // sdk/ts framework-agnostic RFC 9421 MULTISIG forwarding-chain SERVER-verify face
2
+ // — the TS port of Go helpers.VerifyMultisigRequest[Resolved] (verify.go /
3
+ // keyresolver.go). Where core/verify-request.ts is the SINGLE-SIG surface, this is
4
+ // the multi-hop verify a Broker/Exchange built in TS wires when a request carries
5
+ // a forwarding chain: it parses ALL labels, enforces the hop budget
6
+ // FIRST, the structural forwarding chain NEXT, then cryptographically verifies
7
+ // every hop — returning a verdict with the verified keyids in chain order, never
8
+ // throwing.
9
+ //
10
+ // Reject precedence (parity-critical, mirrors Go VerifyMultisigRequest):
11
+ // hop_budget → broken_chain → signature.
12
+ //
13
+ // NO replay: the Go helpers oracle VerifyMultisigRequest[Resolved]
14
+ // performs no replay (replay lives at the connectserver layer), so this face
15
+ // reuses the replay-free per-signature core (verifyParsedSignature) and never
16
+ // touches a ReplayStore. The chain link resolves to the LIVE predecessor bytes, so
17
+ // a stripped, reordered, or tampered predecessor is rejected.
18
+ import { stdBase64 } from "./sign.js";
19
+ import { parseMultisigSignatureInput, signatureBytesByLabel, } from "./multisig-parse.js";
20
+ import { readHeader, requestVerifyFields, verifyParsedSignature, } from "./verify-request.js";
21
+ const rejectMultisig = (reason) => ({
22
+ valid: false,
23
+ reason,
24
+ });
25
+ /**
26
+ * enforceSignatureChain — the STRUCTURAL forwarding-chain gate (Go
27
+ * enforceSignatureChain): labels are exactly sig1..sigN contiguous in header
28
+ * order, sig1 carries no "signature" component, and every sigK (K>1) covers
29
+ * exactly one "signature";key="sig(K-1)". Cryptographic binding is the per-hop
30
+ * verify's job; this is structure only.
31
+ */
32
+ function enforceSignatureChain(members) {
33
+ for (let i = 0; i < members.length; i += 1) {
34
+ const member = members[i];
35
+ if (member.label !== `sig${i + 1}`)
36
+ return false;
37
+ const links = member.covered.filter((c) => c.name === "signature");
38
+ if (i === 0) {
39
+ if (links.length !== 0)
40
+ return false;
41
+ continue;
42
+ }
43
+ if (links.length !== 1 || links[0]?.chainKey !== `sig${i}`)
44
+ return false;
45
+ }
46
+ return true;
47
+ }
48
+ // chainLinkFor builds the forwarding-chain base line for hop i (>0): the token
49
+ // `"signature";key="sig(i)"` and its value `:<std-base64(live predecessor bytes)>:`
50
+ // resolved from the LIVE Signature member, or undefined if the predecessor bytes
51
+ // are absent (a per-hop signature failure).
52
+ function chainLinkFor(i, sigMap) {
53
+ const prevLabel = `sig${i}`;
54
+ const prevBytes = sigMap[prevLabel];
55
+ if (!prevBytes)
56
+ return undefined;
57
+ return { token: `"signature";key="${prevLabel}"`, value: `:${stdBase64(prevBytes)}:` };
58
+ }
59
+ /**
60
+ * Verify an inbound MULTISIG forwarding-chain FORA request; return a reason-tagged
61
+ * verdict carrying the verified keyids in chain order. Enforces the hop budget,
62
+ * then the structural chain, then every hop's signature — in that precedence.
63
+ */
64
+ export async function verifyMultisigRequestServer(input) {
65
+ // Every read goes through the case-insensitive, duplicate-joining fold — see
66
+ // readHeader for why both properties are load-bearing.
67
+ const members = parseMultisigSignatureInput([
68
+ readHeader(input.headers, "signature-input") ?? "",
69
+ ]);
70
+ if (!members)
71
+ return rejectMultisig("signature");
72
+ const budget = input.maxSignatures ?? 0;
73
+ if (budget > 0 && members.length > budget)
74
+ return rejectMultisig("hop_budget");
75
+ if (!enforceSignatureChain(members))
76
+ return rejectMultisig("broken_chain");
77
+ // A covered header the request never carried is refused here and NOT sooner: the
78
+ // oracle finds it while rebuilding a hop's base, which happens after the budget
79
+ // and the chain are enforced. Checking earlier would answer "signature" to an
80
+ // over-budget or reordered chain that the oracle answers "hop_budget" and
81
+ // "broken_chain" for — the corpus pins all three.
82
+ //
83
+ // The fields carry the entitlement-token header too, so enforceEntitlementCoverage
84
+ // gates EVERY hop (Go runs it inside verifySingleSignature, called per hop).
85
+ const fields = requestVerifyFields(input.method, input.url, input.body, input.headers);
86
+ if (fields === undefined)
87
+ return rejectMultisig("signature");
88
+ const sigMap = signatureBytesByLabel(readHeader(input.headers, "signature") ?? "");
89
+ const nowSec = Math.floor(input.now());
90
+ const keyids = [];
91
+ for (let i = 0; i < members.length; i += 1) {
92
+ const member = members[i];
93
+ let chainLink;
94
+ if (i > 0) {
95
+ chainLink = chainLinkFor(i, sigMap);
96
+ if (!chainLink)
97
+ return rejectMultisig("signature");
98
+ }
99
+ const ok = await verifyParsedSignature(fields, {
100
+ rawParams: member.rawInner,
101
+ covered: new Set(member.covered.map((c) => c.name)),
102
+ keyid: member.keyid,
103
+ alg: member.alg,
104
+ ...(member.created !== undefined ? { created: member.created } : {}),
105
+ ...(member.expires !== undefined ? { expires: member.expires } : {}),
106
+ }, sigMap[member.label], input.resolve, nowSec, chainLink, input.maxSignatureAge);
107
+ if (!ok || member.keyid === null)
108
+ return rejectMultisig("signature");
109
+ keyids.push(member.keyid);
110
+ }
111
+ return { valid: true, keyids };
112
+ }