Skip to content

Commit 97d3f3e

Browse files
RBKunnelaclaude
andauthored
feat(sdk): conform pending-approval envelope to core wire contract (B1 slice 1) (#105)
Issue #118 — type the sdk's cross-boundary read of the pending-approval envelope and pin it to paybot-core's wire contract, per docs/architecture/adr-wire-contract-b1.md (B1 slice 1). The sdk does NOT import core (core is BUSL-1.1/private, sdk is MIT/public — a hard license boundary; the repos also run on independent cadences). Instead: - Add src/wire-contract.ts: a hand-declared `PendingEnvelopeWire` mirror of core's `PendingEnvelope`, plus `PENDING_ENVELOPE_WIRE_FIELDS` — a runtime field descriptor pinned to the type via `satisfies` (the compile-time lock). - Type `mapPendingEnvelope(envelope: Readonly<Partial<PendingEnvelopeWire>>)` and narrow `verifyData` at the pending branch in `pay()` (client.ts) — replacing the untyped `Record<string,unknown>` reads. Stays defensive (never-throws preserved). - Export the wire types from src/index.ts. Contract-conformance spine (tests/contract/pending-envelope.conformance.test.ts, 5 tests): vendors core's JSON-Schema snapshot at tests/fixtures/wire-contract.json and asserts `PendingEnvelopeWire` is structurally equal to it — exact field set, matching primitive types, and core's `.strict()` (`additionalProperties:false`) honored. A core field rename + re-vendored snapshot fails this test in CI; that failure IS the regression shield for #118 (drift caught at build, not in prod). Follow-up slices (per ADR, NOT in this slice): VerifyResponse, SettleResponse, the /approvals/{id} poll response, RefundResponse; a CI `test:contract` job. Gates: tsc clean, eslint src 0 errors, full suite green (544 pass, 0 skipped), client.ts coverage 95% stmts. wire-contract.ts is type+const only (no instrumentable lines), mirroring the repo's existing src/types.ts exclusion. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 229a2df commit 97d3f3e

5 files changed

Lines changed: 275 additions & 7 deletions

File tree

src/client.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
import { generateEIP3009Nonce } from './crypto.js';
2727
import { EIP3009_TYPES, getToken, getEip712Domain, resolveTokenAddress } from './networks.js';
2828
import { withSpan, type TelemetryConfig } from './telemetry.js';
29+
import type { PendingEnvelopeWire } from './wire-contract.js';
2930
import { privateKeyToAccount } from 'viem/accounts';
3031

3132
/** Default maximum number of cached idempotent results per client instance. */
@@ -436,7 +437,14 @@ export class PayBotClient {
436437
if (verifyData.status === 'pending_approval') {
437438
paySpan?.setAttribute('success', false);
438439
paySpan?.setAttribute('status', 'pending_approval');
439-
return mapPendingEnvelope(verifyData, request.amount);
440+
// Boundary narrow: the wire is untyped JSON (`Record<string,unknown>`);
441+
// here we conform it to the typed contract (issue #118, B1 slice 1).
442+
// `mapPendingEnvelope` stays defensive about missing/odd fields, so a
443+
// single contract type guards every downstream read.
444+
return mapPendingEnvelope(
445+
verifyData as Readonly<Partial<PendingEnvelopeWire>>,
446+
request.amount
447+
);
440448
}
441449

442450
const settlementToken = verifyData.settlementToken as string | undefined;
@@ -1168,14 +1176,21 @@ export function amountToBaseUnitsUsd(amountUsd: unknown): string {
11681176
* fields are populated per the existing pattern: gross/net = the requested amount
11691177
* (in base units), commission `'0'`/`0`.
11701178
*
1171-
* @param envelope - The parsed `/verify` pending envelope
1172-
* (`{ status, approval_id, poll_url, expires_at, amount_usd, reason, … }`).
1179+
* The `envelope` parameter is typed against {@link PendingEnvelopeWire} — the
1180+
* sdk's hand-declared mirror of core's `pendingEnvelopeSchema` (issue #118, B1).
1181+
* A core-side field rename now breaks this read at compile time, and the
1182+
* contract-conformance test pins {@link PendingEnvelopeWire} to core's vendored
1183+
* snapshot. `Partial` is intentional: the wire is untyped JSON at runtime, so the
1184+
* function stays defensive (a non-conforming server degrades gracefully rather
1185+
* than throwing) while the field NAMES and TYPES remain contract-checked.
1186+
*
1187+
* @param envelope - The parsed `/verify` pending envelope (see {@link PendingEnvelopeWire}).
11731188
* @param requestedAmount - The human-readable USD amount the bot requested,
11741189
* used as a fallback for gross/net when the envelope omits `amount_usd`.
11751190
* @returns A pending `PaymentResult`.
11761191
*/
11771192
export function mapPendingEnvelope(
1178-
envelope: Record<string, unknown>,
1193+
envelope: Readonly<Partial<PendingEnvelopeWire>>,
11791194
requestedAmount: string
11801195
): PaymentResult {
11811196
// Prefer the server's amount_usd (authoritative); fall back to the request.
@@ -1191,9 +1206,9 @@ export function mapPendingEnvelope(
11911206
commissionAmount: '0',
11921207
commissionRate: 0,
11931208
status: 'pending_approval',
1194-
approvalId: envelope.approval_id as string | undefined,
1195-
pollUrl: envelope.poll_url as string | undefined,
1196-
expiresAt: envelope.expires_at as string | undefined,
1209+
approvalId: envelope.approval_id,
1210+
pollUrl: envelope.poll_url,
1211+
expiresAt: envelope.expires_at,
11971212
...(typeof envelope.reason === 'string' ? { error: envelope.reason } : {}),
11981213
};
11991214
}

src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,8 @@ export type {
140140
VerifyWebhookSignatureOptions,
141141
SignWebhookPayloadOptions,
142142
} from './webhook.js';
143+
// Wire contract (issue #118, B1 slice 1) — the sdk's typed mirror of core's
144+
// pending-approval response shape, pinned to core's vendored snapshot by
145+
// `tests/contract/pending-envelope.conformance.test.ts`.
146+
export type { PendingEnvelopeWire, WireFieldType } from './wire-contract.js';
147+
export { PENDING_ENVELOPE_WIRE_FIELDS } from './wire-contract.js';

src/wire-contract.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* @module wire-contract
3+
*
4+
* The sdk's hand-declared mirror of paybot-core's cross-boundary **response**
5+
* shapes (issue #118, B1 slice 1). The sdk CANNOT import core's zod schemas:
6+
* core is `BUSL-1.1` + `private`, the sdk is `MIT` + public — a hard license
7+
* boundary, and the two repos run on independent cadences. Instead, core emits a
8+
* JSON-Schema snapshot (`dist/wire-contract.json`) from its exported schemas; the
9+
* sdk vendors that snapshot at `tests/fixtures/wire-contract.json` and a
10+
* contract-conformance test (`tests/contract/pending-envelope.conformance.test.ts`)
11+
* fails CI when this hand-declared shape and core's snapshot diverge.
12+
*
13+
* See `paybot-core/docs/architecture/adr-wire-contract-b1.md`.
14+
*
15+
* Slice 1: the pending-approval envelope only. Later slices add `VerifyResponse`,
16+
* `SettleResponse`, the `/approvals/{id}` poll response, etc.
17+
*
18+
* Dependencies: none (pure types + a runtime field descriptor).
19+
* Used by: `client.ts` (`mapPendingEnvelope` input type), and the conformance test.
20+
*/
21+
22+
/**
23+
* The pending-approval wire envelope the sdk reads from `/verify` when a payment
24+
* pauses for human approval (core contract §2a). This MUST stay structurally
25+
* equal to core's exported `PendingEnvelope` (`pendingEnvelopeSchema`). The
26+
* conformance test enforces that equality against core's vendored snapshot.
27+
*/
28+
export interface PendingEnvelopeWire {
29+
/** Discriminator the sdk keys off (not the HTTP code). */
30+
status: 'pending_approval';
31+
/** The server-issued approval handle (e.g. `ap_…`). */
32+
approval_id: string;
33+
/** Relative poll path for this approval (e.g. `/approvals/ap_…`). */
34+
poll_url: string;
35+
/** ISO-8601 expiry; past expiry the approval is treated as denied. */
36+
expires_at: string;
37+
/** The authoritative USD amount the server placed in the approval band. */
38+
amount_usd: number;
39+
/** Human-readable band reason. */
40+
reason: string;
41+
/** Audit sequence id for the `APPROVAL_REQUESTED` event. */
42+
audit_seq_id: number;
43+
/** Audit event hash for the `APPROVAL_REQUESTED` event. */
44+
audit_hash: string;
45+
}
46+
47+
/**
48+
* JSON-primitive type tags, matching the `type` field a zod → JSON-Schema dump
49+
* emits for each property. Used by the conformance test to compare this sdk
50+
* mirror against core's vendored snapshot field-by-field.
51+
*/
52+
export type WireFieldType = 'string' | 'number' | 'boolean';
53+
54+
/**
55+
* Runtime descriptor of {@link PendingEnvelopeWire}: every wire field mapped to
56+
* its JSON primitive type. This is the bridge between the compile-time type and
57+
* the runtime conformance check — TypeScript types are erased at runtime, so the
58+
* test compares THIS descriptor to core's JSON-Schema snapshot.
59+
*
60+
* The `satisfies Record<keyof PendingEnvelopeWire, WireFieldType>` below pins the
61+
* descriptor to the type: add/rename/remove a field in `PendingEnvelopeWire` and
62+
* this object fails `tsc` until it is updated in lock-step — which in turn makes
63+
* the conformance test re-check against core's snapshot.
64+
*/
65+
export const PENDING_ENVELOPE_WIRE_FIELDS = {
66+
status: 'string',
67+
approval_id: 'string',
68+
poll_url: 'string',
69+
expires_at: 'string',
70+
amount_usd: 'number',
71+
reason: 'string',
72+
audit_seq_id: 'number',
73+
audit_hash: 'string',
74+
} as const satisfies Record<keyof PendingEnvelopeWire, WireFieldType>;
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/**
2+
* @module tests/contract/pending-envelope.conformance
3+
*
4+
* THE CONTRACT-CONFORMANCE SPINE (issue #118, B1 slice 1 — sdk side).
5+
*
6+
* This is the cross-repo, cross-license bridge. The sdk cannot import core's zod
7+
* schema (core is BUSL-1.1/private, the sdk is MIT/public). Instead:
8+
*
9+
* 1. core emits `dist/wire-contract.json` (a JSON-Schema dump of its exported
10+
* `pendingEnvelopeSchema`) via `npm run build:contract`.
11+
* 2. the sdk VENDORS a copy at `tests/fixtures/wire-contract.json`.
12+
* 3. THIS test asserts the sdk's hand-declared `PendingEnvelopeWire`
13+
* (via its runtime descriptor `PENDING_ENVELOPE_WIRE_FIELDS`) is structurally
14+
* equal to core's snapshot definition — same field set, same primitive types,
15+
* and core's `.strict()` (`additionalProperties:false`) is honored.
16+
*
17+
* When core renames/adds/removes a pending-envelope field, regenerating the
18+
* snapshot and re-vendoring it makes this test FAIL until the sdk mirror is
19+
* updated in lock-step. That CI failure IS the regression shield for #118 — drift
20+
* is caught at build time, never in production.
21+
*
22+
* To refresh the snapshot after an intentional core change:
23+
* (in paybot-core) npm run build && npm run build:contract
24+
* then copy core's dist/wire-contract.json → this repo's tests/fixtures/.
25+
*
26+
* Test naming convention: `[CONTRACT] subject — should [behavior] when [condition]`.
27+
*/
28+
29+
import { describe, it, expect } from 'vitest';
30+
import wireContract from '../fixtures/wire-contract.json';
31+
import {
32+
PENDING_ENVELOPE_WIRE_FIELDS,
33+
type PendingEnvelopeWire,
34+
type WireFieldType,
35+
} from '../../src/wire-contract.js';
36+
37+
/** The shape of one JSON-Schema property entry we care about. */
38+
interface JsonSchemaProperty {
39+
type?: string;
40+
const?: unknown;
41+
}
42+
43+
/** The shape of one contract definition in the vendored snapshot. */
44+
interface JsonSchemaDefinition {
45+
type: string;
46+
properties: Record<string, JsonSchemaProperty>;
47+
required: string[];
48+
additionalProperties: boolean;
49+
}
50+
51+
const CONTRACT_NAME = 'PendingEnvelope';
52+
53+
function getDefinition(name: string): JsonSchemaDefinition {
54+
const defs = (wireContract as { definitions: Record<string, JsonSchemaDefinition> }).definitions;
55+
const def = defs[name];
56+
if (def === undefined) {
57+
throw new Error(
58+
`Vendored snapshot has no "${name}" definition. Re-run core \`build:contract\` and re-vendor tests/fixtures/wire-contract.json.`
59+
);
60+
}
61+
return def;
62+
}
63+
64+
/**
65+
* Map a JSON-Schema property to the sdk's primitive type tag, so the two sides
66+
* are comparable. A `const` literal (e.g. core's `z.literal('pending_approval')`)
67+
* still carries `type:'string'` in the dump, so this stays simple.
68+
*/
69+
function jsonSchemaTypeToWireType(prop: JsonSchemaProperty): WireFieldType {
70+
switch (prop.type) {
71+
case 'string':
72+
return 'string';
73+
case 'number':
74+
case 'integer':
75+
return 'number';
76+
case 'boolean':
77+
return 'boolean';
78+
default:
79+
throw new Error(`Unhandled JSON-Schema type "${String(prop.type)}" in snapshot.`);
80+
}
81+
}
82+
83+
describe('pending-envelope wire conformance (sdk ⊇⊆ core snapshot)', () => {
84+
const def = getDefinition(CONTRACT_NAME);
85+
const snapshotFields = Object.keys(def.properties).sort();
86+
const sdkFields = Object.keys(PENDING_ENVELOPE_WIRE_FIELDS).sort();
87+
88+
it('[CONTRACT] PendingEnvelopeWire — should declare EXACTLY core’s field set (happy)', () => {
89+
// Bidirectional: no field the sdk forgot, no field the sdk invented.
90+
expect(sdkFields).toEqual(snapshotFields);
91+
});
92+
93+
it('[CONTRACT] PendingEnvelopeWire — should match core’s primitive type for every field (happy)', () => {
94+
for (const field of snapshotFields) {
95+
const expected = jsonSchemaTypeToWireType(def.properties[field]);
96+
const actual = PENDING_ENVELOPE_WIRE_FIELDS[field as keyof typeof PENDING_ENVELOPE_WIRE_FIELDS];
97+
expect(actual, `field "${field}" type mismatch`).toBe(expected);
98+
}
99+
});
100+
101+
it('[CONTRACT] snapshot — should mark every field required AND forbid extras (.strict) (edge)', () => {
102+
// core uses `.strict()`; the dump must carry that through so drift fails closed.
103+
expect(def.additionalProperties).toBe(false);
104+
expect([...def.required].sort()).toEqual(snapshotFields);
105+
});
106+
107+
it('[CONTRACT] snapshot — should be a versioned paybot-core artifact (edge)', () => {
108+
// Guard against vendoring an unrelated/garbage JSON by accident.
109+
const meta = wireContract as { $contract?: string; version?: number; issue?: number };
110+
expect(meta.$contract).toBe('paybot-core wire contract');
111+
expect(typeof meta.version).toBe('number');
112+
expect(meta.issue).toBe(118);
113+
});
114+
115+
it('[CONTRACT] PendingEnvelopeWire — should keep status as the pending discriminator (happy)', () => {
116+
// The one field the sdk semantically depends on at the `pay()` branch.
117+
const statusProp = def.properties.status;
118+
expect(statusProp.type).toBe('string');
119+
expect(statusProp.const).toBe('pending_approval');
120+
// Compile-time anchor: a literal value still typed against the wire interface.
121+
const probe: Pick<PendingEnvelopeWire, 'status'> = { status: 'pending_approval' };
122+
expect(probe.status).toBe('pending_approval');
123+
});
124+
});

tests/fixtures/wire-contract.json

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
{
2+
"$contract": "paybot-core wire contract",
3+
"version": 1,
4+
"issue": 118,
5+
"generatedFrom": "paybot-core dist/index.js",
6+
"definitions": {
7+
"PendingEnvelope": {
8+
"$schema": "https://json-schema.org/draft/2020-12/schema",
9+
"type": "object",
10+
"properties": {
11+
"status": {
12+
"type": "string",
13+
"const": "pending_approval"
14+
},
15+
"approval_id": {
16+
"type": "string"
17+
},
18+
"poll_url": {
19+
"type": "string"
20+
},
21+
"expires_at": {
22+
"type": "string"
23+
},
24+
"amount_usd": {
25+
"type": "number"
26+
},
27+
"reason": {
28+
"type": "string"
29+
},
30+
"audit_seq_id": {
31+
"type": "number"
32+
},
33+
"audit_hash": {
34+
"type": "string"
35+
}
36+
},
37+
"required": [
38+
"status",
39+
"approval_id",
40+
"poll_url",
41+
"expires_at",
42+
"amount_usd",
43+
"reason",
44+
"audit_seq_id",
45+
"audit_hash"
46+
],
47+
"additionalProperties": false
48+
}
49+
}
50+
}

0 commit comments

Comments
 (0)