@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,256 @@
1
+ import { ErrorDetailSchema } from "../../../gen/ts/wire/schemas.js";
2
+ import { snakeFromJsonName } from "./wire-names.js";
3
+ /** The fully-qualified proto name Connect stamps on an ErrorDetail transport detail. */
4
+ export const ERROR_DETAIL_TYPE = "fora.v1.ErrorDetail";
5
+ /**
6
+ * The ErrorDetail `reason` oneof members, in proto field-number order (the same
7
+ * order the Go helpers.Reason accessor walks). Exactly one is populated when a
8
+ * typed reason is present; all are absent for a generic transport-class failure.
9
+ */
10
+ export const REASON_FIELDS = [
11
+ "transaction_denial",
12
+ "catalog_rejection",
13
+ "registration_failure",
14
+ "dispute_failure",
15
+ "domain_verification_failure",
16
+ "retrieval_auth_failure",
17
+ "usage_report_rejection",
18
+ ];
19
+ function isRecord(v) {
20
+ return typeof v === "object" && v !== null && !Array.isArray(v);
21
+ }
22
+ /**
23
+ * Parse a canonical proto-JSON ErrorDetail object into the generated model. The
24
+ * payload is the proto-JSON object (snake_case keys, enums as NAME strings). This
25
+ * is the low-level decode the corpus replay exercises and the building block of
26
+ * {@link errorDetailFrom}.
27
+ */
28
+ export function parseErrorDetail(payload) {
29
+ return ErrorDetailSchema.parse(payload);
30
+ }
31
+ /**
32
+ * Return the active typed reason (oneof family + enum NAME), or null when no reason
33
+ * is set. Mirrors the Go helpers.Reason accessor so callers branch on the family +
34
+ * value, never on the human message string.
35
+ */
36
+ export function reason(detail) {
37
+ const record = detail;
38
+ for (const field of REASON_FIELDS) {
39
+ const block = record[field];
40
+ if (block != null && block.reason != null) {
41
+ return { field, value: block.reason };
42
+ }
43
+ }
44
+ return null;
45
+ }
46
+ function detailsOf(err) {
47
+ if (Array.isArray(err))
48
+ return err;
49
+ if (isRecord(err) && Array.isArray(err["details"]))
50
+ return err["details"];
51
+ return [];
52
+ }
53
+ // The one ErrorDetail member whose KEYS belong to whoever emitted it rather than to the
54
+ // proto: `metadata` is a map<string,string>, so a key inside it is data and must reach
55
+ // the caller byte-for-byte. Nothing else in the ErrorDetail subtree is a map or a
56
+ // Struct, which is what makes a single name enough; the conformance suite holds that
57
+ // fact so a future map field cannot quietly widen what the walk below rewrites.
58
+ const OPEN_MAP_MEMBERS = new Set(["metadata"]);
59
+ /**
60
+ * Rewrite a lowerCamelCase proto-JSON object into the proto's own field names.
61
+ *
62
+ * Connect's error-detail `debug` projection is the one place a FORA payload arrives in
63
+ * lowerCamelCase, and no server option changes it: connect-go renders it with its own
64
+ * protojson codec at default options, inside a method on an unexported type, so the
65
+ * snake_case codec a FORA deployment registers reaches the response BODY and not the
66
+ * error beside it. The generated ErrorDetailSchema accepts snake_case only — which is
67
+ * the FORA wire — and strips unknown keys, so without this the reason block is silently
68
+ * dropped and a refusal the Exchange named precisely reads back as no reason at all.
69
+ *
70
+ * Values under an open map keep their keys verbatim: those are the emitter's, not the
71
+ * proto's. The rewrite is textual and depends on protojson's spelling being invertible,
72
+ * which the conformance suite proves for every field in the contract.
73
+ */
74
+ /**
75
+ * How deep a peer's error detail may nest.
76
+ *
77
+ * BOUNDED because the payload is a peer's and the reader runs on every non-2xx. Recursing
78
+ * to the engine's own stack limit turns a nested object into a RangeError thrown out of a
79
+ * library the caller never invoked, which is not one of the failures this package says it
80
+ * throws. A real ErrorDetail nests two deep; the bound is the same 32 the protocol already
81
+ * sets for a third party's JSON in AccountRegistration.data_schema, so one number covers
82
+ * "how deep may a stranger's document be".
83
+ */
84
+ const MAX_DETAIL_DEPTH = 32;
85
+ /** Thrown past MAX_DETAIL_DEPTH. Module-private: it never reaches a caller, because the
86
+ * reader answers "no detail" instead. */
87
+ class TooDeep extends Error {
88
+ }
89
+ function protoNames(payload, budget = MAX_DETAIL_DEPTH) {
90
+ if (budget <= 0)
91
+ throw new TooDeep();
92
+ if (Array.isArray(payload))
93
+ return payload.map((v) => protoNames(v, budget - 1));
94
+ if (!isRecord(payload))
95
+ return payload;
96
+ const out = {};
97
+ for (const [key, value] of Object.entries(payload)) {
98
+ const name = snakeFromJsonName(key);
99
+ const member = OPEN_MAP_MEMBERS.has(name) ? value : protoNames(value, budget - 1);
100
+ // defineProperty, not assignment: a key named "__proto__" would otherwise invoke
101
+ // the prototype setter and set this object's prototype instead of a member on it.
102
+ Object.defineProperty(out, name, {
103
+ value: member,
104
+ enumerable: true,
105
+ writable: true,
106
+ configurable: true,
107
+ });
108
+ }
109
+ return out;
110
+ }
111
+ /**
112
+ * Extract the first FORA ErrorDetail from a Connect error (or its details array).
113
+ * `err` is either a Connect error object (carrying a `details` array) or the
114
+ * details iterable itself. Each detail entry is the Connect wire form
115
+ * `{ "type": "fora.v1.ErrorDetail", ... }`; the ErrorDetail proto-JSON is read from
116
+ * the entry's `debug` projection (Connect includes it for JSON clients) or from a
117
+ * `value` already decoded to an object. Returns null when `err` carries no
118
+ * ErrorDetail — the TS analog of the Go `(detail, false)`.
119
+ *
120
+ * The opaque binary `value` of a detail is intentionally NOT decoded here: the JSON
121
+ * SDKs have no protobuf binary codec, so they consume the proto-JSON form.
122
+ *
123
+ * Both payload forms are read through protoNames, because `debug` arrives
124
+ * lowerCamelCase and a decoded `value` — which only a caller that owns a binary codec
125
+ * can supply — may be either. A snake_case object passes through it unchanged.
126
+ */
127
+ export function errorDetailFrom(err) {
128
+ for (const entry of detailsOf(err)) {
129
+ if (!isRecord(entry) || entry["type"] !== ERROR_DETAIL_TYPE)
130
+ continue;
131
+ const debug = entry["debug"];
132
+ const value = entry["value"];
133
+ const payload = isRecord(debug) ? debug : isRecord(value) ? value : null;
134
+ if (payload === null)
135
+ continue;
136
+ let normalized;
137
+ try {
138
+ normalized = protoNames(payload);
139
+ }
140
+ catch (cause) {
141
+ // A detail entry that cannot be normalized is not an answer, and it came from a
142
+ // peer that just refused the call. The scan continues: `details` is a list, and
143
+ // an entry this reader cannot use says nothing about the next.
144
+ if (cause instanceof TooDeep)
145
+ continue;
146
+ throw cause;
147
+ }
148
+ const parsed = ErrorDetailSchema.safeParse(normalized);
149
+ if (parsed.success)
150
+ return parsed.data;
151
+ }
152
+ return null;
153
+ }
154
+ /**
155
+ * Build an ErrorDetail carrying a typed DenialReason (ExecuteTransaction denial).
156
+ * TS peer of Go `helpers.TransactionDenialDetail`. Sets only the `transaction_denial`
157
+ * reason block; the message's extra sub-fields (offer_id, restriction_mismatches) and
158
+ * metadata are omitted — mutate the returned object post-construction if needed.
159
+ */
160
+ export function transactionDenialDetail(domain, message, reason) {
161
+ return ErrorDetailSchema.parse({
162
+ domain,
163
+ message,
164
+ transaction_denial: { reason },
165
+ });
166
+ }
167
+ /**
168
+ * Build an ErrorDetail carrying a typed RetrievalAuthFailureReason.
169
+ * TS peer of Go `helpers.RetrievalAuthFailureDetail` (signed-URL / proof-of-possession
170
+ * check failed).
171
+ */
172
+ export function retrievalAuthFailureDetail(domain, message, reason) {
173
+ return ErrorDetailSchema.parse({
174
+ domain,
175
+ message,
176
+ retrieval_auth_failure: { reason },
177
+ });
178
+ }
179
+ /**
180
+ * Build an ErrorDetail carrying a typed CatalogRejectionReason.
181
+ * TS peer of Go `helpers.CatalogRejectionDetail`. Sets only the `catalog_rejection`
182
+ * reason block; `rejected_paths` is omitted.
183
+ */
184
+ export function catalogRejectionDetail(domain, message, reason) {
185
+ return ErrorDetailSchema.parse({
186
+ domain,
187
+ message,
188
+ catalog_rejection: { reason },
189
+ });
190
+ }
191
+ /**
192
+ * Build an ErrorDetail carrying a typed RegistrationFailureReason.
193
+ * TS peer of Go `helpers.RegistrationFailureDetail` (agent/provider registration
194
+ * refused).
195
+ *
196
+ * `fieldErrors` carries the offending `registration_data` members when the reason
197
+ * is `REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA`. It is optional
198
+ * rather than positional so the six reasons that carry no per-member detail keep
199
+ * the three-argument call, matching Go's variadic. Passing it with any other
200
+ * reason is a caller error — the field's contract says the list is empty then.
201
+ */
202
+ export function registrationFailureDetail(domain, message, reason, fieldErrors) {
203
+ const detail = ErrorDetailSchema.parse({
204
+ domain,
205
+ message,
206
+ registration_failure: {
207
+ reason,
208
+ ...(fieldErrors?.length ? { field_errors: [...fieldErrors] } : {}),
209
+ },
210
+ });
211
+ // An empty path is "the whole object", and canonical proto-JSON omits an empty
212
+ // scalar — so the wire form of a root-pointer entry carries no `path` key at
213
+ // all. The generated Zod schema declares `.default("")`, which materializes the
214
+ // member on parse, so it is dropped back off here. This is the exact inverse of
215
+ // the read side normalizing an absent path to "".
216
+ const rf = detail
217
+ .registration_failure;
218
+ for (const fe of rf?.field_errors ?? []) {
219
+ if (fe.path === "")
220
+ delete fe.path;
221
+ }
222
+ return detail;
223
+ }
224
+ /**
225
+ * Build an ErrorDetail carrying a typed DisputeFailureReason.
226
+ * TS peer of Go `helpers.DisputeFailureDetail` (DisputeTransaction filing refused).
227
+ */
228
+ export function disputeFailureDetail(domain, message, reason) {
229
+ return ErrorDetailSchema.parse({
230
+ domain,
231
+ message,
232
+ dispute_failure: { reason },
233
+ });
234
+ }
235
+ /**
236
+ * Build an ErrorDetail carrying a typed DomainVerificationFailureReason.
237
+ * TS peer of Go `helpers.DomainVerificationFailureDetail` (domain verification failed).
238
+ */
239
+ export function domainVerificationFailureDetail(domain, message, reason) {
240
+ return ErrorDetailSchema.parse({
241
+ domain,
242
+ message,
243
+ domain_verification_failure: { reason },
244
+ });
245
+ }
246
+ /**
247
+ * Build an ErrorDetail carrying a typed UsageReportRejectionReason.
248
+ * TS peer of Go `helpers.UsageReportRejectionDetail` (ReportUsage filing rejected).
249
+ */
250
+ export function usageReportRejectionDetail(domain, message, reason) {
251
+ return ErrorDetailSchema.parse({
252
+ domain,
253
+ message,
254
+ usage_report_rejection: { reason },
255
+ });
256
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * hashUrl returns the raw 32-byte SHA-256 digest of the signed URL's verbatim
3
+ * UTF-8 bytes.
4
+ */
5
+ export declare function hashUrl(signed: string): Promise<Uint8Array>;
@@ -0,0 +1,15 @@
1
+ // HashURL — TS port of the sdk/go oracle (helpers/signedurl.go HashURL). It
2
+ // returns the SHA-256 digest of a signed URL (the transaction_log.signed_url_hash
3
+ // value, 32 bytes). The hash is over the URL string bytes VERBATIM (opaque
4
+ // bytes, no WHATWG renormalization — consistent with the opaque-URL signing
5
+ // contract). The TS face is async (crypto.subtle.digest); the shared vector
6
+ // encodes the digest as hex.
7
+ import { utf8Bytes } from "./base64url.js";
8
+ /**
9
+ * hashUrl returns the raw 32-byte SHA-256 digest of the signed URL's verbatim
10
+ * UTF-8 bytes.
11
+ */
12
+ export async function hashUrl(signed) {
13
+ const digest = await crypto.subtle.digest("SHA-256", utf8Bytes(signed));
14
+ return new Uint8Array(digest);
15
+ }
@@ -0,0 +1,70 @@
1
+ /** A reference that cannot be read as a host at all. The Go oracle exposes an
2
+ * errors.Is sentinel here; this port throws, as the audience check above does,
3
+ * because a sentinel is not how a caller in this language distinguishes causes.
4
+ *
5
+ * The reference is redacted before it is named. Every refusal the parse raises passes
6
+ * through here, including the ones a credential-bearing value reaches — a control
7
+ * character, a backslash in the userinfo, a malformed escape — so this is the one
8
+ * place that has to do it. */
9
+ export declare function invalidHost(ref: string, why: string): Error;
10
+ /** Replace any userinfo in a reference with a marker, so a message built from it
11
+ * cannot carry a credential.
12
+ *
13
+ * Deliberately conservative, and deliberately NOT the rule: it runs on strings the
14
+ * parse has already refused, so it cannot assume they are well formed, and
15
+ * over-redacting a message costs nothing while under-redacting is the bug. It
16
+ * redacts at the FIRST reading in authorityStarts that finds an "@", rather than
17
+ * deciding on one reading — a credential any reader could see is one that must not
18
+ * reach a log. It looks only for an "@" in what could be the authority, which is why
19
+ * an "@" in a path is left alone.
20
+ *
21
+ * The whole userinfo goes, not just the password. Go's own url.URL.Redacted() keeps
22
+ * the username, which is right for a URL the caller owns — but this value arrives in
23
+ * a third-party manifest, where the username is as much the operator's secret as the
24
+ * password is.
25
+ *
26
+ * The oracle redacts here too, in its own words — the shared corpus records a verdict
27
+ * and never a message, so the three languages agree on the property and not on the
28
+ * wording. Its copy lives in an internal package because three tiers build these
29
+ * messages there and one of them would otherwise be left echoing. */
30
+ export declare function redactUserinfo(ref: string): string;
31
+ /** A `%` that does not begin a valid escape. Exported for the delivery leg, which reads
32
+ * the same rule over a signed URL's path and fragment; a second transcription of it is how
33
+ * two readers of one value end up disagreeing. Not on the package export map, so it stays
34
+ * in-repo. */
35
+ export declare const escapePair: RegExp;
36
+ export interface ParsedRef {
37
+ /** The authority with userinfo removed and the port kept, exactly as written. */
38
+ host: string;
39
+ /** The authority's host alone: no port, IPv6 brackets stripped, case preserved. */
40
+ hostname: string;
41
+ /** The port as written, or "" when none was. Never a default filled in. */
42
+ port: string;
43
+ /** Whether the caller actually WROTE a scheme. Anchoring needs this: a scheme
44
+ * decides which port counts as the default, so a value that named none must not
45
+ * be treated as having named https. */
46
+ hadScheme: boolean;
47
+ /** The scheme written, or "https" for a reference that named none. */
48
+ scheme: string;
49
+ /** Whether the authority carried userinfo. The endpoint rule refuses a
50
+ * credential and needs the answer from THIS reading of the reference: decided
51
+ * over a second, differently-shaped parse it disagrees with the anchor check on
52
+ * exactly the shape it exists to stop. */
53
+ hasUserinfo: boolean;
54
+ }
55
+ /** Read a bare domain, a host:port pair, or a full URL into its authority. A ref
56
+ * with no scheme is read as though it carried https, since a bare domain is
57
+ * otherwise indistinguishable from a path. One parse behind both host predicates,
58
+ * so neither can disagree with the other about what a reference even is. */
59
+ export declare function parseRef(ref: string): ParsedRef;
60
+ /** anchoredParsed is the anchor comparison over two references that have ALREADY
61
+ * been read. It exists so a caller holding a parse can reach the verdict without
62
+ * triggering a second one — the endpoint rule needs the userinfo answer and the
63
+ * anchor answer from one reading of the same string.
64
+ *
65
+ * A side that named no scheme borrows the other's, which decides only WHICH port
66
+ * counts as the default. Hostname and port are compared as two values rather than
67
+ * one joined string: joined, the label boundary would have to find ".a.com" at the
68
+ * end of "sub.a.com:8443" and would refuse a subdomain for having a port — the
69
+ * right answer reached through the wrong comparison is still the wrong comparison. */
70
+ export declare function anchoredParsed(a: ParsedRef, c: ParsedRef): boolean;
@@ -0,0 +1,320 @@
1
+ // The one reading of a host reference, shared by the routing predicates in
2
+ // hosts.ts and by the endpoint rule in the resolvers tier.
3
+ //
4
+ // It is a module of its own, and deliberately absent from both package export maps
5
+ // (the same shape src/opaque-url.ts already has), for the reason the Go oracle's
6
+ // internal endpointrule package gives: the rule is checked in two places for two
7
+ // different reasons, and written twice it drifts. Stated once here, a refusal
8
+ // decided over one reading and enforced over another cannot happen — which is
9
+ // exactly the defect that shipped when the two halves used different parsers.
10
+ //
11
+ // Nothing here is public API. hosts.ts re-exports the predicates built on it;
12
+ // this module's names are internal to the SDK.
13
+ /** A reference that cannot be read as a host at all. The Go oracle exposes an
14
+ * errors.Is sentinel here; this port throws, as the audience check above does,
15
+ * because a sentinel is not how a caller in this language distinguishes causes.
16
+ *
17
+ * The reference is redacted before it is named. Every refusal the parse raises passes
18
+ * through here, including the ones a credential-bearing value reaches — a control
19
+ * character, a backslash in the userinfo, a malformed escape — so this is the one
20
+ * place that has to do it. */
21
+ export function invalidHost(ref, why) {
22
+ return new Error(`hosts: reference is not a usable host: ${why}: ${JSON.stringify(redactUserinfo(ref))}`);
23
+ }
24
+ /** Split what follows an authority's start into the authority and everything after.
25
+ *
26
+ * The authority ends at the first delimiter that starts a path, query or fragment —
27
+ * the three things a reference can carry beyond it.
28
+ *
29
+ * One function because the parse and the redaction have to agree on where it ends.
30
+ * Written twice, a change to the delimiter set moves one copy and not the other, and
31
+ * the two then read different authorities — a split reading inside the module that
32
+ * exists to prevent split readings. */
33
+ function splitAuthority(rest) {
34
+ const end = rest.search(/[/?#]/);
35
+ return end < 0 ? [rest, ""] : [rest.slice(0, end), rest.slice(end)];
36
+ }
37
+ /** Every index at which an authority could plausibly begin, most authoritative
38
+ * first, de-duplicated.
39
+ *
40
+ * A reference the parse has REFUSED has no one true reading — that is what being
41
+ * refused means — so the redaction cannot pick a single one and be safe. Each of
42
+ * these is a reading something could take:
43
+ *
44
+ * - past a valid scheme at the front, which is how the parse reads it;
45
+ * - index 2 behind a bare `//`, which opens an authority while naming no scheme and
46
+ * carries no `://` anywhere;
47
+ * - index 0, a schemeless `host` or `user:pw@host`;
48
+ * - past the first `://` in the string, which is not a scheme separator to this
49
+ * parse but is what a laxer reader downstream may take it for.
50
+ *
51
+ * Taking only the first of these traded one leak for another: reading solely by
52
+ * search let a path segment supply the start, so `u:pw@evil.example/x://a.example`
53
+ * came back untouched — and reading solely by the parse's rule then left
54
+ * `1https://u:pw@a.example/` untouched instead, because a scheme may not begin with
55
+ * a digit. */
56
+ function authorityStarts(ref) {
57
+ const scheme = schemeAtFront.exec(ref);
58
+ const starts = [];
59
+ if (scheme !== null) {
60
+ starts.push(scheme[0].length);
61
+ }
62
+ if (ref.startsWith("//")) {
63
+ starts.push(2);
64
+ }
65
+ starts.push(0);
66
+ const sep = ref.indexOf("://");
67
+ if (sep >= 0) {
68
+ starts.push(sep + 3);
69
+ }
70
+ return [...new Set(starts)];
71
+ }
72
+ /** Replace any userinfo in a reference with a marker, so a message built from it
73
+ * cannot carry a credential.
74
+ *
75
+ * Deliberately conservative, and deliberately NOT the rule: it runs on strings the
76
+ * parse has already refused, so it cannot assume they are well formed, and
77
+ * over-redacting a message costs nothing while under-redacting is the bug. It
78
+ * redacts at the FIRST reading in authorityStarts that finds an "@", rather than
79
+ * deciding on one reading — a credential any reader could see is one that must not
80
+ * reach a log. It looks only for an "@" in what could be the authority, which is why
81
+ * an "@" in a path is left alone.
82
+ *
83
+ * The whole userinfo goes, not just the password. Go's own url.URL.Redacted() keeps
84
+ * the username, which is right for a URL the caller owns — but this value arrives in
85
+ * a third-party manifest, where the username is as much the operator's secret as the
86
+ * password is.
87
+ *
88
+ * The oracle redacts here too, in its own words — the shared corpus records a verdict
89
+ * and never a message, so the three languages agree on the property and not on the
90
+ * wording. Its copy lives in an internal package because three tiers build these
91
+ * messages there and one of them would otherwise be left echoing. */
92
+ export function redactUserinfo(ref) {
93
+ for (const start of authorityStarts(ref)) {
94
+ const [authority, beyond] = splitAuthority(ref.slice(start));
95
+ const at = authority.lastIndexOf("@");
96
+ if (at >= 0) {
97
+ return `${ref.slice(0, start)}[redacted]${authority.slice(at)}${beyond}`;
98
+ }
99
+ }
100
+ return ref;
101
+ }
102
+ /** Refuse brackets that enclose anything but an IPv6 address.
103
+ *
104
+ * Per RFC 3986 only an IPv6 address may be bracketed, and the oracle enforces it:
105
+ * `netip.ParseAddr` must succeed AND the result must not be IPv4, so `[]`,
106
+ * `[not-an-ip]`, `[1:2]` and `[127.0.0.1]` are all refused — the last as an "invalid
107
+ * IP-literal", since a v4 address in brackets is a spelling no parser is obliged to
108
+ * accept.
109
+ *
110
+ * Stripping the brackets without reading what is between them is what the platform
111
+ * parsers this module replaces did NOT do: both of them validate the literal, so the
112
+ * hand-rolled split had to take that job over with them. Left out, a value the oracle
113
+ * refuses at the caller boundary is accepted here and only surfaces later as a
114
+ * transport failure.
115
+ *
116
+ * `new URL` is the validator, and this is NOT the reading the module header refuses.
117
+ * The header refuses platform parsers because they NORMALIZE what they return —
118
+ * `[::ffff:127.0.0.1]` comes back as `[::ffff:7f00:1]`, a different string for the
119
+ * same address — which is precisely why nothing is read back out of it here. Only
120
+ * whether it threw is used, and the shared vectors hold that verdict to the oracle's.
121
+ * An IPv6 grammar written out by hand is the alternative, and the shapes that catch
122
+ * one out — `::ffff:0:127.0.0.1`, `64:ff9b::192.0.2.1`, `::1.2.3.4` — are exactly
123
+ * where a port would drift. */
124
+ function vetIpLiteral(ref, literal) {
125
+ try {
126
+ new URL(`https://[${literal}]`);
127
+ }
128
+ catch {
129
+ throw invalidHost(ref, "brackets enclose no IP literal");
130
+ }
131
+ }
132
+ // An authority admits a CLOSED set of ASCII characters; everything outside it is
133
+ // refused. Stating the set rather than a list of separators is what makes the
134
+ // refusal structural: a separator nobody thought of is already outside it. Code
135
+ // points at or above 0x80 are admitted — the oracle's parser keeps them, so a name
136
+ // in a non-ASCII script is a usable host even though the wire's domain rule
137
+ // refuses it.
138
+ const hostAscii = new Set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~!$&'()*+,;=:[]<>\"");
139
+ // Userinfo admits a DIFFERENT closed set from the host: no `"`, `<`, `>` or `]`.
140
+ // Reusing the host set here would close the backslash hole and still under-refuse
141
+ // four characters the oracle rejects — and the gap is not cosmetic. WHATWG treats
142
+ // `\` as a fourth authority delimiter in special schemes, so a backslash smuggled
143
+ // into userinfo ends the authority early and the fetch reaches an entirely
144
+ // different host from the one the anchor check just approved.
145
+ const userinfoAscii = new Set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~!$&'()*+,;=:");
146
+ // A scheme, and only at the front. The oracle reads `://` as a separator solely
147
+ // when a valid scheme precedes it at position 0; anywhere else the text is a path.
148
+ // Locating the separator by search instead let a path segment supply the host —
149
+ // `evil.example/x://a.example` answered `a.example`.
150
+ const schemeAtFront = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//;
151
+ /** A `%` that does not begin a valid escape. Exported for the delivery leg, which reads
152
+ * the same rule over a signed URL's path and fragment; a second transcription of it is how
153
+ * two readers of one value end up disagreeing. Not on the package export map, so it stays
154
+ * in-repo. */
155
+ export const escapePair = /%(?![0-9A-Fa-f]{2})/;
156
+ // A control character is refused outright rather than removed. This is the reason
157
+ // the platform parser cannot be used here: `new URL` strips tabs, newlines and
158
+ // leading control characters per WHATWG, so it answers "host" where the oracle
159
+ // answers "not a host". The port is read textually below for the same kind of
160
+ // reason — `new URL` folds a scheme's default port away at PARSE time, which is
161
+ // earlier than this rule decides which scheme is even in play.
162
+ const controlChar = /[\u0000-\u001F\u007F]/;
163
+ const digitsOnly = /^[0-9]*$/;
164
+ /** Read a bare domain, a host:port pair, or a full URL into its authority. A ref
165
+ * with no scheme is read as though it carried https, since a bare domain is
166
+ * otherwise indistinguishable from a path. One parse behind both host predicates,
167
+ * so neither can disagree with the other about what a reference even is. */
168
+ export function parseRef(ref) {
169
+ if (ref.trim() === "") {
170
+ throw invalidHost(ref, "empty reference");
171
+ }
172
+ if (controlChar.test(ref)) {
173
+ throw invalidHost(ref, "control character");
174
+ }
175
+ // `://` is a separator only behind a valid scheme at the front. A reference that
176
+ // carries the sequence anywhere else is not schemeless — it is malformed, which
177
+ // is what the oracle answers, so treating it as schemeless and prepending https
178
+ // would trade one wrong answer for another.
179
+ const hadScheme = ref.includes("://");
180
+ if (hadScheme && !schemeAtFront.test(ref)) {
181
+ throw invalidHost(ref, 'no valid scheme before "://"');
182
+ }
183
+ const work = hadScheme ? ref : `https://${ref}`;
184
+ const sep = work.indexOf("://");
185
+ const scheme = hadScheme ? work.slice(0, sep).toLowerCase() : "https";
186
+ const [authority, beyond] = splitAuthority(work.slice(sep + 3));
187
+ // Escapes are read PER COMPONENT, because the oracle reads them per component.
188
+ // A malformed escape is refused in a path and in a fragment, and admitted in a
189
+ // query, which is not unescaped at parse time. Checking the whole reference
190
+ // instead refused `?q=a%20b` — an ordinary, fully conformant endpoint.
191
+ //
192
+ // The fragment is cut FIRST, and that ordering is the rule rather than a detail:
193
+ // everything after the first "#" is fragment, so a "?" inside one does not start
194
+ // a query. Reading the query first leaves `/#a?b=%zz` looking like a query and
195
+ // admits a malformed escape the oracle refuses.
196
+ const hash = beyond.indexOf("#");
197
+ const fragment = hash < 0 ? "" : beyond.slice(hash + 1);
198
+ const beforeFragment = hash < 0 ? beyond : beyond.slice(0, hash);
199
+ const query = beforeFragment.indexOf("?");
200
+ const path = query < 0 ? beforeFragment : beforeFragment.slice(0, query);
201
+ if (escapePair.test(path)) {
202
+ throw invalidHost(ref, "malformed percent-escape in path");
203
+ }
204
+ if (escapePair.test(fragment)) {
205
+ throw invalidHost(ref, "malformed percent-escape in fragment");
206
+ }
207
+ // Userinfo is split at the LAST "@", so an "@" inside a credential does not
208
+ // become part of the host.
209
+ const at = authority.lastIndexOf("@");
210
+ const hasUserinfo = at >= 0;
211
+ const userinfo = hasUserinfo ? authority.slice(0, at) : "";
212
+ const host = hasUserinfo ? authority.slice(at + 1) : authority;
213
+ if (host === "") {
214
+ throw invalidHost(ref, "no host");
215
+ }
216
+ for (const ch of userinfo) {
217
+ const cp = ch.codePointAt(0) ?? 0;
218
+ // "%" and "@" are excluded from the set check on purpose: escapes are read
219
+ // below, and an "@" before the last one is part of the credential, not a
220
+ // second separator.
221
+ if (cp < 0x80 && ch !== "%" && ch !== "@" && !userinfoAscii.has(ch)) {
222
+ throw invalidHost(ref, `invalid character ${JSON.stringify(ch)} in userinfo`);
223
+ }
224
+ }
225
+ if (escapePair.test(userinfo)) {
226
+ throw invalidHost(ref, "malformed percent-escape in userinfo");
227
+ }
228
+ for (const ch of host) {
229
+ const cp = ch.codePointAt(0) ?? 0;
230
+ // A percent-escape is refused in the HOST, and only there. The oracle admits
231
+ // just the escapes decoding to a byte at or above 0x80, plus %25 — none of
232
+ // which a domain name carries — so refusing the lot costs nothing a caller
233
+ // can use and removes an unescaping step the three languages would each get
234
+ // subtly wrong. Every value the two answers differ on refuses downstream
235
+ // anyway: a host holding a raw high byte anchors to no bare domain.
236
+ if (ch === "%") {
237
+ throw invalidHost(ref, "percent-escape in the host component");
238
+ }
239
+ if (cp < 0x80 && !hostAscii.has(ch)) {
240
+ throw invalidHost(ref, `invalid character ${JSON.stringify(ch)} in host`);
241
+ }
242
+ }
243
+ let hostname;
244
+ let port;
245
+ if (host.startsWith("[")) {
246
+ const close = host.indexOf("]");
247
+ if (close < 0) {
248
+ throw invalidHost(ref, "missing ']' in host");
249
+ }
250
+ hostname = host.slice(1, close);
251
+ vetIpLiteral(ref, hostname);
252
+ const after = host.slice(close + 1);
253
+ if (after === "") {
254
+ port = "";
255
+ }
256
+ else if (after.startsWith(":")) {
257
+ port = after.slice(1);
258
+ }
259
+ else {
260
+ throw invalidHost(ref, "trailing characters after ']' in host");
261
+ }
262
+ }
263
+ else {
264
+ if (host.includes("[")) {
265
+ throw invalidHost(ref, "missing ']' in host");
266
+ }
267
+ // From the FIRST colon onward, so a value that merely ENDS in digits does
268
+ // not pass as a port: "a.example::443" and "a.example:44:3" are refused.
269
+ const colon = host.indexOf(":");
270
+ hostname = colon < 0 ? host : host.slice(0, colon);
271
+ port = colon < 0 ? "" : host.slice(colon + 1);
272
+ }
273
+ if (!digitsOnly.test(port)) {
274
+ throw invalidHost(ref, `invalid port ${JSON.stringify(port)} after host`);
275
+ }
276
+ return { host, hostname, port, hadScheme, scheme, hasUserinfo };
277
+ }
278
+ // The port a scheme reaches when none is written.
279
+ const defaultPorts = { http: "80", https: "443" };
280
+ /** canonicalPort renders "the same port" as one string, so a port written out in
281
+ * full and the same port left implicit compare equal. An unknown scheme has no
282
+ * default to fold, so its port is kept verbatim. */
283
+ function canonicalPort(scheme, port) {
284
+ if (port === "") {
285
+ return "";
286
+ }
287
+ const def = defaultPorts[scheme.toLowerCase()];
288
+ return def !== undefined && port === def ? "" : port;
289
+ }
290
+ /** sameOrSubdomain reports whether candidate equals anchor or is a subdomain of
291
+ * it. Comparison is case-insensitive and tolerant of ONE trailing root dot — not
292
+ * of every trailing dot, which would make a doubled root dot compare equal to a
293
+ * name that never carried one. A subdomain match requires a full dot-delimited
294
+ * label boundary, so "evil-a.com" is NOT treated as a subdomain of "a.com" — the
295
+ * check a bare suffix match gets wrong, and the one an attacker registers a domain
296
+ * to exploit. */
297
+ function sameOrSubdomain(anchor, candidate) {
298
+ const a = anchor.toLowerCase().replace(/\.$/, "");
299
+ const c = candidate.toLowerCase().replace(/\.$/, "");
300
+ if (a === "") {
301
+ return false;
302
+ }
303
+ return c === a || c.endsWith(`.${a}`);
304
+ }
305
+ /** anchoredParsed is the anchor comparison over two references that have ALREADY
306
+ * been read. It exists so a caller holding a parse can reach the verdict without
307
+ * triggering a second one — the endpoint rule needs the userinfo answer and the
308
+ * anchor answer from one reading of the same string.
309
+ *
310
+ * A side that named no scheme borrows the other's, which decides only WHICH port
311
+ * counts as the default. Hostname and port are compared as two values rather than
312
+ * one joined string: joined, the label boundary would have to find ".a.com" at the
313
+ * end of "sub.a.com:8443" and would refuse a subdomain for having a port — the
314
+ * right answer reached through the wrong comparison is still the wrong comparison. */
315
+ export function anchoredParsed(a, c) {
316
+ const anchorScheme = a.hadScheme ? a.scheme : c.scheme;
317
+ const candidateScheme = c.hadScheme ? c.scheme : a.scheme;
318
+ return (sameOrSubdomain(a.hostname, c.hostname) &&
319
+ canonicalPort(anchorScheme, a.port) === canonicalPort(candidateScheme, c.port));
320
+ }