@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 +454 -0
- package/dist/assert.d.ts +66 -0
- package/dist/chunk-IUYT5BR2.mjs +412 -0
- package/dist/chunk-KIQOUUEZ.mjs +1 -0
- package/dist/fixtures/data.d.ts +8 -0
- package/dist/fixtures/index.d.ts +55 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +1665 -0
- package/dist/index.mjs +1253 -0
- package/dist/schemas.d.ts +98 -0
- package/dist/table.d.ts +97 -0
- package/dist/table.js +432 -0
- package/dist/table.mjs +21 -0
- package/dist/types.d.ts +486 -0
- package/dist/types.js +19 -0
- package/dist/types.mjs +2 -0
- package/package.json +81 -0
- package/table.d.ts +1 -0
- package/table.js +5 -0
- package/types.d.ts +1 -0
- package/types.js +3 -0
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.
|
package/dist/assert.d.ts
ADDED
|
@@ -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
|