@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.
- package/LICENSE +201 -0
- package/README.md +37 -0
- package/gen/ts/vocab/functiontokens.js +78 -0
- package/gen/ts/vocab/functiontokens.ts +84 -0
- package/gen/ts/vocab/geographytokens.js +30 -0
- package/gen/ts/vocab/geographytokens.ts +37 -0
- package/gen/ts/vocab/pricingunits.js +56 -0
- package/gen/ts/vocab/pricingunits.ts +63 -0
- package/gen/ts/vocab/quotametrics.js +40 -0
- package/gen/ts/vocab/quotametrics.ts +47 -0
- package/gen/ts/vocab/usertypes.js +40 -0
- package/gen/ts/vocab/usertypes.ts +46 -0
- package/gen/ts/wire/base.js +245 -0
- package/gen/ts/wire/base.ts +255 -0
- package/gen/ts/wire/names.js +41 -0
- package/gen/ts/wire/names.ts +42 -0
- package/gen/ts/wire/schemas.js +111 -0
- package/gen/ts/wire/schemas.ts +216 -0
- package/package.json +164 -0
- package/sdk/ts/client/content.d.ts +74 -0
- package/sdk/ts/client/content.js +466 -0
- package/sdk/ts/client/errors.d.ts +79 -0
- package/sdk/ts/client/errors.js +148 -0
- package/sdk/ts/client/index.d.ts +241 -0
- package/sdk/ts/client/index.js +740 -0
- package/sdk/ts/client/route.d.ts +41 -0
- package/sdk/ts/client/route.js +107 -0
- package/sdk/ts/client/send.d.ts +64 -0
- package/sdk/ts/client/send.js +142 -0
- package/sdk/ts/client/transport.d.ts +187 -0
- package/sdk/ts/client/transport.js +381 -0
- package/sdk/ts/core/multisig-parse.d.ts +47 -0
- package/sdk/ts/core/multisig-parse.js +234 -0
- package/sdk/ts/core/sign-request.d.ts +83 -0
- package/sdk/ts/core/sign-request.js +146 -0
- package/sdk/ts/core/sign.d.ts +31 -0
- package/sdk/ts/core/sign.js +82 -0
- package/sdk/ts/core/signing-transport.d.ts +75 -0
- package/sdk/ts/core/signing-transport.js +172 -0
- package/sdk/ts/core/verifier.d.ts +177 -0
- package/sdk/ts/core/verifier.js +269 -0
- package/sdk/ts/core/verify-multisig-request.d.ts +72 -0
- package/sdk/ts/core/verify-multisig-request.js +112 -0
- package/sdk/ts/core/verify-request.d.ts +181 -0
- package/sdk/ts/core/verify-request.js +284 -0
- package/sdk/ts/core/window.d.ts +22 -0
- package/sdk/ts/core/window.js +43 -0
- package/sdk/ts/core/wire-canon.d.ts +20 -0
- package/sdk/ts/core/wire-canon.js +174 -0
- package/sdk/ts/hono/middleware.d.ts +38 -0
- package/sdk/ts/hono/middleware.js +64 -0
- package/sdk/ts/resolvers/errors.d.ts +130 -0
- package/sdk/ts/resolvers/errors.js +150 -0
- package/sdk/ts/resolvers/http.d.ts +98 -0
- package/sdk/ts/resolvers/http.js +274 -0
- package/sdk/ts/resolvers/index.d.ts +9 -0
- package/sdk/ts/resolvers/index.js +15 -0
- package/sdk/ts/resolvers/jwks.d.ts +2 -0
- package/sdk/ts/resolvers/jwks.js +43 -0
- package/sdk/ts/resolvers/offer-key-cache.d.ts +68 -0
- package/sdk/ts/resolvers/offer-key-cache.js +152 -0
- package/sdk/ts/resolvers/registration-requirements.d.ts +47 -0
- package/sdk/ts/resolvers/registration-requirements.js +220 -0
- package/sdk/ts/resolvers/ssrf.d.ts +28 -0
- package/sdk/ts/resolvers/ssrf.js +235 -0
- package/sdk/ts/resolvers/static.d.ts +7 -0
- package/sdk/ts/resolvers/static.js +16 -0
- package/sdk/ts/resolvers/wba.d.ts +134 -0
- package/sdk/ts/resolvers/wba.js +506 -0
- package/sdk/ts/resolvers/wellknown.d.ts +28 -0
- package/sdk/ts/resolvers/wellknown.js +169 -0
- package/sdk/ts/src/acceptance.d.ts +46 -0
- package/sdk/ts/src/acceptance.js +162 -0
- package/sdk/ts/src/base64url.d.ts +19 -0
- package/sdk/ts/src/base64url.js +55 -0
- package/sdk/ts/src/crossfield.d.ts +28 -0
- package/sdk/ts/src/crossfield.js +215 -0
- package/sdk/ts/src/endpoint-rule.d.ts +30 -0
- package/sdk/ts/src/endpoint-rule.js +63 -0
- package/sdk/ts/src/errordetail.d.ts +115 -0
- package/sdk/ts/src/errordetail.js +256 -0
- package/sdk/ts/src/hashurl.d.ts +5 -0
- package/sdk/ts/src/hashurl.js +15 -0
- package/sdk/ts/src/host-ref.d.ts +70 -0
- package/sdk/ts/src/host-ref.js +320 -0
- package/sdk/ts/src/hosts.d.ts +138 -0
- package/sdk/ts/src/hosts.js +214 -0
- package/sdk/ts/src/idempotency.d.ts +11 -0
- package/sdk/ts/src/idempotency.js +27 -0
- package/sdk/ts/src/jsondepth.d.ts +27 -0
- package/sdk/ts/src/jsondepth.js +77 -0
- package/sdk/ts/src/licenseterm.d.ts +113 -0
- package/sdk/ts/src/licenseterm.js +433 -0
- package/sdk/ts/src/money.d.ts +21 -0
- package/sdk/ts/src/money.js +60 -0
- package/sdk/ts/src/offer-sign.d.ts +11 -0
- package/sdk/ts/src/offer-sign.js +31 -0
- package/sdk/ts/src/opaque-url.d.ts +3 -0
- package/sdk/ts/src/opaque-url.js +20 -0
- package/sdk/ts/src/pop.d.ts +51 -0
- package/sdk/ts/src/pop.js +166 -0
- package/sdk/ts/src/regschema.d.ts +229 -0
- package/sdk/ts/src/regschema.js +1315 -0
- package/sdk/ts/src/scopes.d.ts +20 -0
- package/sdk/ts/src/scopes.js +45 -0
- package/sdk/ts/src/signurl.d.ts +23 -0
- package/sdk/ts/src/signurl.js +107 -0
- package/sdk/ts/src/thumbprint.d.ts +14 -0
- package/sdk/ts/src/thumbprint.js +24 -0
- package/sdk/ts/src/verify.d.ts +49 -0
- package/sdk/ts/src/verify.js +109 -0
- package/sdk/ts/src/wire-names.d.ts +1 -0
- package/sdk/ts/src/wire-names.js +16 -0
- package/sdk/ts/src/wire.d.ts +68 -0
- package/sdk/ts/src/wire.js +114 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Code generated by protoc-gen-foravocab. DO NOT EDIT.
|
|
2
|
+
//
|
|
3
|
+
// Source vocabulary: on fora.v1.RESTRICTION_KIND_USER_TYPE.
|
|
4
|
+
// The token list is authored solely in that option; these constants,
|
|
5
|
+
// isRegistered, Aliases and canonical derive from it and cannot drift.
|
|
6
|
+
export const Individual = "individual";
|
|
7
|
+
export const Academic = "academic";
|
|
8
|
+
export const NonProfit = "non_profit";
|
|
9
|
+
export const NewsPublisher = "news_publisher";
|
|
10
|
+
export const Broadcaster = "broadcaster";
|
|
11
|
+
export const CommercialEntity = "commercial_entity";
|
|
12
|
+
// All lists every registered token in registration order.
|
|
13
|
+
export const All = [
|
|
14
|
+
Individual,
|
|
15
|
+
Academic,
|
|
16
|
+
NonProfit,
|
|
17
|
+
NewsPublisher,
|
|
18
|
+
Broadcaster,
|
|
19
|
+
CommercialEntity,
|
|
20
|
+
];
|
|
21
|
+
const registered = new Set(All);
|
|
22
|
+
// isRegistered reports whether s is a registered bare token. Namespaced
|
|
23
|
+
// (vendor:token) values are NOT registered tokens and return false.
|
|
24
|
+
export function isRegistered(s) {
|
|
25
|
+
return registered.has(s);
|
|
26
|
+
}
|
|
27
|
+
// Aliases maps an accepted alias spelling to the registered token it
|
|
28
|
+
// canonicalises to. Authored beside the tokens; an axis without aliases
|
|
29
|
+
// carries an empty map so every axis has the same face.
|
|
30
|
+
export const Aliases = new Map([
|
|
31
|
+
["business", CommercialEntity],
|
|
32
|
+
["enterprise", CommercialEntity],
|
|
33
|
+
["personal", Individual],
|
|
34
|
+
]);
|
|
35
|
+
// canonical returns the registered token s is an alias of, or s unchanged
|
|
36
|
+
// when it is not an alias. It neither trims nor case-folds: the SDK folds a
|
|
37
|
+
// token before it looks it up here.
|
|
38
|
+
export function canonical(s) {
|
|
39
|
+
return Aliases.get(s) ?? s;
|
|
40
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Code generated by protoc-gen-foravocab. DO NOT EDIT.
|
|
2
|
+
//
|
|
3
|
+
// Source vocabulary: on fora.v1.RESTRICTION_KIND_USER_TYPE.
|
|
4
|
+
// The token list is authored solely in that option; these constants,
|
|
5
|
+
// isRegistered, Aliases and canonical derive from it and cannot drift.
|
|
6
|
+
|
|
7
|
+
export const Individual = "individual";
|
|
8
|
+
export const Academic = "academic";
|
|
9
|
+
export const NonProfit = "non_profit";
|
|
10
|
+
export const NewsPublisher = "news_publisher";
|
|
11
|
+
export const Broadcaster = "broadcaster";
|
|
12
|
+
export const CommercialEntity = "commercial_entity";
|
|
13
|
+
|
|
14
|
+
// All lists every registered token in registration order.
|
|
15
|
+
export const All = [
|
|
16
|
+
Individual,
|
|
17
|
+
Academic,
|
|
18
|
+
NonProfit,
|
|
19
|
+
NewsPublisher,
|
|
20
|
+
Broadcaster,
|
|
21
|
+
CommercialEntity,
|
|
22
|
+
] as const;
|
|
23
|
+
|
|
24
|
+
const registered: ReadonlySet<string> = new Set(All);
|
|
25
|
+
|
|
26
|
+
// isRegistered reports whether s is a registered bare token. Namespaced
|
|
27
|
+
// (vendor:token) values are NOT registered tokens and return false.
|
|
28
|
+
export function isRegistered(s: string): boolean {
|
|
29
|
+
return registered.has(s);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Aliases maps an accepted alias spelling to the registered token it
|
|
33
|
+
// canonicalises to. Authored beside the tokens; an axis without aliases
|
|
34
|
+
// carries an empty map so every axis has the same face.
|
|
35
|
+
export const Aliases: ReadonlyMap<string, string> = new Map<string, string>([
|
|
36
|
+
["business", CommercialEntity],
|
|
37
|
+
["enterprise", CommercialEntity],
|
|
38
|
+
["personal", Individual],
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
// canonical returns the registered token s is an alias of, or s unchanged
|
|
42
|
+
// when it is not an alias. It neither trims nor case-folds: the SDK folds a
|
|
43
|
+
// token before it looks it up here.
|
|
44
|
+
export function canonical(s: string): string {
|
|
45
|
+
return Aliases.get(s) ?? s;
|
|
46
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Hand-written policy seam for the generated Zod schemas — the TS parallel of the Python
|
|
2
|
+
// wire.base.WireModel. This is the ONE place message-wide wire policy is set.
|
|
3
|
+
//
|
|
4
|
+
// Two rules live here, and both are applied at EVERY depth of a message:
|
|
5
|
+
//
|
|
6
|
+
// 1. Unknown keys are STRIPPED (Zod's default), so a top-level field from a newer protocol
|
|
7
|
+
// version is ACCEPTED and dropped — matching Pydantic `extra="ignore"` and the Go typed-
|
|
8
|
+
// struct re-marshal. To change the policy repo-wide, swap `.strip()` in wire() for
|
|
9
|
+
// `.strict()` (reject) or `.passthrough()` (retain).
|
|
10
|
+
//
|
|
11
|
+
// 2. parseWire is the parse entry point, and it holds the two things the wire needs that a
|
|
12
|
+
// bare schema cannot express: a `null` means the field has no value, and a
|
|
13
|
+
// lowerCamelCase answer is refused. See parseWire.
|
|
14
|
+
//
|
|
15
|
+
// Map and Struct fields keep their own value schema and are never walked into: their keys
|
|
16
|
+
// and their values are caller-chosen data, so neither rule applies inside one.
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import { snakeFromJsonName } from "./names.js";
|
|
19
|
+
/**
|
|
20
|
+
* wire sets the unknown-key policy on one generated schema.
|
|
21
|
+
*
|
|
22
|
+
* It returns a ZodObject, not a ZodEffects. That is load-bearing rather than incidental:
|
|
23
|
+
* the readers that introspect these schemas — the from-wire offer inversion, the
|
|
24
|
+
* snake-field guard — reach for `.shape` and `instanceof z.ZodObject` on the exported
|
|
25
|
+
* value. An effect would break the first and silently stop matching in the second, which
|
|
26
|
+
* is the worse failure: a guard that passes because it no longer looks at anything.
|
|
27
|
+
*/
|
|
28
|
+
export function wire(schema) {
|
|
29
|
+
return schema instanceof z.ZodObject ? schema.strip() : schema;
|
|
30
|
+
}
|
|
31
|
+
/** Rule id for the refusal below, so a caller can name it without matching on a message
|
|
32
|
+
* string. The Python seam spells the same id as `wire.base.JSON_NAME_ALIAS_ERROR`, and the
|
|
33
|
+
* two must stay equal: a face that reports the refusal as a validation finding — the
|
|
34
|
+
* publisher's entry pre-check does — carries this id in both languages. */
|
|
35
|
+
export const JSON_NAME_ALIAS_ERROR = "fora_json_name_alias";
|
|
36
|
+
/** The refusal parseWire raises. Carried as its own type so the caller above can map it to
|
|
37
|
+
* a typed protocol failure without matching on a message string. */
|
|
38
|
+
export class WireNamingError extends Error {
|
|
39
|
+
/** The offending key, as the peer spelled it. */
|
|
40
|
+
key;
|
|
41
|
+
/** Dotted path to the message that carried it; "" at the root. */
|
|
42
|
+
path;
|
|
43
|
+
constructor(key, path) {
|
|
44
|
+
super(`peer answered with the lowerCamelCase json_name alias (${join(path, key)}); the ` +
|
|
45
|
+
"FORA wire is snake_case proto-JSON, so its answer cannot be read without " +
|
|
46
|
+
"silently dropping every multiword field");
|
|
47
|
+
this.name = "WireNamingError";
|
|
48
|
+
this.key = key;
|
|
49
|
+
this.path = path;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* parseWire validates one message against its generated schema, under the wire policy.
|
|
54
|
+
*
|
|
55
|
+
* A generated schema describes the message. It cannot describe the two things that are
|
|
56
|
+
* true of the WIRE rather than of the message, so both live here — applied in one
|
|
57
|
+
* schema-driven pass over the value, at every depth.
|
|
58
|
+
*
|
|
59
|
+
* **A `null` means the field has no value.** The canonical wire is proto-JSON, and there a
|
|
60
|
+
* null is a field's default — for ANY field, not only a message-typed one. So a null is
|
|
61
|
+
* dropped wherever the schema does not require a value: the field then reads as unset, or
|
|
62
|
+
* takes the default the schema declares, which is what the oracle answers for the same
|
|
63
|
+
* bytes. Where the schema DOES require a value the null is left in place for it to refuse.
|
|
64
|
+
*
|
|
65
|
+
* Both halves are load-bearing. EmitUnpopulated — what a FORA Exchange serves — renders an
|
|
66
|
+
* unpopulated non-optional message field as null rather than omitting it, so `{"ext":null}`
|
|
67
|
+
* is the ordinary shape of a real response. It renders an unset Timestamp that way too, and
|
|
68
|
+
* that is the case a narrower rule missed: the generator flattens a Timestamp to a string
|
|
69
|
+
* schema, so a test for "is this a message?" answered no and every response carrying an
|
|
70
|
+
* attestation without an attested-at, or a rate limit without a reset time, was refused
|
|
71
|
+
* outright. Pydantic spells all of these `X | None` and accepts the null, so reading
|
|
72
|
+
* presence rather than type is what keeps the two languages on one wire.
|
|
73
|
+
*
|
|
74
|
+
* **A lowerCamelCase answer is REFUSED.** The FORA wire is snake_case proto-JSON and the
|
|
75
|
+
* camelCase json_name alias is out of contract, so a conformant peer serves snake_case —
|
|
76
|
+
* connect-go does that only when a codec with UseProtoNames is registered, which a FORA
|
|
77
|
+
* deployment does and a stock connect-go server does not. The generated schemas accept
|
|
78
|
+
* snake_case only and STRIP what they do not recognise, so a camelCase answer would
|
|
79
|
+
* otherwise parse SUCCESSFULLY into a message with every multiword field missing, and
|
|
80
|
+
* nothing anywhere would say so.
|
|
81
|
+
*
|
|
82
|
+
* Depth is the point, for the refusal especially, and the money verb is why: a stock
|
|
83
|
+
* connect-go server omits unset fields, so a TransactionResponse arrives as
|
|
84
|
+
* `{ver, items}` — every root key a single word, identical in both spellings — while
|
|
85
|
+
* `transaction_id` sits one level down in TransactionResultItem and is dropped. That reads
|
|
86
|
+
* as a purchase that succeeded with no transaction id and no delivery URL, severing the
|
|
87
|
+
* dispute chain at its first link.
|
|
88
|
+
*
|
|
89
|
+
* Open maps are out of reach by construction rather than by a hold-back list: the pass is
|
|
90
|
+
* driven by the schema, and it stops at a map.
|
|
91
|
+
*
|
|
92
|
+
* Applied on the way in rather than baked into the schema, because the schema has to stay
|
|
93
|
+
* portable: the repo runs Zod 3 in the SDK trees and Zod 4 in the canonical round-trip
|
|
94
|
+
* gate, and the two disagree about how a schema is rebuilt — `.extend()` drops a
|
|
95
|
+
* description on one of them, and a reconstructed array loses its bounds. Expressing it in
|
|
96
|
+
* the JSON Schema instead would emit a union in place of every message field, which the
|
|
97
|
+
* from-wire inversion reads as a scalar. The Python twin needs neither workaround: a
|
|
98
|
+
* validator on WireModel is inherited by every nested model, so it recurses for free.
|
|
99
|
+
*/
|
|
100
|
+
export function parseWire(schema, raw) {
|
|
101
|
+
const parsed = schema.safeParse(underWirePolicy(schema, raw, ""));
|
|
102
|
+
if (!parsed.success)
|
|
103
|
+
return { success: false };
|
|
104
|
+
return { success: true, data: parsed.data };
|
|
105
|
+
}
|
|
106
|
+
const hasOwn = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
|
|
107
|
+
/**
|
|
108
|
+
* underWirePolicy returns `value` with the wire policy applied, refusing a non-canonical
|
|
109
|
+
* field name. Exported so a guard can drive the policy without a parse behind it.
|
|
110
|
+
*
|
|
111
|
+
* The input is never mutated: a request goes through here too, and the caller keeps the
|
|
112
|
+
* object it passed.
|
|
113
|
+
*/
|
|
114
|
+
export function underWirePolicy(schema, value, path) {
|
|
115
|
+
if (!(schema instanceof z.ZodType))
|
|
116
|
+
return value;
|
|
117
|
+
const core = unwrapped(schema);
|
|
118
|
+
if (core instanceof z.ZodArray) {
|
|
119
|
+
if (!Array.isArray(value))
|
|
120
|
+
return value;
|
|
121
|
+
const element = core.element;
|
|
122
|
+
return value.map((item, i) => underWirePolicy(element, item, `${path}[${i}]`));
|
|
123
|
+
}
|
|
124
|
+
// A map's keys are caller-chosen data. Nothing below one is a proto field name, and a
|
|
125
|
+
// null inside a Struct is a value (NullValue), not an absence.
|
|
126
|
+
if (!(core instanceof z.ZodObject))
|
|
127
|
+
return value;
|
|
128
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
129
|
+
return value;
|
|
130
|
+
const shape = core.shape;
|
|
131
|
+
const out = {};
|
|
132
|
+
for (const [key, member] of Object.entries(value)) {
|
|
133
|
+
// own-property check, not `shape[key] !== undefined`: a key like "__proto__" or
|
|
134
|
+
// "constructor" resolves to an inherited member of Object.prototype, which would
|
|
135
|
+
// read as a declared field and hand the walk something that is not a schema.
|
|
136
|
+
// hasOwnProperty.call, not Object.hasOwn: this file ships as source and compiles
|
|
137
|
+
// under the consumer's lib; Object.hasOwn needs lib ES2022.
|
|
138
|
+
if (!hasOwn(shape, key)) {
|
|
139
|
+
const name = snakeFromJsonName(key);
|
|
140
|
+
if (name !== key && hasOwn(shape, name))
|
|
141
|
+
throw new WireNamingError(key, path);
|
|
142
|
+
// defineProperty, not assignment: a key named "__proto__" would replace this
|
|
143
|
+
// object's prototype and create no member, so the value would never be walked
|
|
144
|
+
// and the schema would then read declared keys back through the prototype
|
|
145
|
+
// chain — accepting nested camelCase the depth check is here to refuse.
|
|
146
|
+
keep(out, key, member); // unknown to this pin; the schema strips it
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const field = shape[key];
|
|
150
|
+
if (field === undefined)
|
|
151
|
+
continue;
|
|
152
|
+
// A null is how proto-JSON spells "no value here". Absent and null are the same
|
|
153
|
+
// state, so a field the schema does not require is left unset and one it does
|
|
154
|
+
// require keeps the null for the schema to refuse.
|
|
155
|
+
if (member === null && mayBeUnset(field))
|
|
156
|
+
continue;
|
|
157
|
+
keep(out, key, underWirePolicy(field, member, join(path, key)));
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
/** keep writes a member, including one whose name is an inherited property of every
|
|
162
|
+
* object. See the call sites: a plain assignment to "__proto__" sets a prototype. */
|
|
163
|
+
function keep(out, key, value) {
|
|
164
|
+
Object.defineProperty(out, key, {
|
|
165
|
+
value,
|
|
166
|
+
enumerable: true,
|
|
167
|
+
writable: true,
|
|
168
|
+
configurable: true,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
/** Peel the wrapper kinds that decorate a schema without changing what it holds: the ones
|
|
172
|
+
* the generator emits around a field, and the refinement the cross-field layer attaches
|
|
173
|
+
* around a whole message. `.unwrap()` and `.removeDefault()` are spelled the same way in
|
|
174
|
+
* Zod 3 and Zod 4 — which is the whole reason this reads rather than rebuilds. */
|
|
175
|
+
function unwrapped(schema) {
|
|
176
|
+
let s = schema;
|
|
177
|
+
for (;;) {
|
|
178
|
+
if (s instanceof z.ZodOptional || s instanceof z.ZodNullable) {
|
|
179
|
+
s = s.unwrap();
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (s instanceof z.ZodDefault) {
|
|
183
|
+
s = s.removeDefault();
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
// A REFINEMENT wraps the object without changing what it holds. This is how the
|
|
187
|
+
// cross-field layer attaches its rules (sdk/ts/src/crossfield.ts), and without this
|
|
188
|
+
// branch the ZodObject test in underWirePolicy fails on every composed schema, so
|
|
189
|
+
// the whole wire policy is skipped for it: a camelCase answer parses successfully
|
|
190
|
+
// into a message with every multiword field missing, and the cross-field rule that
|
|
191
|
+
// needed one of those fields cannot fire. Peeling here is INSPECTION ONLY — parseWire
|
|
192
|
+
// still hands the ORIGINAL schema to safeParse, so the refinement still runs.
|
|
193
|
+
//
|
|
194
|
+
// Read as a method rather than through `instanceof z.ZodEffects`, because that class
|
|
195
|
+
// exists only in Zod 3. In Zod 4 a refinement keeps the schema's own class, so the
|
|
196
|
+
// ZodObject test already holds and there is nothing to peel — and naming the class
|
|
197
|
+
// would throw, since scripts/check-canonical.sh runs this file under Zod 4.
|
|
198
|
+
const innerType = s.innerType;
|
|
199
|
+
if (typeof innerType === "function") {
|
|
200
|
+
// A PREPROCESS effect rewrites the input BEFORE the inner schema sees it, so the
|
|
201
|
+
// inner shape is not the shape of what arrives here and the policy would be
|
|
202
|
+
// applied against the wrong object. refine, superRefine and transform all check
|
|
203
|
+
// the input against the inner schema first, so for those the inner shape is right.
|
|
204
|
+
if (effectType(s) === "preprocess")
|
|
205
|
+
return s;
|
|
206
|
+
s = innerType.call(s);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
return s;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/** Which kind of Zod 3 effect a schema carries. Only reached when `innerType` is present,
|
|
213
|
+
* which in Zod 3 is ZodEffects and nothing else. */
|
|
214
|
+
function effectType(s) {
|
|
215
|
+
return s._def.effect.type;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Whether the schema would accept this field carrying no value at all.
|
|
219
|
+
*
|
|
220
|
+
* This is the whole test for whether a null may be dropped, and it is deliberately a
|
|
221
|
+
* question about PRESENCE rather than about type. The predicate it replaced asked whether
|
|
222
|
+
* the field held a message or a map, which reads as the same question and is not: the
|
|
223
|
+
* generator FLATTENS the well-known types, so a google.protobuf.Timestamp — a message on
|
|
224
|
+
* the wire, rendered as null when unset like any other — arrives here as a plain string
|
|
225
|
+
* schema and failed a structural test for messages. Every response carrying one was refused.
|
|
226
|
+
*
|
|
227
|
+
* Optional and defaulted both count, and the default is the reason the two are not one
|
|
228
|
+
* check: dropping the null on a defaulted field lets the schema supply the default, which
|
|
229
|
+
* is exactly what the oracle answers for the same bytes.
|
|
230
|
+
*/
|
|
231
|
+
function mayBeUnset(field) {
|
|
232
|
+
let s = field;
|
|
233
|
+
for (;;) {
|
|
234
|
+
if (s instanceof z.ZodOptional || s instanceof z.ZodDefault)
|
|
235
|
+
return true;
|
|
236
|
+
if (s instanceof z.ZodNullable) {
|
|
237
|
+
s = s.unwrap();
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function join(path, key) {
|
|
244
|
+
return path === "" ? key : `${path}.${key}`;
|
|
245
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// Hand-written policy seam for the generated Zod schemas — the TS parallel of the Python
|
|
2
|
+
// wire.base.WireModel. This is the ONE place message-wide wire policy is set.
|
|
3
|
+
//
|
|
4
|
+
// Two rules live here, and both are applied at EVERY depth of a message:
|
|
5
|
+
//
|
|
6
|
+
// 1. Unknown keys are STRIPPED (Zod's default), so a top-level field from a newer protocol
|
|
7
|
+
// version is ACCEPTED and dropped — matching Pydantic `extra="ignore"` and the Go typed-
|
|
8
|
+
// struct re-marshal. To change the policy repo-wide, swap `.strip()` in wire() for
|
|
9
|
+
// `.strict()` (reject) or `.passthrough()` (retain).
|
|
10
|
+
//
|
|
11
|
+
// 2. parseWire is the parse entry point, and it holds the two things the wire needs that a
|
|
12
|
+
// bare schema cannot express: a `null` means the field has no value, and a
|
|
13
|
+
// lowerCamelCase answer is refused. See parseWire.
|
|
14
|
+
//
|
|
15
|
+
// Map and Struct fields keep their own value schema and are never walked into: their keys
|
|
16
|
+
// and their values are caller-chosen data, so neither rule applies inside one.
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
|
|
19
|
+
import { snakeFromJsonName } from "./names.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* wire sets the unknown-key policy on one generated schema.
|
|
23
|
+
*
|
|
24
|
+
* It returns a ZodObject, not a ZodEffects. That is load-bearing rather than incidental:
|
|
25
|
+
* the readers that introspect these schemas — the from-wire offer inversion, the
|
|
26
|
+
* snake-field guard — reach for `.shape` and `instanceof z.ZodObject` on the exported
|
|
27
|
+
* value. An effect would break the first and silently stop matching in the second, which
|
|
28
|
+
* is the worse failure: a guard that passes because it no longer looks at anything.
|
|
29
|
+
*/
|
|
30
|
+
export function wire<T extends z.ZodTypeAny>(schema: T): T {
|
|
31
|
+
return schema instanceof z.ZodObject ? (schema.strip() as unknown as T) : schema;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Rule id for the refusal below, so a caller can name it without matching on a message
|
|
35
|
+
* string. The Python seam spells the same id as `wire.base.JSON_NAME_ALIAS_ERROR`, and the
|
|
36
|
+
* two must stay equal: a face that reports the refusal as a validation finding — the
|
|
37
|
+
* publisher's entry pre-check does — carries this id in both languages. */
|
|
38
|
+
export const JSON_NAME_ALIAS_ERROR = "fora_json_name_alias";
|
|
39
|
+
|
|
40
|
+
/** The refusal parseWire raises. Carried as its own type so the caller above can map it to
|
|
41
|
+
* a typed protocol failure without matching on a message string. */
|
|
42
|
+
export class WireNamingError extends Error {
|
|
43
|
+
/** The offending key, as the peer spelled it. */
|
|
44
|
+
readonly key: string;
|
|
45
|
+
/** Dotted path to the message that carried it; "" at the root. */
|
|
46
|
+
readonly path: string;
|
|
47
|
+
|
|
48
|
+
constructor(key: string, path: string) {
|
|
49
|
+
super(
|
|
50
|
+
`peer answered with the lowerCamelCase json_name alias (${join(path, key)}); the ` +
|
|
51
|
+
"FORA wire is snake_case proto-JSON, so its answer cannot be read without " +
|
|
52
|
+
"silently dropping every multiword field",
|
|
53
|
+
);
|
|
54
|
+
this.name = "WireNamingError";
|
|
55
|
+
this.key = key;
|
|
56
|
+
this.path = path;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* parseWire validates one message against its generated schema, under the wire policy.
|
|
62
|
+
*
|
|
63
|
+
* A generated schema describes the message. It cannot describe the two things that are
|
|
64
|
+
* true of the WIRE rather than of the message, so both live here — applied in one
|
|
65
|
+
* schema-driven pass over the value, at every depth.
|
|
66
|
+
*
|
|
67
|
+
* **A `null` means the field has no value.** The canonical wire is proto-JSON, and there a
|
|
68
|
+
* null is a field's default — for ANY field, not only a message-typed one. So a null is
|
|
69
|
+
* dropped wherever the schema does not require a value: the field then reads as unset, or
|
|
70
|
+
* takes the default the schema declares, which is what the oracle answers for the same
|
|
71
|
+
* bytes. Where the schema DOES require a value the null is left in place for it to refuse.
|
|
72
|
+
*
|
|
73
|
+
* Both halves are load-bearing. EmitUnpopulated — what a FORA Exchange serves — renders an
|
|
74
|
+
* unpopulated non-optional message field as null rather than omitting it, so `{"ext":null}`
|
|
75
|
+
* is the ordinary shape of a real response. It renders an unset Timestamp that way too, and
|
|
76
|
+
* that is the case a narrower rule missed: the generator flattens a Timestamp to a string
|
|
77
|
+
* schema, so a test for "is this a message?" answered no and every response carrying an
|
|
78
|
+
* attestation without an attested-at, or a rate limit without a reset time, was refused
|
|
79
|
+
* outright. Pydantic spells all of these `X | None` and accepts the null, so reading
|
|
80
|
+
* presence rather than type is what keeps the two languages on one wire.
|
|
81
|
+
*
|
|
82
|
+
* **A lowerCamelCase answer is REFUSED.** The FORA wire is snake_case proto-JSON and the
|
|
83
|
+
* camelCase json_name alias is out of contract, so a conformant peer serves snake_case —
|
|
84
|
+
* connect-go does that only when a codec with UseProtoNames is registered, which a FORA
|
|
85
|
+
* deployment does and a stock connect-go server does not. The generated schemas accept
|
|
86
|
+
* snake_case only and STRIP what they do not recognise, so a camelCase answer would
|
|
87
|
+
* otherwise parse SUCCESSFULLY into a message with every multiword field missing, and
|
|
88
|
+
* nothing anywhere would say so.
|
|
89
|
+
*
|
|
90
|
+
* Depth is the point, for the refusal especially, and the money verb is why: a stock
|
|
91
|
+
* connect-go server omits unset fields, so a TransactionResponse arrives as
|
|
92
|
+
* `{ver, items}` — every root key a single word, identical in both spellings — while
|
|
93
|
+
* `transaction_id` sits one level down in TransactionResultItem and is dropped. That reads
|
|
94
|
+
* as a purchase that succeeded with no transaction id and no delivery URL, severing the
|
|
95
|
+
* dispute chain at its first link.
|
|
96
|
+
*
|
|
97
|
+
* Open maps are out of reach by construction rather than by a hold-back list: the pass is
|
|
98
|
+
* driven by the schema, and it stops at a map.
|
|
99
|
+
*
|
|
100
|
+
* Applied on the way in rather than baked into the schema, because the schema has to stay
|
|
101
|
+
* portable: the repo runs Zod 3 in the SDK trees and Zod 4 in the canonical round-trip
|
|
102
|
+
* gate, and the two disagree about how a schema is rebuilt — `.extend()` drops a
|
|
103
|
+
* description on one of them, and a reconstructed array loses its bounds. Expressing it in
|
|
104
|
+
* the JSON Schema instead would emit a union in place of every message field, which the
|
|
105
|
+
* from-wire inversion reads as a scalar. The Python twin needs neither workaround: a
|
|
106
|
+
* validator on WireModel is inherited by every nested model, so it recurses for free.
|
|
107
|
+
*/
|
|
108
|
+
export function parseWire<T>(
|
|
109
|
+
schema: { safeParse: (v: unknown) => { success: boolean; data?: unknown } },
|
|
110
|
+
raw: unknown,
|
|
111
|
+
): { success: true; data: T } | { success: false } {
|
|
112
|
+
const parsed = schema.safeParse(underWirePolicy(schema, raw, ""));
|
|
113
|
+
if (!parsed.success) return { success: false };
|
|
114
|
+
return { success: true, data: parsed.data as T };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const hasOwn = (o: object, k: PropertyKey): boolean => Object.prototype.hasOwnProperty.call(o, k);
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* underWirePolicy returns `value` with the wire policy applied, refusing a non-canonical
|
|
121
|
+
* field name. Exported so a guard can drive the policy without a parse behind it.
|
|
122
|
+
*
|
|
123
|
+
* The input is never mutated: a request goes through here too, and the caller keeps the
|
|
124
|
+
* object it passed.
|
|
125
|
+
*/
|
|
126
|
+
export function underWirePolicy(schema: unknown, value: unknown, path: string): unknown {
|
|
127
|
+
if (!(schema instanceof z.ZodType)) return value;
|
|
128
|
+
const core = unwrapped(schema as z.ZodTypeAny);
|
|
129
|
+
|
|
130
|
+
if (core instanceof z.ZodArray) {
|
|
131
|
+
if (!Array.isArray(value)) return value;
|
|
132
|
+
const element = core.element as z.ZodTypeAny;
|
|
133
|
+
return value.map((item, i) => underWirePolicy(element, item, `${path}[${i}]`));
|
|
134
|
+
}
|
|
135
|
+
// A map's keys are caller-chosen data. Nothing below one is a proto field name, and a
|
|
136
|
+
// null inside a Struct is a value (NullValue), not an absence.
|
|
137
|
+
if (!(core instanceof z.ZodObject)) return value;
|
|
138
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return value;
|
|
139
|
+
|
|
140
|
+
const shape = core.shape as Record<string, z.ZodTypeAny>;
|
|
141
|
+
const out: Record<string, unknown> = {};
|
|
142
|
+
for (const [key, member] of Object.entries(value as Record<string, unknown>)) {
|
|
143
|
+
// own-property check, not `shape[key] !== undefined`: a key like "__proto__" or
|
|
144
|
+
// "constructor" resolves to an inherited member of Object.prototype, which would
|
|
145
|
+
// read as a declared field and hand the walk something that is not a schema.
|
|
146
|
+
// hasOwnProperty.call, not Object.hasOwn: this file ships as source and compiles
|
|
147
|
+
// under the consumer's lib; Object.hasOwn needs lib ES2022.
|
|
148
|
+
if (!hasOwn(shape, key)) {
|
|
149
|
+
const name = snakeFromJsonName(key);
|
|
150
|
+
if (name !== key && hasOwn(shape, name)) throw new WireNamingError(key, path);
|
|
151
|
+
// defineProperty, not assignment: a key named "__proto__" would replace this
|
|
152
|
+
// object's prototype and create no member, so the value would never be walked
|
|
153
|
+
// and the schema would then read declared keys back through the prototype
|
|
154
|
+
// chain — accepting nested camelCase the depth check is here to refuse.
|
|
155
|
+
keep(out, key, member); // unknown to this pin; the schema strips it
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const field = shape[key];
|
|
159
|
+
if (field === undefined) continue;
|
|
160
|
+
// A null is how proto-JSON spells "no value here". Absent and null are the same
|
|
161
|
+
// state, so a field the schema does not require is left unset and one it does
|
|
162
|
+
// require keeps the null for the schema to refuse.
|
|
163
|
+
if (member === null && mayBeUnset(field)) continue;
|
|
164
|
+
keep(out, key, underWirePolicy(field, member, join(path, key)));
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** keep writes a member, including one whose name is an inherited property of every
|
|
170
|
+
* object. See the call sites: a plain assignment to "__proto__" sets a prototype. */
|
|
171
|
+
function keep(out: Record<string, unknown>, key: string, value: unknown): void {
|
|
172
|
+
Object.defineProperty(out, key, {
|
|
173
|
+
value,
|
|
174
|
+
enumerable: true,
|
|
175
|
+
writable: true,
|
|
176
|
+
configurable: true,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Peel the wrapper kinds that decorate a schema without changing what it holds: the ones
|
|
181
|
+
* the generator emits around a field, and the refinement the cross-field layer attaches
|
|
182
|
+
* around a whole message. `.unwrap()` and `.removeDefault()` are spelled the same way in
|
|
183
|
+
* Zod 3 and Zod 4 — which is the whole reason this reads rather than rebuilds. */
|
|
184
|
+
function unwrapped(schema: z.ZodTypeAny): z.ZodTypeAny {
|
|
185
|
+
let s = schema;
|
|
186
|
+
for (;;) {
|
|
187
|
+
if (s instanceof z.ZodOptional || s instanceof z.ZodNullable) {
|
|
188
|
+
s = s.unwrap() as z.ZodTypeAny;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (s instanceof z.ZodDefault) {
|
|
192
|
+
s = s.removeDefault() as z.ZodTypeAny;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
// A REFINEMENT wraps the object without changing what it holds. This is how the
|
|
196
|
+
// cross-field layer attaches its rules (sdk/ts/src/crossfield.ts), and without this
|
|
197
|
+
// branch the ZodObject test in underWirePolicy fails on every composed schema, so
|
|
198
|
+
// the whole wire policy is skipped for it: a camelCase answer parses successfully
|
|
199
|
+
// into a message with every multiword field missing, and the cross-field rule that
|
|
200
|
+
// needed one of those fields cannot fire. Peeling here is INSPECTION ONLY — parseWire
|
|
201
|
+
// still hands the ORIGINAL schema to safeParse, so the refinement still runs.
|
|
202
|
+
//
|
|
203
|
+
// Read as a method rather than through `instanceof z.ZodEffects`, because that class
|
|
204
|
+
// exists only in Zod 3. In Zod 4 a refinement keeps the schema's own class, so the
|
|
205
|
+
// ZodObject test already holds and there is nothing to peel — and naming the class
|
|
206
|
+
// would throw, since scripts/check-canonical.sh runs this file under Zod 4.
|
|
207
|
+
const innerType = (s as { innerType?: () => z.ZodTypeAny }).innerType;
|
|
208
|
+
if (typeof innerType === "function") {
|
|
209
|
+
// A PREPROCESS effect rewrites the input BEFORE the inner schema sees it, so the
|
|
210
|
+
// inner shape is not the shape of what arrives here and the policy would be
|
|
211
|
+
// applied against the wrong object. refine, superRefine and transform all check
|
|
212
|
+
// the input against the inner schema first, so for those the inner shape is right.
|
|
213
|
+
if (effectType(s) === "preprocess") return s;
|
|
214
|
+
s = innerType.call(s) as z.ZodTypeAny;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
return s;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Which kind of Zod 3 effect a schema carries. Only reached when `innerType` is present,
|
|
222
|
+
* which in Zod 3 is ZodEffects and nothing else. */
|
|
223
|
+
function effectType(s: z.ZodTypeAny): string {
|
|
224
|
+
return (s as unknown as { _def: { effect: { type: string } } })._def.effect.type;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Whether the schema would accept this field carrying no value at all.
|
|
229
|
+
*
|
|
230
|
+
* This is the whole test for whether a null may be dropped, and it is deliberately a
|
|
231
|
+
* question about PRESENCE rather than about type. The predicate it replaced asked whether
|
|
232
|
+
* the field held a message or a map, which reads as the same question and is not: the
|
|
233
|
+
* generator FLATTENS the well-known types, so a google.protobuf.Timestamp — a message on
|
|
234
|
+
* the wire, rendered as null when unset like any other — arrives here as a plain string
|
|
235
|
+
* schema and failed a structural test for messages. Every response carrying one was refused.
|
|
236
|
+
*
|
|
237
|
+
* Optional and defaulted both count, and the default is the reason the two are not one
|
|
238
|
+
* check: dropping the null on a defaulted field lets the schema supply the default, which
|
|
239
|
+
* is exactly what the oracle answers for the same bytes.
|
|
240
|
+
*/
|
|
241
|
+
function mayBeUnset(field: z.ZodTypeAny): boolean {
|
|
242
|
+
let s = field;
|
|
243
|
+
for (;;) {
|
|
244
|
+
if (s instanceof z.ZodOptional || s instanceof z.ZodDefault) return true;
|
|
245
|
+
if (s instanceof z.ZodNullable) {
|
|
246
|
+
s = s.unwrap() as z.ZodTypeAny;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function join(path: string, key: string): string {
|
|
254
|
+
return path === "" ? key : `${path}.${key}`;
|
|
255
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Recovering a proto field name from protojson's lowerCamelCase spelling of it.
|
|
2
|
+
// Hand-written; not regenerated. The Python twin is gen/python/wire/names.py, and the two
|
|
3
|
+
// are held to one answer by a shared vector set.
|
|
4
|
+
//
|
|
5
|
+
// This lives beside the generated schemas rather than in the SDK because the schema seam
|
|
6
|
+
// itself needs it: base.ts refuses an answer spelled in the json_name alias, and a rule
|
|
7
|
+
// the seam depends on cannot live in a tier above the seam. The SDK re-exports it, so
|
|
8
|
+
// there is one implementation per language rather than one per caller.
|
|
9
|
+
//
|
|
10
|
+
// The FORA wire is snake_case proto-JSON everywhere — proto field names, the corpus, the
|
|
11
|
+
// generated clients — and the camelCase alias is out of contract. Two places still have to
|
|
12
|
+
// reason about it:
|
|
13
|
+
//
|
|
14
|
+
// - Connect's error-detail `debug` projection IS lowerCamelCase and cannot be made
|
|
15
|
+
// otherwise. connect-go renders it with its own protojson codec at default options,
|
|
16
|
+
// inside a method on an unexported type, so the snake_case codec a FORA deployment
|
|
17
|
+
// registers reaches the response body and not the error beside it. That projection is
|
|
18
|
+
// normalized before parsing.
|
|
19
|
+
// - A response body from a server that registered no snake_case codec is lowerCamelCase
|
|
20
|
+
// throughout. That one is REFUSED rather than normalized: it is out of contract, and
|
|
21
|
+
// naming it is what turns a message with every multiword field silently missing into
|
|
22
|
+
// an error.
|
|
23
|
+
//
|
|
24
|
+
// The boundary test is ASCII `A-Z` and nothing else, deliberately. protojson builds
|
|
25
|
+
// json_name by uppercasing the character after each underscore, and a proto field name is
|
|
26
|
+
// [a-z_][a-z0-9_]*, so an ASCII uppercase letter is the only boundary protojson can
|
|
27
|
+
// produce. A broader test — "not equal to its own lowercase", or a Unicode uppercase
|
|
28
|
+
// predicate — answers differently for characters protojson never emits (titlecase `Dž` is
|
|
29
|
+
// the plain example), which is how three transcriptions of one rule drifted apart.
|
|
30
|
+
//
|
|
31
|
+
// The rewrite is textual. It is sound only while protojson's spelling inverts back to every
|
|
32
|
+
// field's name — it would not for a field like `field_2`, whose json_name is `field2` —
|
|
33
|
+
// which the conformance suite asserts for the whole contract.
|
|
34
|
+
/** Recover a proto field name from protojson's lowerCamelCase spelling of it. */
|
|
35
|
+
export function snakeFromJsonName(name) {
|
|
36
|
+
let out = "";
|
|
37
|
+
for (const ch of name) {
|
|
38
|
+
out += ch >= "A" && ch <= "Z" ? `_${ch.toLowerCase()}` : ch;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|