@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,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
+ }