@fun-xyz/fiat-contract 0.2.1

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/README.md ADDED
@@ -0,0 +1,454 @@
1
+ # fiat-contract
2
+
3
+ The **published contract** between `fun-backend` (emits envelopes) and `funkit`/`connect-core`
4
+ (renders them). Both repos test against it; neither owns it.
5
+
6
+ Four things, zero runtime logic beyond validation:
7
+
8
+ | File | What it is |
9
+ | --- | --- |
10
+ | `src/types.ts` | `FlowState` · `Transition` · `FailureReason` · `Surface` · `Instructions` · `FormDescriptor` · `OrderStatus` · `StepResponse` |
11
+ | `src/schemas.ts` | zod mirrors of every type — the single runtime validator |
12
+ | `src/table.ts` | the transition table **as data**: per state, its legal transition set, the states any call from it may return, and `terminal: boolean` |
13
+ | `src/assert.ts` + `src/fixtures/` | `assertEnvelope` · `assertLegalEmission` · `assertLegalReturn` · `walkTable` · fixture loader + 16 recorded envelopes (inlined as data — no filesystem, so React Native can bundle it) |
14
+
15
+ ## Three entry points — production vs test-time
16
+
17
+ | Import | Weight | Contains | Used by |
18
+ | --- | --- | --- | --- |
19
+ | `@fun-xyz/fiat-contract/types` | **0.1 KB** (types erase) | every type; no runtime values | production, both repos |
20
+ | `@fun-xyz/fiat-contract/table` | **13.8 KB**, zero deps | `TRANSITION_TABLE`, `stateKey`, `tableEntry`, `isTerminal`, `walkTable`, `TABLE_VERSION`, `TERMINAL_ORDER_STATUSES`, `DOCUMENTED_ENDPOINTS` | **production frontend** + backend |
21
+ | `@fun-xyz/fiat-contract` | 47.6 KB, needs zod | the above + 46 zod schemas + assertions + 16 fixtures | tests, and backend dev/test guards |
22
+
23
+ `./table` is not a micro-optimisation. Terminality is table data a **shipped** client must read
24
+ (litmus rule 3 — clients never infer it), and Metro has no cross-module tree-shaking on by default,
25
+ so importing `isTerminal` from the root would ship zod and all 16 fixtures into a React Native
26
+ bundle. Two CI jobs hold that line: `consumer` asserts requiring `./table` never loads zod into the
27
+ process, and `metro` bundles `./table` with real Metro — in **both** package-exports modes, since
28
+ Metro before RN 0.79 ignores `exports` entirely — then greps the emitted bundle for zod, fixtures and
29
+ schemas and executes it.
30
+
31
+ Source of truth: [Fiat Client Contract](https://app.notion.com/p/3b9fc3b2a002815eb270fa4c818268cc)
32
+ (§The envelope · §Conformance package · §split `InputSpec` — ACCEPTED) and
33
+ [Fiat Frontend — State Machine & Screen Map](https://app.notion.com/p/3bbfc3b2a00281c994c2cebd17b1d6d3)
34
+ (✅ Decisions · per-screen State details · Event bindings per flow state).
35
+ Tracking: [Headless Fiat Onramp](https://linear.app/funxyz/project/headless-fiat-onramp-0147f4102399) ·
36
+ this package is [ENG-5268](https://linear.app/funxyz/issue/ENG-5268).
37
+
38
+ ## The litmus rules
39
+
40
+ 1. **Errors are fields, never states.** Every fallible state carries `error?: FailureReason`.
41
+ Stay-on-screen ⇒ an error field. Change-screen ⇒ a different state (session expiry just returns
42
+ a `SESSION_AUTH` envelope — there is no error routing table).
43
+ 2. **`params` = server literals · `inputs` = collected specs · `expects` = injected surface
44
+ results.** `body = {…params, …collected(inputs), …injected(expects)}`; a key collision across
45
+ the three is a contract violation, not last-write-wins. zod rejects a `FieldSpec` hiding in
46
+ `params`, so the old double-duty bug is unrepresentable.
47
+ 3. **Terminality comes from the table.** `terminal` is enumerated per state entry — never inferred
48
+ from `transitions.length`. `KYC ON_HOLD` carries `transitions: []` and is **not** terminal.
49
+ `ORDER{CREATED}` terminality rides `status ∈ TERMINAL_ORDER_STATUSES`, and a terminal status may
50
+ still carry a recovery CTA (`FAILED` + retryable `failureReason`).
51
+ 4. **`SUBMIT` is a user-fired request, not a POST.** The harness parses the verb off the endpoint
52
+ string; `GET /fiat/orders/:id` under `SUBMIT` is legal.
53
+ 5. **Fixtures stay synthetic.** `q_8f2`, `o_31c`, `eyJ…`, `"…"` — never paste a real session token,
54
+ bank field, or PII value into a fixture. Redaction applies to fixtures too.
55
+
56
+ ## Versioning — the package version IS the table version
57
+
58
+ `TABLE_VERSION` (exported from `src/table.ts`) must equal `package.json#version`; a test asserts it.
59
+ That version rides the capability handshake (`supportedProviders` / `supportedSurfaces` /
60
+ `supportedStepKinds` + table version), which is how BE↔FE skew stays explicit and designed-for:
61
+ the server never routes a flow into a state kind the installed SDK didn't declare.
62
+
63
+ Starting at **0.1.0**. Bump the minor for additive vocabulary (a new field, a new fixture); bump the
64
+ major when an existing shape changes meaning.
65
+
66
+ ## How each repo consumes it
67
+
68
+ **`fun-backend`** — adapters compile their provider's real flow into this state machine.
69
+
70
+ ```ts
71
+ import { assertEnvelope, assertLegalEmission, walkTable } from '@fun-xyz/fiat-contract';
72
+
73
+ assertLegalEmission(state, transitions); // per emission: adapter conformance
74
+ assertEnvelope(outgoing); // outgoing-envelope validation in dev/test
75
+ const owed = walkTable((entry) => entry.allowedTransitions); // what the adapter must emit
76
+ ```
77
+
78
+ Its own suites: adapter conformance (fixture-driven + property-generated states), outgoing-envelope
79
+ validation, and the scheduled provider-sandbox drift run diffed against `src/fixtures`.
80
+
81
+ **`connect-core`** — the harness obeys `transitions` for sequencing and owns rendering.
82
+
83
+ ```ts
84
+ import { FIXTURES, loadFixture, walkTable } from '@fun-xyz/fiat-contract';
85
+
86
+ walkTable((entry) => expect(computePage(entry.key)).toBeDefined()); // exhaustive resolver walk
87
+ FIXTURES.forEach(({ id }) => renderCold(loadFixture(id))); // stale-rule survival
88
+ ```
89
+
90
+ Its own suites: exhaustive `computePage` table-walk, fixture-driven cold-render tests, harness units
91
+ (any-state handling, same-state error re-entry retains form values, one-live-envelope focus gating,
92
+ idempotency-key reuse).
93
+
94
+ ## Usage
95
+
96
+ Everything below is compiled against the packed tarball in CI (`scripts/check-readme-examples.sh`),
97
+ so these examples cannot drift from the API.
98
+
99
+ ### Production: a backend route handler
100
+
101
+ The backend *builds* envelopes, so its production use is entirely compile-time — the types are the
102
+ verification. Runtime assertions stay behind a dev/test guard.
103
+
104
+ ```ts
105
+ import type { FlowState, StepResponse, Transition } from '@fun-xyz/fiat-contract/types';
106
+
107
+ export async function createOrder(quoteRef: string): Promise<StepResponse> {
108
+ const state: FlowState = {
109
+ kind: 'PAYMENT',
110
+ phase: 'INSTRUCT',
111
+ instructions: { kind: 'BANK_FIELDS', fields: await beneficiaryFields(), expiresAt },
112
+ };
113
+
114
+ const transitions: Transition[] = [
115
+ { id: 'confirm_paid', mode: 'SUBMIT', endpoint: `POST /fiat/orders/${orderId}/confirm-payment` },
116
+ // endpoint is a bounded union: `POST /fiat/orders/${string}/confirm-paymnt` is a compile error,
117
+ // and so is any path outside the documented /fiat/* surface
118
+ ];
119
+
120
+ if (!isProduction) {
121
+ const { assertLegalEmission } = await import('@fun-xyz/fiat-contract');
122
+ assertLegalEmission(state, transitions); // dev/test only — never on the hot path
123
+ }
124
+
125
+ return { state, provider: 'TRANSAK', transitions };
126
+ }
127
+ ```
128
+
129
+ What the types buy at compile time: a state kind that isn't in the union won't type, a transition
130
+ mode/shape mismatch won't type, and — since `FiatEndpoint` is a bounded union — neither will a
131
+ typo'd endpoint. No runtime cost.
132
+
133
+ ### Production: a frontend screen
134
+
135
+ The client narrows on the state and reads terminality from the table. Both imports are
136
+ production-safe: types erase, `./table` is 13.8 KB with no zod.
137
+
138
+ ```ts
139
+ import { isTerminal, stateKey } from '@fun-xyz/fiat-contract/table';
140
+ import type { StepResponse } from '@fun-xyz/fiat-contract/types';
141
+
142
+ export function FiatScreen({ env }: { env: StepResponse }) {
143
+ const page = computePage(stateKey(env.state), clientLocal); // client owns state → screen
144
+ const done = isTerminal(env.state); // table data, never inferred
145
+
146
+ if (env.state.kind === 'PAYMENT' && env.state.phase === 'INSTRUCT') {
147
+ return render(page, env.state.instructions); // narrowed: instructions exists here
148
+ }
149
+ if (env.state.kind === 'ORDER' && env.state.phase === 'CREATED') {
150
+ return render(page, { status: env.state.status, done });
151
+ }
152
+ return render(page, env.state);
153
+ }
154
+ ```
155
+
156
+ `isTerminal` is the whole reason `./table` exists as a runtime entry: `KYC ON_HOLD` carries
157
+ `transitions: []` and is **not** terminal, while `ORDER{CREATED, SETTLED}` is — no client may
158
+ re-derive that rule locally.
159
+
160
+ ### Production: the transitions loop
161
+
162
+ ```ts
163
+ import type { StepResponse, Transition } from '@fun-xyz/fiat-contract/types';
164
+
165
+ function useTransitions(env: StepResponse) {
166
+ return {
167
+ ctas: env.transitions.filter((t) => t.mode === 'SUBMIT'), // render buttons
168
+ poll: env.transitions.find((t) => t.mode === 'AWAIT'), // harness schedules
169
+ surface: env.transitions.find((t) => t.mode === 'CLIENT_SURFACE'), // harness mounts
170
+ };
171
+ }
172
+
173
+ function bodyFor(t: Transition, collected: Record<string, unknown>, surface?: Record<string, unknown>) {
174
+ if (t.mode === 'SUBMIT') return { ...t.params, ...collected }; // verb parsed from t.endpoint
175
+ if (t.mode === 'CLIENT_SURFACE') {
176
+ const injected = Object.fromEntries((t.report.expects ?? []).map((k) => [k, surface?.[k]]));
177
+ return { ...t.report.params, ...injected };
178
+ }
179
+ return { ...t.poll.params };
180
+ }
181
+ ```
182
+
183
+ Screens never inspect the array themselves; they receive `ctas` and bind labels
184
+ (`label(t.id) ?? t.labelFallback` — the contract carries no display copy). A key colliding across
185
+ `params` / `inputs` / `expects` is a contract violation, not last-write-wins.
186
+
187
+ ## Testing against it
188
+
189
+ These import from the root, which carries zod. Test-time only.
190
+
191
+ ### Validate an envelope at the boundary
192
+
193
+ `assertEnvelope` parses and returns a typed envelope, or throws `ContractViolation` listing every
194
+ problem. Use it on the way out of `fun-backend` (dev/test) and on the way in to `connect-core` tests.
195
+
196
+ ```ts
197
+ import { assertEnvelope, ContractViolation } from '@fun-xyz/fiat-contract';
198
+
199
+ try {
200
+ const envelope = assertEnvelope(await res.json());
201
+ // ^? StepResponse — state is a narrowable discriminated union from here on
202
+ if (envelope.state.kind === 'PAYMENT' && envelope.state.phase === 'INSTRUCT') {
203
+ render(envelope.state.instructions); // narrowed: instructions exists, quote does not
204
+ }
205
+ } catch (err) {
206
+ if (err instanceof ContractViolation) console.error(err.issues); // ['state.quote: Required', …]
207
+ throw err;
208
+ }
209
+ ```
210
+
211
+ ### Assert an emission is legal (backend adapter conformance)
212
+
213
+ Shape validity is not sequence validity. `assertLegalEmission` judges `(state, transitions)` against
214
+ the table: is each transition in this state's legal set, does a terminal state carry none, does a
215
+ `CLIENT_SURFACE` transition have a Surface in state, do `params`/`inputs`/`expects` collide.
216
+
217
+ ```ts
218
+ import { assertLegalEmission, checkLegalEmission } from '@fun-xyz/fiat-contract';
219
+
220
+ // Throwing form — use in adapter unit tests
221
+ assertLegalEmission(
222
+ { kind: 'SESSION_AUTH', channel: 'EMAIL_OTP' },
223
+ [{
224
+ id: 'verify',
225
+ mode: 'SUBMIT',
226
+ endpoint: 'POST /fiat/session/verify',
227
+ params: { quoteRef: 'q_8f2' }, // server literal, spread verbatim
228
+ inputs: { code: { type: 'TEXT', length: 6 } }, // spec the client collects
229
+ }],
230
+ );
231
+
232
+ // Non-throwing form — use when you want to report in bulk across many generated states
233
+ const { issues, unjudgeable } = checkLegalEmission(state, transitions);
234
+ if (!unjudgeable && issues.length) report(issues);
235
+ ```
236
+
237
+ `unjudgeable` is `true` only for states the docs publish no transition set for (`FUN_AUTH`) — it
238
+ means "cannot judge", never "passed".
239
+
240
+ ### Walk the table exhaustively (frontend resolver coverage)
241
+
242
+ The table is data, so a runtime walk proves every state resolves to a screen — stronger than TS
243
+ exhaustiveness alone, because it also fails when a *new* state is added to the contract.
244
+
245
+ ```ts
246
+ import { walkTable, isTerminal, TRANSITION_TABLE } from '@fun-xyz/fiat-contract';
247
+
248
+ it('every contract state resolves to a screen', () => {
249
+ walkTable((entry) => {
250
+ expect(computePage(entry.key)).toBeDefined();
251
+ });
252
+ });
253
+
254
+ // Terminality is table data — never `transitions.length === 0`
255
+ isTerminal({ kind: 'KYC', phase: 'NO_ACTION_REQUIRED', reason: 'ON_HOLD' }); // false: empty, not over
256
+ isTerminal({ kind: 'ORDER', phase: 'CREATED', status: 'SETTLED' }); // true
257
+ isTerminal({ kind: 'ORDER', phase: 'CREATED', status: 'PROCESSING' }); // false
258
+
259
+ TRANSITION_TABLE['PAYMENT/INSTRUCT'].mayReturn; // ['ORDER/CREATED']
260
+ ```
261
+
262
+ ### Render every fixture cold (stale-rule survival)
263
+
264
+ Any call may return any state, so every screen must render from a cold envelope with no prior
265
+ context. The fixtures are the FE doc's own envelopes, so this is a test against the spec.
266
+
267
+ ```ts
268
+ import { FIXTURES, loadFixture, assertFixture } from '@fun-xyz/fiat-contract';
269
+
270
+ FIXTURES.forEach(({ id, stateKey, docRef }) => {
271
+ it(`${id} renders cold (${docRef})`, () => {
272
+ const { envelope } = assertFixture(id); // validated + emission-legality checked
273
+ expect(() => renderCold(envelope)).not.toThrow();
274
+ expect(computePage(stateKey)).toBeDefined();
275
+ });
276
+ });
277
+
278
+ loadFixture('screen-10-kyc-on-hold'); // raw JSON, fresh deep copy, `unknown` — validate before use
279
+ ```
280
+
281
+ ### Use a schema directly
282
+
283
+ All 46 schemas are exported when you need to validate a fragment rather than a whole envelope.
284
+ They are typed `z.ZodType<T>`, so you get `.parse` / `.safeParse` / `.optional()` — not `.shape` or
285
+ `.extend`, deliberately.
286
+
287
+ ```ts
288
+ import { FlowStateSchema, QuoteSchema, TransitionSchema } from '@fun-xyz/fiat-contract';
289
+
290
+ const quote = QuoteSchema.parse(row.quote_json);
291
+ const result = FlowStateSchema.safeParse(input);
292
+ if (!result.success) log(result.error.issues);
293
+ void TransitionSchema;
294
+ ```
295
+
296
+ ### Check the handshake version
297
+
298
+ ```ts
299
+ import { TABLE_VERSION } from '@fun-xyz/fiat-contract';
300
+
301
+ const handshake = {
302
+ supportedProviders: ['TRANSAK'],
303
+ supportedSurfaces: ['PCI_COMPONENT', 'PAY_SHEET'],
304
+ supportedStepKinds: ['QUOTE', 'SESSION_AUTH', 'KYC', 'PAYMENT', 'ORDER', 'BLOCKED'],
305
+ tableVersion: TABLE_VERSION, // the package version IS the table version
306
+ };
307
+ ```
308
+
309
+ ## Install
310
+
311
+ ```bash
312
+ pnpm add -D @fun-xyz/fiat-contract
313
+ ```
314
+
315
+ ```ts
316
+ import type { StepResponse } from '@fun-xyz/fiat-contract/types'; // production, 0 KB
317
+ import { isTerminal } from '@fun-xyz/fiat-contract/table'; // production, 13.8 KB, no zod
318
+ import { assertEnvelope } from '@fun-xyz/fiat-contract'; // tests only, pulls zod
319
+ ```
320
+
321
+ Public npm — no `.npmrc`, no token, no registry config. Releases fire from a `v*` tag
322
+ (`.github/workflows/release.yml`), which refuses to publish when the tag and `package.json` version
323
+ disagree, and no-ops when that version is already published.
324
+
325
+ Sourcemaps are excluded from the tarball (`"!dist/**/*.map"` in `files`) — unlike the funkit SDK
326
+ packages, which publish theirs on purpose. These declarations carry open-decision commentary that has
327
+ no reason to reach consumers. 50.5 kB, 21 files.
328
+
329
+ ## How connect-core consumes this
330
+
331
+ connect-core **bundles `./table` and `./types` into its own `dist`** rather than shipping them as
332
+ resolvable dependencies. One line of build config, not a copy of the code: one authored table, pinned
333
+ in connect-core's lockfile, recompiled into its output every build. Copy-pasting the table into
334
+ `connect-core/src` is not this, and would defeat the package.
335
+
336
+ ```js
337
+ // funkit/packages/connect-core/build.config.js — inside the externalize plugin
338
+ const BUNDLED_SPECIFIERS = new Set([
339
+ '@fun-xyz/fiat-contract/table',
340
+ '@fun-xyz/fiat-contract/types',
341
+ ]);
342
+ build.onResolve({ filter }, (args) =>
343
+ BUNDLED_SPECIFIERS.has(args.path)
344
+ ? undefined // let esbuild bundle it in
345
+ : { external: true, path: args.path }, // everything else stays external
346
+ );
347
+ ```
348
+
349
+ Why bundle rather than depend:
350
+
351
+ - **Pins the tested pair.** As a dependency, a partner's install resolves the semver range at *their*
352
+ install time, so an SDK tested against table 0.2.1 could run against 0.3.0 in the field. Bundling
353
+ compiles `TABLE_VERSION` into the release, which is what the handshake then reports.
354
+ - **Metro never sees the subpath**, so the package-exports question (§Packaging) cannot reach a
355
+ partner's bundler.
356
+
357
+ Consequences:
358
+
359
+ - The build exception needs its comment, or it gets deleted as dead config. A test in connect-core
360
+ asserts the table inlines and that zod and the fixtures stay out.
361
+ - `connect` / `connect-rn` should import the table *from connect-core*, not bundle their own copy
362
+ (~14 KB apiece).
363
+ - The shipped table version is not readable from a partner's `node_modules` — read the handshake.
364
+
365
+ The root entry stays a plain `devDependency` in both repos: test-only, so it never reaches a published
366
+ artifact.
367
+
368
+ ## Packaging
369
+
370
+ Dual CJS + ESM, same shape as `@funkit/fun-relay`, because the two consumers load it differently:
371
+
372
+ | Requirement | Source | How it's met |
373
+ | --- | --- | --- |
374
+ | `require()` must work | `fun-backend/apps/api-server` compiles `module: commonjs` and runs plain node — no bundler | esbuild emits `dist/index.js` (CJS) + `dist/index.mjs` (ESM); `exports` maps `require`/`import`; no `"type": "module"` |
375
+ | Zero Node builtins | `connect-core` is React Native — Metro cannot resolve `node:fs` | fixtures are inlined as generated TS data (`src/fixtures/data.ts`), so nothing touches the filesystem; `platform: browser`, every bare import external |
376
+ | Subpath imports must resolve without `exports` support | Metro only reads `package.json#exports` from RN 0.79 on, and `@funkit/connect-rn` accepts `react-native: >=0.74` | root compat stubs `table.js` / `types.js` (+ `.d.ts`) that re-export `dist/`. Without them Metro fails with *"Unable to resolve module"* — verified, not theorised. Resolvers that do read `exports` never see the stubs |
377
+ | Production code must not pull zod or fixtures | `connect-core` ships to React Native, where Metro does not tree-shake unused exports by default | three entry points: `./types` (erased), `./table` (13.8 KB, zero deps), root (schemas + fixtures, test-time). CI asserts `require('.../table')` never loads zod |
378
+ | `.d.ts` must not lock a zod major | Both repos run `skipLibCheck: true`, which turns a broken declaration into a silent `any` | every exported schema is annotated `z.ZodType<T>`, so declarations name only `z.ZodType`; the structural drift checks stay module-private |
379
+
380
+ Declarations come from `tsc --emitDeclarationOnly`; esbuild only emits JS. The fixture `.json` files
381
+ remain the verbatim record — `data.ts` is generated from them and CI fails if it drifts.
382
+
383
+ ## Scripts
384
+
385
+ ```
386
+ npm run typecheck # tsc --noEmit, strict; includes the schema↔type mirror assertions
387
+ npm test # vitest: fixtures, zod round-trip, table integrity, negative asserts
388
+ npm run generate:fixtures # regenerate src/fixtures/data.ts from the .json record
389
+ npm run build # tsc declarations + esbuild dual CJS/ESM into dist/
390
+ npm run check # typecheck + test
391
+
392
+ ./scripts/verify-consumer.sh 3.23.8 pnpm # pack, install, require/import, and typecheck a
393
+ # consumer with skipLibCheck OFF — catches packaging
394
+ # faults the in-repo suite structurally cannot
395
+ ./scripts/check-readme-examples.sh # compile every ```ts block in this README against
396
+ # the packed package
397
+ ./scripts/verify-metro.sh # bundle ./table with real Metro in both
398
+ # package-exports modes; assert no zod/fixtures reach
399
+ # the bundle, and that it executes
400
+ ```
401
+
402
+ ## Dependencies
403
+
404
+ zod is an **optional peer**, range `^3.22.0 || ^4.0.0`. Only the root entry needs it; `./table` and
405
+ `./types` have no runtime dependency on zod, so an install that never imports the root entry needs no
406
+ zod at all.
407
+
408
+ | Repo | zod | Notes |
409
+ | --- | --- | --- |
410
+ | `funkit` (pnpm 9.4) | **3.23.8** (`apps/fits`, `apps/frog`) | `connect-core` declares it explicitly — required, see below |
411
+ | `fun-backend` (pnpm 10.16) | **3.25.76** | forced tree-wide by `pnpm.overrides` |
412
+
413
+ Neither repo is on zod 4; the `|| ^4.0.0` arm is verified, not required. CI runs the suite and a
414
+ packed-tarball consumer check on 3.22.4, 3.23.8, 3.25.76 and 4.4.3. (zod `3.25.0` exactly is
415
+ unusable — that release ships no `dist/`; 3.25.1+ is fine.)
416
+
417
+ **A consumer importing the root entry must declare zod itself.** The peer being optional means pnpm
418
+ installs none by default, so a `test/**` import of the root entry fails with a missing module — loud,
419
+ and the reason the peer is optional. When it was a required peer, pnpm instead resolved zod 4 and,
420
+ under `dedupe-peer-dependents`, moved funkit's viem onto it, which `abitype`'s `^3 >=3.22.0` peer
421
+ rejects.
422
+
423
+ Declarations never bake in a zod major: every exported schema is annotated `z.ZodType<T>`. Consumers
424
+ get `.parse` / `.safeParse` / `.optional()`, not `.shape` / `.extend` — a contract you validate
425
+ against, not compose from.
426
+
427
+ ## Open decisions — encoded, not resolved
428
+
429
+ Every one of these is marked `// TODO(open-decision): <doc ref>` at the site that encodes it.
430
+ The current documented shape is what ships; none of these are settled here.
431
+
432
+ | Open item | How 0.1.0 encodes it |
433
+ | --- | --- |
434
+ | `orderId` placement | Both: optional on `PAYMENT{INSTRUCT}` (FE doc v0) **and** optional as an envelope sibling (contract worked example). One fixture of each. |
435
+ | `PENDING_ORDER` removal | Kind ships, with the removal proposal flagged on the type, the table entry, and the fixture. Screen 12 stays frozen. |
436
+ | `[OQ7]` failure enumeration | The published `FailureReason` taxonomy only. Expired instructions, partial payment, per-rail cancel eligibility, and terminal-vs-escalating rejections are flagged unenumerated. |
437
+ | Screen 11 escalation trigger | Encoded as published (`SUBMIT GET /fiat/kyc` + `params: {tier}`) with the doc's own warning that a GET carrying params is not a real shape. |
438
+ | Card capture report target | Both topologies are legal in the table (`POST /fiat/orders` for capture-then-order; `POST /fiat/orders/:id/surface-result` otherwise) pending the Transak answer. |
439
+ | Cancel placement | `cancel` is legal on `PAYMENT{INSTRUCT}` and `ORDER{CREATED}`, marked conditional on the placement decision. |
440
+ | `FUN_AUTH` shape | `challenge: Record<string, JsonValue>`; the table entry is `docStatus: 'UNSPECIFIED'`, so `assertLegalEmission` reports it unjudgeable instead of guessing. |
441
+ | `statusHistory` element shape | The documented minimum (`{status}`) — no invented timestamps. |
442
+ | `QR_IMAGE` instruction | In the union per §The envelope, flagged against OQ1's "deliberately not pre-declared". |
443
+
444
+ Two fixture gaps are declared in `FIXTURE_COVERAGE_GAPS` rather than filled with invented envelopes:
445
+ `FUN_AUTH` (shape owned by the auth spike) and `KYC/CAPTURE` (Screen 8 tombstone, dropped from v1).
446
+ Every other state has a recorded envelope.
447
+
448
+ ## Not decided here — needs a human
449
+
450
+ - **Whether `PENDING_ORDER` ships in the types** or waits for the removal decision. It ships
451
+ today (flagged everywhere) so the current server shape validates.
452
+ - **Repo hosting / org placement.** The docs recommend a standalone repo (option B) or `fun-backend`
453
+ (option C) — explicitly **not** funkit. Backend sign-off is pending; this repo is standalone,
454
+ which is reversible either way.
@@ -0,0 +1,66 @@
1
+ /**
2
+ * fiat-contract — assertion helpers. The only executable code in the package, and it does
3
+ * exactly two things: validate shapes (zod) and judge emissions against the table.
4
+ *
5
+ * Test *suites* live in the consumer repos next to the code they test:
6
+ * - `fun-backend`: adapter conformance (every emission ∈ the legal set), outgoing-envelope
7
+ * validation in dev/test, the scheduled provider drift run.
8
+ * - `connect-core`: exhaustive `computePage` table-walk, fixture-driven cold-render tests,
9
+ * harness units.
10
+ */
11
+ import { type EndpointTemplate, type StateKey, type TableEntry } from './table';
12
+ import { type FixtureMeta } from './fixtures/index';
13
+ import type { FlowState, StepResponse, Transition } from './types';
14
+ /** Thrown for anything the contract calls a violation — 4xx-class, never a user-facing state. */
15
+ export declare class ContractViolation extends Error {
16
+ readonly issues: readonly string[];
17
+ constructor(message: string, issues?: readonly string[]);
18
+ }
19
+ /** Validate a `/fiat/*` response envelope. Returns the parsed envelope; throws on violation. */
20
+ export declare function assertEnvelope(json: unknown): StepResponse;
21
+ /**
22
+ * Validate an envelope whose `provider` is absent — only doc excerpts look like this
23
+ * (see `src/fixtures`: fixtures marked `complete: false`). Never valid on the wire.
24
+ */
25
+ export declare function assertEnvelopeFragment(json: unknown): Omit<StepResponse, 'provider'>;
26
+ /** `"POST /fiat/orders/:id/cancel"` matches `"POST /fiat/orders/o_31c/cancel"`. */
27
+ export declare function endpointMatches(template: EndpointTemplate, actual: string): boolean;
28
+ export interface EmissionCheck {
29
+ state: StateKey;
30
+ issues: readonly string[];
31
+ /** True when the docs publish no legal transition set for this state (`FUN_AUTH`). */
32
+ unjudgeable: boolean;
33
+ }
34
+ /**
35
+ * Every issue with `(state, transitions)` as an emission — the backend's conformance check.
36
+ * Returns findings instead of throwing so adapters can report in bulk; `assertLegalEmission`
37
+ * is the throwing wrapper.
38
+ */
39
+ export declare function checkLegalEmission(state: FlowState, transitions: readonly Transition[]): EmissionCheck;
40
+ /** Throwing form of `checkLegalEmission` — the assertion adapters and harness tests use. */
41
+ export declare function assertLegalEmission(state: FlowState, transitions: readonly Transition[]): void;
42
+ /**
43
+ * Assert a response's state is one the table says `from` may receive back.
44
+ *
45
+ * The stale rule means any call may in fact return any state, so this is a conformance
46
+ * assertion on the server's *typical* sequence — never a client-side gate on rendering.
47
+ * A state whose bindings row is unpublished (`mayReturn` absent) cannot be judged.
48
+ */
49
+ export declare function assertLegalReturn(from: FlowState, to: FlowState): void;
50
+ /**
51
+ * Visit every state entry exactly once. `connect-core`'s exhaustiveness test walks this to prove
52
+ * every kind × phase resolves to a screen; `fun-backend` walks it to enumerate what it must emit.
53
+ */
54
+ export declare function walkTable<T>(fn: (entry: TableEntry, key: StateKey) => T): T[];
55
+ export interface ValidatedFixture {
56
+ meta: FixtureMeta;
57
+ /** `provider` is absent on fragment fixtures (`meta.complete === false`). */
58
+ envelope: Omit<StepResponse, 'provider'> & {
59
+ provider?: StepResponse['provider'];
60
+ };
61
+ }
62
+ /** Load one fixture, validate its shape, and assert its emission is legal for its state. */
63
+ export declare function assertFixture(id: string): ValidatedFixture;
64
+ /** Validate every recorded fixture. Throws on the first violation. */
65
+ export declare function assertAllFixtures(): ValidatedFixture[];
66
+ //# sourceMappingURL=assert.d.ts.map