@ophirai/sdk 0.1.0

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 (60) hide show
  1. package/README.md +139 -0
  2. package/dist/__tests__/buyer.test.d.ts +1 -0
  3. package/dist/__tests__/buyer.test.js +664 -0
  4. package/dist/__tests__/discovery.test.d.ts +1 -0
  5. package/dist/__tests__/discovery.test.js +188 -0
  6. package/dist/__tests__/escrow.test.d.ts +1 -0
  7. package/dist/__tests__/escrow.test.js +385 -0
  8. package/dist/__tests__/identity.test.d.ts +1 -0
  9. package/dist/__tests__/identity.test.js +222 -0
  10. package/dist/__tests__/integration.test.d.ts +1 -0
  11. package/dist/__tests__/integration.test.js +681 -0
  12. package/dist/__tests__/lockstep.test.d.ts +1 -0
  13. package/dist/__tests__/lockstep.test.js +320 -0
  14. package/dist/__tests__/messages.test.d.ts +1 -0
  15. package/dist/__tests__/messages.test.js +976 -0
  16. package/dist/__tests__/negotiation.test.d.ts +1 -0
  17. package/dist/__tests__/negotiation.test.js +667 -0
  18. package/dist/__tests__/seller.test.d.ts +1 -0
  19. package/dist/__tests__/seller.test.js +767 -0
  20. package/dist/__tests__/server.test.d.ts +1 -0
  21. package/dist/__tests__/server.test.js +239 -0
  22. package/dist/__tests__/signing.test.d.ts +1 -0
  23. package/dist/__tests__/signing.test.js +713 -0
  24. package/dist/__tests__/sla.test.d.ts +1 -0
  25. package/dist/__tests__/sla.test.js +342 -0
  26. package/dist/__tests__/transport.test.d.ts +1 -0
  27. package/dist/__tests__/transport.test.js +197 -0
  28. package/dist/__tests__/x402.test.d.ts +1 -0
  29. package/dist/__tests__/x402.test.js +141 -0
  30. package/dist/buyer.d.ts +190 -0
  31. package/dist/buyer.js +555 -0
  32. package/dist/discovery.d.ts +47 -0
  33. package/dist/discovery.js +51 -0
  34. package/dist/escrow.d.ts +177 -0
  35. package/dist/escrow.js +434 -0
  36. package/dist/identity.d.ts +60 -0
  37. package/dist/identity.js +108 -0
  38. package/dist/index.d.ts +122 -0
  39. package/dist/index.js +43 -0
  40. package/dist/lockstep.d.ts +94 -0
  41. package/dist/lockstep.js +127 -0
  42. package/dist/messages.d.ts +172 -0
  43. package/dist/messages.js +262 -0
  44. package/dist/negotiation.d.ts +113 -0
  45. package/dist/negotiation.js +214 -0
  46. package/dist/seller.d.ts +127 -0
  47. package/dist/seller.js +395 -0
  48. package/dist/server.d.ts +52 -0
  49. package/dist/server.js +149 -0
  50. package/dist/signing.d.ts +98 -0
  51. package/dist/signing.js +165 -0
  52. package/dist/sla.d.ts +95 -0
  53. package/dist/sla.js +187 -0
  54. package/dist/transport.d.ts +41 -0
  55. package/dist/transport.js +127 -0
  56. package/dist/types.d.ts +86 -0
  57. package/dist/types.js +1 -0
  58. package/dist/x402.d.ts +25 -0
  59. package/dist/x402.js +54 -0
  60. package/package.json +40 -0
@@ -0,0 +1,262 @@
1
+ import { v4 as uuidv4 } from 'uuid';
2
+ import { METHODS, DEFAULT_CONFIG, OphirError, OphirErrorCode } from '@ophirai/protocol';
3
+ import { signMessage, agreementHash } from './signing.js';
4
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5
+ /** Validate that a string is non-empty. */
6
+ function requireNonEmpty(value, name) {
7
+ if (!value || typeof value !== 'string' || value.trim().length === 0) {
8
+ throw new OphirError(OphirErrorCode.INVALID_MESSAGE, `${name} must be a non-empty string`);
9
+ }
10
+ }
11
+ /** Validate that a string looks like a UUID. */
12
+ function requireUUID(value, name) {
13
+ requireNonEmpty(value, name);
14
+ if (!UUID_RE.test(value)) {
15
+ throw new OphirError(OphirErrorCode.INVALID_MESSAGE, `${name} must be a valid UUID, got: ${value}`);
16
+ }
17
+ }
18
+ /** Compute an ISO 8601 expiration timestamp from a TTL in milliseconds. */
19
+ function expiresAt(ttlMs) {
20
+ return new Date(Date.now() + ttlMs).toISOString();
21
+ }
22
+ /** Wrap params in a JSON-RPC 2.0 request envelope with a UUID id. */
23
+ function rpcEnvelope(method, params) {
24
+ return { jsonrpc: '2.0', method, id: uuidv4(), params };
25
+ }
26
+ /**
27
+ * Build a signed RFQ (Request for Quote) message. Signs the RFQ with the buyer's Ed25519 key
28
+ * so that sellers can verify the buyer authorized this request.
29
+ *
30
+ * @throws {OphirError} if buyer identity fields are missing.
31
+ * @throws {OphirError} if secretKey is not 64 bytes.
32
+ *
33
+ * @example
34
+ * ```typescript
35
+ * const rfq = buildRFQ({
36
+ * buyer: { agent_id: 'did:key:z6Mk...', endpoint: 'https://buyer.example.com' },
37
+ * service: { category: 'inference' },
38
+ * budget: { max_price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
39
+ * secretKey: keypair.secretKey,
40
+ * });
41
+ * ```
42
+ */
43
+ export function buildRFQ(params) {
44
+ requireNonEmpty(params.buyer.agent_id, 'buyer.agent_id');
45
+ requireNonEmpty(params.buyer.endpoint, 'buyer.endpoint');
46
+ if (params.secretKey.length !== 64) {
47
+ throw new OphirError(OphirErrorCode.INVALID_SIGNATURE, `Invalid secret key length: expected 64, got ${params.secretKey.length}`);
48
+ }
49
+ const unsigned = {
50
+ rfq_id: uuidv4(),
51
+ buyer: params.buyer,
52
+ service: params.service,
53
+ budget: params.budget,
54
+ sla_requirements: params.sla,
55
+ negotiation_style: 'rfq',
56
+ max_rounds: params.maxRounds ?? DEFAULT_CONFIG.max_negotiation_rounds,
57
+ expires_at: expiresAt(params.ttlMs ?? DEFAULT_CONFIG.rfq_timeout_ms),
58
+ accepted_payments: params.acceptedPayments,
59
+ };
60
+ const signature = signMessage(unsigned, params.secretKey);
61
+ const rfqParams = { ...unsigned, signature };
62
+ return rpcEnvelope(METHODS.RFQ, rfqParams);
63
+ }
64
+ /**
65
+ * Build a signed Quote response message. Signs the quote with the seller's Ed25519 key.
66
+ *
67
+ * @throws {OphirError} if rfqId or seller identity fields are missing.
68
+ *
69
+ * @example
70
+ * ```typescript
71
+ * const quote = buildQuote({
72
+ * rfqId: 'a1b2c3d4-...',
73
+ * seller: { agent_id: 'did:key:z6Mk...', endpoint: 'https://seller.example.com' },
74
+ * pricing: { price_per_unit: '0.005', currency: 'USDC', unit: 'request', pricing_model: 'fixed' },
75
+ * secretKey: keypair.secretKey,
76
+ * });
77
+ * ```
78
+ */
79
+ export function buildQuote(params) {
80
+ requireNonEmpty(params.rfqId, 'rfqId');
81
+ requireNonEmpty(params.seller.agent_id, 'seller.agent_id');
82
+ requireNonEmpty(params.seller.endpoint, 'seller.endpoint');
83
+ if (params.secretKey.length !== 64) {
84
+ throw new OphirError(OphirErrorCode.INVALID_SIGNATURE, `Invalid secret key length: expected 64, got ${params.secretKey.length}`);
85
+ }
86
+ const unsigned = {
87
+ quote_id: uuidv4(),
88
+ rfq_id: params.rfqId,
89
+ seller: params.seller,
90
+ pricing: params.pricing,
91
+ sla_offered: params.sla,
92
+ execution: params.execution,
93
+ escrow_requirement: params.escrow,
94
+ expires_at: expiresAt(params.ttlMs ?? DEFAULT_CONFIG.quote_timeout_ms),
95
+ };
96
+ const signature = signMessage(unsigned, params.secretKey);
97
+ const quoteParams = { ...unsigned, signature };
98
+ return rpcEnvelope(METHODS.QUOTE, quoteParams);
99
+ }
100
+ /**
101
+ * Build a signed counter-offer message. Signs with the sender's Ed25519 key.
102
+ *
103
+ * @throws {OphirError} if rfqId, inResponseTo, or from.agent_id are missing.
104
+ *
105
+ * @example
106
+ * ```typescript
107
+ * const counter = buildCounter({
108
+ * rfqId: 'a1b2c3d4-...',
109
+ * inResponseTo: 'e5f6g7h8-...',
110
+ * round: 1,
111
+ * from: { agent_id: 'did:key:z6Mk...', role: 'buyer' },
112
+ * modifications: { price_per_unit: '0.008' },
113
+ * secretKey: keypair.secretKey,
114
+ * });
115
+ * ```
116
+ */
117
+ export function buildCounter(params) {
118
+ requireNonEmpty(params.rfqId, 'rfqId');
119
+ requireNonEmpty(params.inResponseTo, 'inResponseTo');
120
+ requireNonEmpty(params.from.agent_id, 'from.agent_id');
121
+ if (params.secretKey.length !== 64) {
122
+ throw new OphirError(OphirErrorCode.INVALID_SIGNATURE, `Invalid secret key length: expected 64, got ${params.secretKey.length}`);
123
+ }
124
+ const unsigned = {
125
+ counter_id: uuidv4(),
126
+ rfq_id: params.rfqId,
127
+ in_response_to: params.inResponseTo,
128
+ round: params.round,
129
+ from: params.from,
130
+ modifications: params.modifications,
131
+ justification: params.justification,
132
+ expires_at: expiresAt(params.ttlMs ?? DEFAULT_CONFIG.counter_timeout_ms),
133
+ };
134
+ const signature = signMessage(unsigned, params.secretKey);
135
+ const counterParams = { ...unsigned, signature };
136
+ return rpcEnvelope(METHODS.COUNTER, counterParams);
137
+ }
138
+ /**
139
+ * Build an Accept message with agreement hash and buyer signature.
140
+ *
141
+ * Validates that finalTerms contains the required fields: price_per_unit, currency, and unit.
142
+ *
143
+ * @throws {OphirError} if rfqId, acceptingMessageId, or required finalTerms fields are missing.
144
+ *
145
+ * @example
146
+ * ```typescript
147
+ * const accept = buildAccept({
148
+ * rfqId: 'a1b2c3d4-...',
149
+ * acceptingMessageId: 'e5f6g7h8-...',
150
+ * finalTerms: { price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
151
+ * buyerSecretKey: keypair.secretKey,
152
+ * });
153
+ * ```
154
+ */
155
+ export function buildAccept(params) {
156
+ requireNonEmpty(params.rfqId, 'rfqId');
157
+ requireNonEmpty(params.acceptingMessageId, 'acceptingMessageId');
158
+ if (!params.finalTerms || typeof params.finalTerms !== 'object') {
159
+ throw new OphirError(OphirErrorCode.INVALID_MESSAGE, 'finalTerms must be a non-null object');
160
+ }
161
+ if (!params.finalTerms.price_per_unit || !params.finalTerms.currency || !params.finalTerms.unit) {
162
+ throw new OphirError(OphirErrorCode.INVALID_MESSAGE, 'finalTerms must contain price_per_unit, currency, and unit');
163
+ }
164
+ if (params.buyerSecretKey.length !== 64) {
165
+ throw new OphirError(OphirErrorCode.INVALID_SIGNATURE, `Invalid buyer secret key length: expected 64, got ${params.buyerSecretKey.length}`);
166
+ }
167
+ const hash = agreementHash(params.finalTerms);
168
+ const unsigned = {
169
+ agreement_id: uuidv4(),
170
+ rfq_id: params.rfqId,
171
+ accepting_message_id: params.acceptingMessageId,
172
+ final_terms: params.finalTerms,
173
+ agreement_hash: hash,
174
+ };
175
+ const buyerSig = signMessage(unsigned, params.buyerSecretKey);
176
+ const acceptParams = {
177
+ ...unsigned,
178
+ buyer_signature: buyerSig,
179
+ ...(params.sellerSignature ? { seller_signature: params.sellerSignature } : {}),
180
+ };
181
+ return rpcEnvelope(METHODS.ACCEPT, acceptParams);
182
+ }
183
+ /**
184
+ * Build a signed Reject message to decline a negotiation. Signs with the rejecting
185
+ * agent's Ed25519 key so that receivers can verify the rejection is authorized.
186
+ *
187
+ * @throws {OphirError} if rfqId, rejectingMessageId, reason, or agentId are empty.
188
+ * @throws {OphirError} if secretKey is not 64 bytes.
189
+ *
190
+ * @example
191
+ * ```typescript
192
+ * const reject = buildReject({
193
+ * rfqId: 'a1b2c3d4-...',
194
+ * rejectingMessageId: 'e5f6g7h8-...',
195
+ * reason: 'Price too high',
196
+ * agentId: 'did:key:z6Mk...',
197
+ * secretKey: keypair.secretKey,
198
+ * });
199
+ * ```
200
+ */
201
+ export function buildReject(params) {
202
+ requireNonEmpty(params.rfqId, 'rfqId');
203
+ requireNonEmpty(params.rejectingMessageId, 'rejectingMessageId');
204
+ requireNonEmpty(params.reason, 'reason');
205
+ requireNonEmpty(params.agentId, 'agentId');
206
+ if (params.secretKey.length !== 64) {
207
+ throw new OphirError(OphirErrorCode.INVALID_SIGNATURE, `Invalid secret key length: expected 64, got ${params.secretKey.length}`);
208
+ }
209
+ const unsigned = {
210
+ rfq_id: params.rfqId,
211
+ rejecting_message_id: params.rejectingMessageId,
212
+ reason: params.reason,
213
+ from: { agent_id: params.agentId },
214
+ };
215
+ const signature = signMessage(unsigned, params.secretKey);
216
+ const rejectParams = { ...unsigned, signature };
217
+ return rpcEnvelope(METHODS.REJECT, rejectParams);
218
+ }
219
+ /**
220
+ * Build a signed Dispute message with violation evidence.
221
+ *
222
+ * @throws {OphirError} if agreementId, filedBy.agent_id, requestedRemedy, or escrowAction are empty.
223
+ *
224
+ * @example
225
+ * ```typescript
226
+ * const dispute = buildDispute({
227
+ * agreementId: 'a1b2c3d4-...',
228
+ * filedBy: { agent_id: 'did:key:z6Mk...', role: 'buyer' },
229
+ * violation: {
230
+ * sla_metric: 'uptime_pct',
231
+ * agreed_value: 99.9,
232
+ * observed_value: 95.0,
233
+ * measurement_window: '24h',
234
+ * evidence_hash: 'abc123',
235
+ * },
236
+ * requestedRemedy: 'Full refund',
237
+ * escrowAction: 'release_to_buyer',
238
+ * secretKey: keypair.secretKey,
239
+ * });
240
+ * ```
241
+ */
242
+ export function buildDispute(params) {
243
+ requireNonEmpty(params.agreementId, 'agreementId');
244
+ requireNonEmpty(params.filedBy.agent_id, 'filedBy.agent_id');
245
+ requireNonEmpty(params.requestedRemedy, 'requestedRemedy');
246
+ requireNonEmpty(params.escrowAction, 'escrowAction');
247
+ if (params.secretKey.length !== 64) {
248
+ throw new OphirError(OphirErrorCode.INVALID_SIGNATURE, `Invalid secret key length: expected 64, got ${params.secretKey.length}`);
249
+ }
250
+ const unsigned = {
251
+ dispute_id: uuidv4(),
252
+ agreement_id: params.agreementId,
253
+ filed_by: params.filedBy,
254
+ violation: params.violation,
255
+ requested_remedy: params.requestedRemedy,
256
+ escrow_action: params.escrowAction,
257
+ lockstep_report: params.lockstepReport,
258
+ };
259
+ const signature = signMessage(unsigned, params.secretKey);
260
+ const disputeParams = { ...unsigned, signature };
261
+ return rpcEnvelope(METHODS.DISPUTE, disputeParams);
262
+ }
@@ -0,0 +1,113 @@
1
+ import type { NegotiationState, RFQParams, QuoteParams, CounterParams } from '@ophirai/protocol';
2
+ import type { Agreement } from './types.js';
3
+ /**
4
+ * Tracks the state of a single negotiation from RFQ through completion or rejection.
5
+ *
6
+ * All state transitions are validated against the protocol's canonical state machine
7
+ * defined in `@ophirai/protocol` using `isValidTransition()`. Invalid transitions
8
+ * throw `OphirError` with code `INVALID_STATE_TRANSITION`.
9
+ */
10
+ export declare class NegotiationSession {
11
+ readonly rfqId: string;
12
+ state: NegotiationState;
13
+ readonly rfq: RFQParams;
14
+ quotes: QuoteParams[];
15
+ counters: CounterParams[];
16
+ agreement?: Agreement;
17
+ rejectionReason?: string;
18
+ currentRound: number;
19
+ maxRounds: number;
20
+ createdAt: Date;
21
+ updatedAt: Date;
22
+ timeouts: Map<NegotiationState, number>;
23
+ private escrowAddress?;
24
+ constructor(rfq: RFQParams, maxRounds?: number);
25
+ /** Add a received quote to this session and transition state to QUOTES_RECEIVED.
26
+ *
27
+ * Can be called from RFQ_SENT (first quote triggers transition),
28
+ * QUOTES_RECEIVED (additional quotes accumulate without re-transitioning),
29
+ * or COUNTERING (seller's response quote during counter-offer flow).
30
+ *
31
+ * @param quote - The quote parameters received from a seller agent
32
+ * @throws {OphirError} When the session is not in a state that accepts quotes
33
+ */
34
+ addQuote(quote: QuoteParams): void;
35
+ /** Add a counter-offer and increment the negotiation round.
36
+ * @param counter - The counter-offer parameters
37
+ * @throws {OphirError} When the transition from the current state to COUNTERING is not valid
38
+ * @throws {OphirError} When the current round exceeds the maximum allowed rounds
39
+ * @example
40
+ * ```typescript
41
+ * session.addCounter({ rfq_id: 'rfq_1', counter_id: 'ctr_1', ...terms });
42
+ * ```
43
+ */
44
+ addCounter(counter: CounterParams): void;
45
+ /** Accept the negotiation with a finalized agreement and transition to ACCEPTED.
46
+ * @param agreement - The finalized agreement terms both parties have agreed upon
47
+ * @throws {OphirError} When the transition from the current state to ACCEPTED is not valid
48
+ * @example
49
+ * ```typescript
50
+ * session.accept({ agreement_id: 'agr_1', terms, sla, payment });
51
+ * ```
52
+ */
53
+ accept(agreement: Agreement): void;
54
+ /** Reject the negotiation with a human-readable reason and transition to REJECTED.
55
+ *
56
+ * Valid from: RFQ_SENT, QUOTES_RECEIVED, COUNTERING, ACCEPTED (per protocol spec).
57
+ * @param reason - Human-readable explanation for why the negotiation was rejected
58
+ * @throws {OphirError} When the transition from the current state to REJECTED is not valid
59
+ */
60
+ reject(reason: string): void;
61
+ /** Record that escrow has been funded on-chain and transition to ESCROWED.
62
+ * @param escrowAddress - The on-chain address where escrow funds are held
63
+ * @throws {OphirError} When the transition from the current state to ESCROWED is not valid
64
+ */
65
+ escrowFunded(escrowAddress: string): void;
66
+ /** Activate the agreement so the seller begins service delivery.
67
+ * @throws {OphirError} When the transition from the current state to ACTIVE is not valid
68
+ */
69
+ activate(): void;
70
+ /** Mark the agreement as successfully completed and transition to COMPLETED.
71
+ * @throws {OphirError} When the transition from the current state to COMPLETED is not valid
72
+ */
73
+ complete(): void;
74
+ /** Transition to DISPUTED state to claim an SLA violation.
75
+ * @throws {OphirError} When the transition from the current state to DISPUTED is not valid
76
+ */
77
+ dispute(): void;
78
+ /** Mark a dispute as resolved and transition to RESOLVED.
79
+ * @throws {OphirError} When the transition from the current state to RESOLVED is not valid
80
+ */
81
+ resolve(): void;
82
+ /** Get the on-chain escrow address, if set.
83
+ * @returns The escrow address, or undefined if escrow has not been funded
84
+ */
85
+ getEscrowAddress(): string | undefined;
86
+ /** Check if the current state has exceeded its configured timeout.
87
+ * @returns True if the time elapsed since the last transition exceeds the state timeout
88
+ */
89
+ isExpired(): boolean;
90
+ /** Check if the session is in a terminal state (COMPLETED, REJECTED, or RESOLVED).
91
+ * @returns True if the session has reached a terminal state
92
+ */
93
+ isTerminal(): boolean;
94
+ /** Get the set of valid next states from the current state.
95
+ * @returns Read-only array of states that can be transitioned to
96
+ */
97
+ getValidNextStates(): readonly NegotiationState[];
98
+ /** Serialize session state to a plain object for logging or persistence.
99
+ * @returns A plain object containing all session fields with dates as ISO strings
100
+ */
101
+ toJSON(): Record<string, unknown>;
102
+ /**
103
+ * Validate a state transition against the protocol's canonical state machine.
104
+ * Uses `isValidTransition()` from `@ophirai/protocol` as the single source of truth.
105
+ *
106
+ * @param targetState - The state to transition to
107
+ * @param action - The action name for error messages
108
+ * @throws {OphirError} INVALID_STATE_TRANSITION if the transition is not allowed
109
+ */
110
+ private enforceTransition;
111
+ /** Apply a validated state transition. */
112
+ private applyTransition;
113
+ }
@@ -0,0 +1,214 @@
1
+ import { OphirError, OphirErrorCode, DEFAULT_CONFIG, isValidTransition, isTerminalState, getValidNextStates } from '@ophirai/protocol';
2
+ const DEFAULT_TIMEOUTS = {
3
+ RFQ_SENT: DEFAULT_CONFIG.rfq_timeout_ms,
4
+ QUOTES_RECEIVED: DEFAULT_CONFIG.quote_timeout_ms,
5
+ COUNTERING: DEFAULT_CONFIG.counter_timeout_ms,
6
+ };
7
+ /**
8
+ * Tracks the state of a single negotiation from RFQ through completion or rejection.
9
+ *
10
+ * All state transitions are validated against the protocol's canonical state machine
11
+ * defined in `@ophirai/protocol` using `isValidTransition()`. Invalid transitions
12
+ * throw `OphirError` with code `INVALID_STATE_TRANSITION`.
13
+ */
14
+ export class NegotiationSession {
15
+ rfqId;
16
+ state;
17
+ rfq;
18
+ quotes = [];
19
+ counters = [];
20
+ agreement;
21
+ rejectionReason;
22
+ currentRound = 0;
23
+ maxRounds;
24
+ createdAt;
25
+ updatedAt;
26
+ timeouts;
27
+ escrowAddress;
28
+ constructor(rfq, maxRounds) {
29
+ this.rfqId = rfq.rfq_id;
30
+ this.rfq = rfq;
31
+ this.state = 'RFQ_SENT';
32
+ this.maxRounds = maxRounds ?? rfq.max_rounds ?? DEFAULT_CONFIG.max_negotiation_rounds;
33
+ this.createdAt = new Date();
34
+ this.updatedAt = new Date();
35
+ this.timeouts = new Map();
36
+ for (const [state, ms] of Object.entries(DEFAULT_TIMEOUTS)) {
37
+ this.timeouts.set(state, ms);
38
+ }
39
+ }
40
+ /** Add a received quote to this session and transition state to QUOTES_RECEIVED.
41
+ *
42
+ * Can be called from RFQ_SENT (first quote triggers transition),
43
+ * QUOTES_RECEIVED (additional quotes accumulate without re-transitioning),
44
+ * or COUNTERING (seller's response quote during counter-offer flow).
45
+ *
46
+ * @param quote - The quote parameters received from a seller agent
47
+ * @throws {OphirError} When the session is not in a state that accepts quotes
48
+ */
49
+ addQuote(quote) {
50
+ // Allow accumulating quotes while already in QUOTES_RECEIVED (same-state is a no-op transition)
51
+ if (this.state === 'QUOTES_RECEIVED') {
52
+ this.quotes.push(quote);
53
+ this.updatedAt = new Date();
54
+ return;
55
+ }
56
+ // During countering, a seller's response quote is part of the counter-offer flow.
57
+ // Accumulate it without changing state (COUNTERING → COUNTERING is valid).
58
+ if (this.state === 'COUNTERING') {
59
+ this.quotes.push(quote);
60
+ this.updatedAt = new Date();
61
+ return;
62
+ }
63
+ this.enforceTransition('QUOTES_RECEIVED', 'addQuote');
64
+ this.quotes.push(quote);
65
+ this.applyTransition('QUOTES_RECEIVED');
66
+ }
67
+ /** Add a counter-offer and increment the negotiation round.
68
+ * @param counter - The counter-offer parameters
69
+ * @throws {OphirError} When the transition from the current state to COUNTERING is not valid
70
+ * @throws {OphirError} When the current round exceeds the maximum allowed rounds
71
+ * @example
72
+ * ```typescript
73
+ * session.addCounter({ rfq_id: 'rfq_1', counter_id: 'ctr_1', ...terms });
74
+ * ```
75
+ */
76
+ addCounter(counter) {
77
+ this.enforceTransition('COUNTERING', 'addCounter');
78
+ this.currentRound++;
79
+ if (this.currentRound > this.maxRounds) {
80
+ throw new OphirError(OphirErrorCode.MAX_ROUNDS_EXCEEDED, `Round ${this.currentRound} exceeds max ${this.maxRounds}`);
81
+ }
82
+ this.counters.push(counter);
83
+ this.applyTransition('COUNTERING');
84
+ }
85
+ /** Accept the negotiation with a finalized agreement and transition to ACCEPTED.
86
+ * @param agreement - The finalized agreement terms both parties have agreed upon
87
+ * @throws {OphirError} When the transition from the current state to ACCEPTED is not valid
88
+ * @example
89
+ * ```typescript
90
+ * session.accept({ agreement_id: 'agr_1', terms, sla, payment });
91
+ * ```
92
+ */
93
+ accept(agreement) {
94
+ this.enforceTransition('ACCEPTED', 'accept');
95
+ this.agreement = agreement;
96
+ this.applyTransition('ACCEPTED');
97
+ }
98
+ /** Reject the negotiation with a human-readable reason and transition to REJECTED.
99
+ *
100
+ * Valid from: RFQ_SENT, QUOTES_RECEIVED, COUNTERING, ACCEPTED (per protocol spec).
101
+ * @param reason - Human-readable explanation for why the negotiation was rejected
102
+ * @throws {OphirError} When the transition from the current state to REJECTED is not valid
103
+ */
104
+ reject(reason) {
105
+ this.enforceTransition('REJECTED', 'reject');
106
+ this.rejectionReason = reason;
107
+ this.applyTransition('REJECTED');
108
+ }
109
+ /** Record that escrow has been funded on-chain and transition to ESCROWED.
110
+ * @param escrowAddress - The on-chain address where escrow funds are held
111
+ * @throws {OphirError} When the transition from the current state to ESCROWED is not valid
112
+ */
113
+ escrowFunded(escrowAddress) {
114
+ this.enforceTransition('ESCROWED', 'escrowFunded');
115
+ this.escrowAddress = escrowAddress;
116
+ this.applyTransition('ESCROWED');
117
+ }
118
+ /** Activate the agreement so the seller begins service delivery.
119
+ * @throws {OphirError} When the transition from the current state to ACTIVE is not valid
120
+ */
121
+ activate() {
122
+ this.enforceTransition('ACTIVE', 'activate');
123
+ this.applyTransition('ACTIVE');
124
+ }
125
+ /** Mark the agreement as successfully completed and transition to COMPLETED.
126
+ * @throws {OphirError} When the transition from the current state to COMPLETED is not valid
127
+ */
128
+ complete() {
129
+ this.enforceTransition('COMPLETED', 'complete');
130
+ this.applyTransition('COMPLETED');
131
+ }
132
+ /** Transition to DISPUTED state to claim an SLA violation.
133
+ * @throws {OphirError} When the transition from the current state to DISPUTED is not valid
134
+ */
135
+ dispute() {
136
+ this.enforceTransition('DISPUTED', 'dispute');
137
+ this.applyTransition('DISPUTED');
138
+ }
139
+ /** Mark a dispute as resolved and transition to RESOLVED.
140
+ * @throws {OphirError} When the transition from the current state to RESOLVED is not valid
141
+ */
142
+ resolve() {
143
+ this.enforceTransition('RESOLVED', 'resolve');
144
+ this.applyTransition('RESOLVED');
145
+ }
146
+ /** Get the on-chain escrow address, if set.
147
+ * @returns The escrow address, or undefined if escrow has not been funded
148
+ */
149
+ getEscrowAddress() {
150
+ return this.escrowAddress;
151
+ }
152
+ /** Check if the current state has exceeded its configured timeout.
153
+ * @returns True if the time elapsed since the last transition exceeds the state timeout
154
+ */
155
+ isExpired() {
156
+ const timeout = this.timeouts.get(this.state);
157
+ if (timeout === undefined)
158
+ return false;
159
+ return Date.now() - this.updatedAt.getTime() > timeout;
160
+ }
161
+ /** Check if the session is in a terminal state (COMPLETED, REJECTED, or RESOLVED).
162
+ * @returns True if the session has reached a terminal state
163
+ */
164
+ isTerminal() {
165
+ return isTerminalState(this.state);
166
+ }
167
+ /** Get the set of valid next states from the current state.
168
+ * @returns Read-only array of states that can be transitioned to
169
+ */
170
+ getValidNextStates() {
171
+ return getValidNextStates(this.state);
172
+ }
173
+ /** Serialize session state to a plain object for logging or persistence.
174
+ * @returns A plain object containing all session fields with dates as ISO strings
175
+ */
176
+ toJSON() {
177
+ return {
178
+ rfqId: this.rfqId,
179
+ state: this.state,
180
+ rfq: this.rfq,
181
+ quotes: this.quotes,
182
+ counters: this.counters,
183
+ agreement: this.agreement,
184
+ rejectionReason: this.rejectionReason,
185
+ currentRound: this.currentRound,
186
+ maxRounds: this.maxRounds,
187
+ escrowAddress: this.escrowAddress,
188
+ createdAt: this.createdAt.toISOString(),
189
+ updatedAt: this.updatedAt.toISOString(),
190
+ };
191
+ }
192
+ /**
193
+ * Validate a state transition against the protocol's canonical state machine.
194
+ * Uses `isValidTransition()` from `@ophirai/protocol` as the single source of truth.
195
+ *
196
+ * @param targetState - The state to transition to
197
+ * @param action - The action name for error messages
198
+ * @throws {OphirError} INVALID_STATE_TRANSITION if the transition is not allowed
199
+ */
200
+ enforceTransition(targetState, action) {
201
+ if (isTerminalState(this.state)) {
202
+ throw new OphirError(OphirErrorCode.INVALID_STATE_TRANSITION, `Cannot ${action}: session is in terminal state ${this.state}`, { currentState: this.state, targetState });
203
+ }
204
+ if (!isValidTransition(this.state, targetState)) {
205
+ const validNext = getValidNextStates(this.state);
206
+ throw new OphirError(OphirErrorCode.INVALID_STATE_TRANSITION, `Cannot ${action} from state ${this.state}. Valid transitions: ${validNext.join(', ')}`, { currentState: this.state, targetState, validTransitions: [...validNext] });
207
+ }
208
+ }
209
+ /** Apply a validated state transition. */
210
+ applyTransition(to) {
211
+ this.state = to;
212
+ this.updatedAt = new Date();
213
+ }
214
+ }