@envarai/envarpay 0.1.0-alpha.2 → 0.2.0-alpha.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.
Files changed (59) hide show
  1. package/README.md +56 -145
  2. package/dist/commerce/buyer-store.d.ts +93 -0
  3. package/dist/commerce/buyer-store.js +168 -0
  4. package/dist/commerce/buyer.d.ts +129 -0
  5. package/dist/commerce/buyer.js +631 -0
  6. package/dist/commerce/card.d.ts +5 -0
  7. package/dist/commerce/card.js +29 -0
  8. package/dist/commerce/chain.d.ts +24 -0
  9. package/dist/commerce/chain.js +146 -0
  10. package/dist/commerce/cli.d.ts +2 -0
  11. package/dist/commerce/cli.js +268 -0
  12. package/dist/commerce/client-runtime.d.ts +8 -0
  13. package/dist/commerce/client-runtime.js +7 -0
  14. package/dist/commerce/config.d.ts +7 -0
  15. package/dist/commerce/config.js +164 -0
  16. package/dist/commerce/envar-runtime.d.ts +46 -0
  17. package/dist/commerce/envar-runtime.js +256 -0
  18. package/dist/commerce/envar.d.ts +126 -0
  19. package/dist/commerce/envar.js +647 -0
  20. package/dist/commerce/http.d.ts +2 -0
  21. package/dist/commerce/http.js +34 -0
  22. package/dist/commerce/index.d.ts +4 -0
  23. package/dist/commerce/index.js +4 -0
  24. package/dist/commerce/management.d.ts +14 -0
  25. package/dist/commerce/management.js +138 -0
  26. package/dist/commerce/mpp-client.d.ts +59 -0
  27. package/dist/commerce/mpp-client.js +24 -0
  28. package/dist/commerce/mpp-runtime.d.ts +3 -0
  29. package/dist/commerce/mpp-runtime.js +3 -0
  30. package/dist/commerce/mpp.d.ts +53 -0
  31. package/dist/commerce/mpp.js +163 -0
  32. package/dist/commerce/peer-proxy.d.ts +44 -0
  33. package/dist/commerce/peer-proxy.js +667 -0
  34. package/dist/commerce/pricing.d.ts +12 -0
  35. package/dist/commerce/pricing.js +60 -0
  36. package/dist/commerce/runtime.d.ts +8 -0
  37. package/dist/commerce/runtime.js +8 -0
  38. package/dist/commerce/server.d.ts +58 -0
  39. package/dist/commerce/server.js +485 -0
  40. package/dist/commerce/store.d.ts +86 -0
  41. package/dist/commerce/store.js +466 -0
  42. package/dist/commerce/stripe-provider.d.ts +9 -0
  43. package/dist/commerce/stripe-provider.js +97 -0
  44. package/dist/commerce/types.d.ts +113 -0
  45. package/dist/commerce/types.js +8 -0
  46. package/dist/commerce/upstream.d.ts +6 -0
  47. package/dist/commerce/upstream.js +103 -0
  48. package/dist/commerce/vault.d.ts +8 -0
  49. package/dist/commerce/vault.js +55 -0
  50. package/dist/commerce/x402.d.ts +37 -0
  51. package/dist/commerce/x402.js +174 -0
  52. package/dist/index.js +3 -1
  53. package/examples/buyer-policy.json +18 -0
  54. package/examples/envar-integration-policy.json +18 -0
  55. package/examples/seller.json +52 -0
  56. package/package.json +75 -10
  57. package/schemas/buyer-policy.schema.json +148 -0
  58. package/schemas/envarpay.config.schema.json +376 -0
  59. package/schemas/quote.schema.json +91 -0
@@ -0,0 +1,86 @@
1
+ import { Task, type ListTasksRequest, type ListTasksResponse } from '@a2a-js/sdk';
2
+ import type { TaskStore, ServerCallContext } from '@a2a-js/sdk/server';
3
+ import { type CommerceConfig, type Input, type PriceQuote, type Service } from './types.js';
4
+ export interface OrderRecord {
5
+ id: string;
6
+ caller: string;
7
+ serviceRevision: string;
8
+ offerId: string;
9
+ messageId: string;
10
+ inputDigest: string;
11
+ input: Input;
12
+ quote: PriceQuote;
13
+ taskId: string;
14
+ paymentState: 'not_required' | 'quoted' | 'settling' | 'confirmed' | 'rejected' | 'unknown';
15
+ executionState: 'not_started' | 'queued' | 'dispatching' | 'working' | 'input_required' | 'auth_required' | 'completed' | 'failed' | 'unknown';
16
+ }
17
+ export interface RemoteTaskReference {
18
+ origin: string;
19
+ taskId: string;
20
+ contextId: string;
21
+ interfaceUrl?: string;
22
+ }
23
+ export interface ContinuationRecord {
24
+ id: string;
25
+ orderId: string;
26
+ messageId: string;
27
+ input: Input;
28
+ patch: Input;
29
+ requestDigest: string;
30
+ state: 'ready' | 'dispatching' | 'done' | 'unknown';
31
+ }
32
+ /** A single-process SQLite store. Every quote/purchase key is scoped to caller. */
33
+ export declare class CommerceStore implements TaskStore {
34
+ private readonly db;
35
+ private readonly ownerDb?;
36
+ constructor(path: string);
37
+ private migrateV1;
38
+ close(): void;
39
+ registerCatalog(config: CommerceConfig): void;
40
+ serviceRevision(id: string): Service | undefined;
41
+ catalogHistory(): {
42
+ service: Service;
43
+ profiles: CommerceConfig['paymentProfiles'];
44
+ }[];
45
+ private transaction;
46
+ private order;
47
+ getOrder(id: string, caller: string): OrderRecord | undefined;
48
+ paymentForOrder(orderId: string): {
49
+ id: string;
50
+ state: string;
51
+ credentialRef: string;
52
+ receipt: Record<string, unknown> | null;
53
+ } | undefined;
54
+ paymentChallenge<T>(orderId: string): T | undefined;
55
+ freezePaymentChallenge<T>(orderId: string, challenge: T): T;
56
+ orderForTask(taskId: string, caller: string, tenant: string): OrderRecord | undefined;
57
+ remoteTask(orderId: string, caller: string): RemoteTaskReference | undefined;
58
+ findOrder(caller: string, serviceRevision: string, messageId: string): OrderRecord | undefined;
59
+ ensureQuote(quote: PriceQuote, input: Input): OrderRecord;
60
+ private createTaskOnce;
61
+ enqueueFree(orderId: string, caller: string): Task;
62
+ reservePayment(orderId: string, caller: string, economicKey: string, credentialRef: string, paymentIdentifier?: string): string;
63
+ recordSettlement(attemptId: string, state: 'confirmed' | 'rejected' | 'unknown', receipt: Record<string, unknown>): Task | undefined;
64
+ claimReady(): OrderRecord | undefined;
65
+ markExecutionUnknown(orderId: string): void;
66
+ private markTaskUnknown;
67
+ recordRemoteTask(orderId: string, origin: string, remoteTaskId: string, contextId?: string, interfaceUrl?: string): void;
68
+ private continuation;
69
+ continuationFor(orderId: string, messageId: string): ContinuationRecord | undefined;
70
+ clarificationInput(orderId: string): Input;
71
+ enqueueContinuation(orderId: string, caller: string, messageId: string, patch: Input, input: Input): ContinuationRecord;
72
+ claimContinuation(): {
73
+ order: OrderRecord;
74
+ continuation: ContinuationRecord;
75
+ } | undefined;
76
+ acceptContinuation(id: string): void;
77
+ unknownContinuation(id: string): void;
78
+ hasUnresolvedContinuation(orderId: string): boolean;
79
+ acknowledgeRecoveredContinuations(orderId: string, task: Task): void;
80
+ recoverInterruptedDispatches(): number;
81
+ private scope;
82
+ save(task: Task, context: ServerCallContext): Promise<void>;
83
+ load(taskId: string, context: ServerCallContext): Promise<Task | undefined>;
84
+ list(params: ListTasksRequest, context: ServerCallContext): Promise<ListTasksResponse>;
85
+ fingerprint(order: OrderRecord): string;
86
+ }
@@ -0,0 +1,466 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { chmodSync, mkdirSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { Task, TaskState } from '@a2a-js/sdk';
6
+ import { CommerceError } from './types.js';
7
+ import { digest } from './config.js';
8
+ /** A single-process SQLite store. Every quote/purchase key is scoped to caller. */
9
+ export class CommerceStore {
10
+ db;
11
+ ownerDb;
12
+ constructor(path) {
13
+ if (path !== ':memory:') {
14
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
15
+ }
16
+ if (path !== ':memory:') {
17
+ // Separate SQLite ownership transaction survives no process: OS file locks
18
+ // are released on SIGKILL, so restart does not need unsafe stale-file unlink.
19
+ this.ownerDb = new DatabaseSync(path + '.owner.sqlite3');
20
+ chmodSync(path + '.owner.sqlite3', 0o600);
21
+ try {
22
+ this.ownerDb.exec('PRAGMA busy_timeout=0; BEGIN EXCLUSIVE');
23
+ }
24
+ catch {
25
+ this.ownerDb.close();
26
+ throw new CommerceError('store_locked', 'This ledger has an active owner lock. Stop its process before starting another instance.');
27
+ }
28
+ }
29
+ this.db = new DatabaseSync(path);
30
+ if (path !== ':memory:')
31
+ chmodSync(path, 0o600);
32
+ this.db.exec(`
33
+ PRAGMA foreign_keys=ON;
34
+ PRAGMA busy_timeout=5000;
35
+ PRAGMA journal_mode=WAL;
36
+ PRAGMA synchronous=FULL;
37
+ CREATE TABLE IF NOT EXISTS commerce_meta (version INTEGER NOT NULL);
38
+ INSERT INTO commerce_meta SELECT 2 WHERE NOT EXISTS (SELECT 1 FROM commerce_meta);
39
+ CREATE TABLE IF NOT EXISTS commerce_service_revisions (id TEXT PRIMARY KEY, digest TEXT NOT NULL, service_json TEXT NOT NULL);
40
+ CREATE TABLE IF NOT EXISTS commerce_orders (
41
+ id TEXT PRIMARY KEY, caller TEXT NOT NULL, service_revision TEXT NOT NULL,
42
+ offer_id TEXT NOT NULL, message_id TEXT NOT NULL, input_digest TEXT NOT NULL,
43
+ input_json TEXT NOT NULL, quote_json TEXT NOT NULL, task_id TEXT NOT NULL UNIQUE,
44
+ payment_state TEXT NOT NULL CHECK(payment_state IN ('not_required','quoted','settling','confirmed','rejected','unknown')),
45
+ execution_state TEXT NOT NULL CHECK(execution_state IN ('not_started','queued','dispatching','working','input_required','auth_required','completed','failed','unknown')),
46
+ UNIQUE(caller,service_revision,message_id)
47
+ );
48
+ CREATE TABLE IF NOT EXISTS commerce_tasks (
49
+ id TEXT PRIMARY KEY, owner TEXT NOT NULL, tenant TEXT NOT NULL,
50
+ task_json TEXT NOT NULL, updated_at TEXT NOT NULL
51
+ );
52
+ CREATE TABLE IF NOT EXISTS commerce_outbox (
53
+ order_id TEXT PRIMARY KEY REFERENCES commerce_orders(id),
54
+ state TEXT NOT NULL CHECK(state IN ('ready','dispatching','done','unknown')),
55
+ remote_origin TEXT, remote_task_id TEXT
56
+ );
57
+ CREATE TABLE IF NOT EXISTS commerce_payment_attempts (
58
+ id TEXT PRIMARY KEY, order_id TEXT NOT NULL REFERENCES commerce_orders(id),
59
+ economic_key TEXT NOT NULL UNIQUE, credential_ref TEXT NOT NULL,
60
+ state TEXT NOT NULL CHECK(state IN ('settling','confirmed','rejected','unknown')),
61
+ receipt_json TEXT
62
+ );
63
+ CREATE TABLE IF NOT EXISTS commerce_payment_challenges (
64
+ order_id TEXT PRIMARY KEY REFERENCES commerce_orders(id), challenge_json TEXT NOT NULL
65
+ );
66
+ CREATE TABLE IF NOT EXISTS commerce_payment_identifiers (
67
+ caller TEXT NOT NULL, identifier TEXT NOT NULL,
68
+ attempt_id TEXT NOT NULL UNIQUE REFERENCES commerce_payment_attempts(id),
69
+ PRIMARY KEY(caller,identifier)
70
+ );
71
+ CREATE TRIGGER IF NOT EXISTS require_paid_outbox BEFORE INSERT ON commerce_outbox
72
+ WHEN NOT EXISTS (SELECT 1 FROM commerce_orders WHERE id=NEW.order_id AND payment_state IN ('confirmed','not_required'))
73
+ BEGIN SELECT RAISE(ABORT,'Payment not confirmed'); END;
74
+ `);
75
+ const v = this.db.prepare('SELECT version FROM commerce_meta').get();
76
+ if (v.version === 1) {
77
+ try {
78
+ this.migrateV1();
79
+ }
80
+ catch (error) {
81
+ this.close();
82
+ throw error;
83
+ }
84
+ }
85
+ else if (v.version !== 2) {
86
+ this.close();
87
+ throw new CommerceError('store_version', 'Unsupported commerce store version');
88
+ }
89
+ const columns = this.db.prepare('PRAGMA table_info(commerce_outbox)').all();
90
+ if (!columns.some(c => c.name === 'remote_context_id'))
91
+ this.db.exec("ALTER TABLE commerce_outbox ADD COLUMN remote_context_id TEXT NOT NULL DEFAULT ''");
92
+ if (!columns.some(c => c.name === 'remote_interface'))
93
+ this.db.exec('ALTER TABLE commerce_outbox ADD COLUMN remote_interface TEXT');
94
+ this.db.exec(`CREATE TABLE IF NOT EXISTS commerce_continuations(
95
+ id TEXT PRIMARY KEY, order_id TEXT NOT NULL REFERENCES commerce_orders(id),
96
+ message_id TEXT NOT NULL, request_digest TEXT NOT NULL, patch_json TEXT NOT NULL, input_json TEXT NOT NULL,
97
+ state TEXT NOT NULL CHECK(state IN ('ready','dispatching','done','unknown')), UNIQUE(order_id,message_id)
98
+ );
99
+ CREATE INDEX IF NOT EXISTS commerce_tasks_owner_tenant_updated ON commerce_tasks(owner,tenant,updated_at DESC,id);
100
+ CREATE INDEX IF NOT EXISTS commerce_continuations_order_state ON commerce_continuations(order_id,state);`);
101
+ }
102
+ migrateV1() {
103
+ // Rebuild only the state constraint. Preserve original IDs, credentials and every payment row.
104
+ this.db.exec('PRAGMA foreign_keys=OFF');
105
+ try {
106
+ this.transaction(() => {
107
+ this.db.exec(`DROP TRIGGER IF EXISTS require_paid_outbox;
108
+ CREATE TABLE commerce_orders_v2(
109
+ id TEXT PRIMARY KEY, caller TEXT NOT NULL, service_revision TEXT NOT NULL,
110
+ offer_id TEXT NOT NULL, message_id TEXT NOT NULL, input_digest TEXT NOT NULL,
111
+ input_json TEXT NOT NULL, quote_json TEXT NOT NULL, task_id TEXT NOT NULL UNIQUE,
112
+ payment_state TEXT NOT NULL CHECK(payment_state IN ('not_required','quoted','settling','confirmed','rejected','unknown')),
113
+ execution_state TEXT NOT NULL CHECK(execution_state IN ('not_started','queued','dispatching','working','input_required','auth_required','completed','failed','unknown')),
114
+ UNIQUE(caller,service_revision,message_id));
115
+ INSERT INTO commerce_orders_v2 SELECT * FROM commerce_orders;
116
+ DROP TABLE commerce_orders;
117
+ ALTER TABLE commerce_orders_v2 RENAME TO commerce_orders;
118
+ UPDATE commerce_meta SET version=2;
119
+ CREATE TRIGGER require_paid_outbox BEFORE INSERT ON commerce_outbox
120
+ WHEN NOT EXISTS(SELECT 1 FROM commerce_orders WHERE id=NEW.order_id AND payment_state IN ('confirmed','not_required'))
121
+ BEGIN SELECT RAISE(ABORT,'Payment not confirmed'); END;`);
122
+ });
123
+ }
124
+ finally {
125
+ this.db.exec('PRAGMA foreign_keys=ON');
126
+ }
127
+ if (this.db.prepare('PRAGMA foreign_key_check').all().length)
128
+ throw new CommerceError('store_integrity', 'Ledger migration failed integrity validation');
129
+ }
130
+ close() {
131
+ this.db.close();
132
+ if (this.ownerDb) {
133
+ this.ownerDb.exec('ROLLBACK');
134
+ this.ownerDb.close();
135
+ }
136
+ }
137
+ registerCatalog(config) {
138
+ this.transaction(() => {
139
+ for (const service of config.services) {
140
+ const id = `${service.id}:${service.revision}`;
141
+ const snapshot = { service, profiles: Object.fromEntries(service.offers.filter(x => x.paymentProfile).map(x => [x.paymentProfile, config.paymentProfiles[x.paymentProfile]])) };
142
+ const fingerprint = digest(snapshot);
143
+ const prior = this.db.prepare('SELECT digest FROM commerce_service_revisions WHERE id=?').get(id);
144
+ if (prior && prior.digest !== fingerprint)
145
+ throw new CommerceError('immutable_revision', 'Changing a service or payment profile requires a new service revision');
146
+ this.db.prepare('INSERT OR IGNORE INTO commerce_service_revisions VALUES (?,?,?)').run(id, fingerprint, JSON.stringify(snapshot));
147
+ }
148
+ });
149
+ }
150
+ serviceRevision(id) {
151
+ const row = this.db.prepare('SELECT service_json FROM commerce_service_revisions WHERE id=?').get(id);
152
+ return row ? JSON.parse(String(row.service_json)).service : undefined;
153
+ }
154
+ catalogHistory() {
155
+ return this.db.prepare('SELECT service_json FROM commerce_service_revisions ORDER BY rowid').all().map(r => JSON.parse(String(r.service_json)));
156
+ }
157
+ transaction(fn) {
158
+ this.db.exec('BEGIN IMMEDIATE');
159
+ try {
160
+ const result = fn();
161
+ this.db.exec('COMMIT');
162
+ return result;
163
+ }
164
+ catch (error) {
165
+ this.db.exec('ROLLBACK');
166
+ throw error;
167
+ }
168
+ }
169
+ order(row) {
170
+ return row ? { id: String(row.id), caller: String(row.caller), serviceRevision: String(row.service_revision), offerId: String(row.offer_id), messageId: String(row.message_id), inputDigest: String(row.input_digest), input: JSON.parse(String(row.input_json)), quote: JSON.parse(String(row.quote_json)), taskId: String(row.task_id), paymentState: row.payment_state, executionState: row.execution_state } : undefined;
171
+ }
172
+ getOrder(id, caller) {
173
+ return this.order(this.db.prepare('SELECT * FROM commerce_orders WHERE id=? AND caller=?').get(id, caller));
174
+ }
175
+ paymentForOrder(orderId) {
176
+ const row = this.db.prepare('SELECT * FROM commerce_payment_attempts WHERE order_id=? ORDER BY rowid DESC LIMIT 1').get(orderId);
177
+ return row ? { id: String(row.id), state: String(row.state), credentialRef: String(row.credential_ref), receipt: row.receipt_json ? JSON.parse(String(row.receipt_json)) : null } : undefined;
178
+ }
179
+ paymentChallenge(orderId) {
180
+ const row = this.db.prepare('SELECT challenge_json FROM commerce_payment_challenges WHERE order_id=?').get(orderId);
181
+ return row ? JSON.parse(String(row.challenge_json)) : undefined;
182
+ }
183
+ freezePaymentChallenge(orderId, challenge) {
184
+ return this.transaction(() => {
185
+ this.db.prepare('INSERT OR IGNORE INTO commerce_payment_challenges VALUES (?,?)').run(orderId, JSON.stringify(challenge));
186
+ return this.paymentChallenge(orderId);
187
+ });
188
+ }
189
+ orderForTask(taskId, caller, tenant) {
190
+ return this.order(this.db.prepare('SELECT * FROM commerce_orders WHERE task_id=? AND caller=? AND service_revision=?').get(taskId, caller, tenant));
191
+ }
192
+ remoteTask(orderId, caller) {
193
+ const row = this.db.prepare('SELECT x.* FROM commerce_outbox x JOIN commerce_orders o ON o.id=x.order_id WHERE x.order_id=? AND o.caller=?').get(orderId, caller);
194
+ return row?.remote_task_id ? { origin: String(row.remote_origin), taskId: String(row.remote_task_id), contextId: String(row.remote_context_id ?? ''), ...(row.remote_interface ? { interfaceUrl: String(row.remote_interface) } : {}) } : undefined;
195
+ }
196
+ findOrder(caller, serviceRevision, messageId) {
197
+ return this.order(this.db.prepare('SELECT * FROM commerce_orders WHERE caller=? AND service_revision=? AND message_id=?').get(caller, serviceRevision, messageId));
198
+ }
199
+ ensureQuote(quote, input) {
200
+ if (digest(input) !== quote.inputDigest)
201
+ throw new CommerceError('quote_mismatch', 'Input must match the frozen quote');
202
+ return this.transaction(() => {
203
+ const version = `${quote.serviceId}:${quote.serviceRevision}`;
204
+ const existing = this.findOrder(quote.caller, version, quote.messageId);
205
+ if (existing) {
206
+ if (existing.inputDigest !== quote.inputDigest || existing.offerId !== quote.offerId)
207
+ throw new CommerceError('purchase_conflict', 'Message ID already identifies a different purchase');
208
+ return existing;
209
+ }
210
+ const id = randomUUID(), taskId = randomUUID();
211
+ this.db.prepare('INSERT INTO commerce_orders VALUES (?,?,?,?,?,?,?,?,?,?,?)').run(id, quote.caller, version, quote.offerId, quote.messageId, quote.inputDigest, JSON.stringify(input), JSON.stringify(quote), taskId, quote.paymentProfile ? 'quoted' : 'not_required', 'not_started');
212
+ return this.getOrder(id, quote.caller);
213
+ });
214
+ }
215
+ createTaskOnce(order) {
216
+ let row = this.db.prepare('SELECT task_json FROM commerce_tasks WHERE id=?').get(order.taskId);
217
+ if (!row) {
218
+ const task = Task.fromJSON({ id: order.taskId, contextId: randomUUID(), status: { state: 'TASK_STATE_SUBMITTED', timestamp: new Date().toISOString() } });
219
+ this.db.prepare('INSERT INTO commerce_tasks VALUES (?,?,?,?,?)').run(task.id, order.caller, order.serviceRevision, JSON.stringify(Task.toJSON(task)), new Date().toISOString());
220
+ row = { task_json: JSON.stringify(Task.toJSON(task)) };
221
+ }
222
+ this.db.prepare("INSERT OR IGNORE INTO commerce_outbox(order_id,state) VALUES (?,'ready')").run(order.id);
223
+ this.db.prepare("UPDATE commerce_orders SET execution_state='queued' WHERE id=? AND execution_state='not_started'").run(order.id);
224
+ return Task.fromJSON(JSON.parse(String(row.task_json)));
225
+ }
226
+ enqueueFree(orderId, caller) {
227
+ return this.transaction(() => {
228
+ const order = this.getOrder(orderId, caller);
229
+ if (!order || order.paymentState !== 'not_required')
230
+ throw new CommerceError('payment_required', 'Free execution requires a free order');
231
+ return this.createTaskOnce(order);
232
+ });
233
+ }
234
+ reservePayment(orderId, caller, economicKey, credentialRef, paymentIdentifier) {
235
+ return this.transaction(() => {
236
+ const order = this.getOrder(orderId, caller);
237
+ if (!order || !['quoted', 'rejected'].includes(order.paymentState))
238
+ throw new CommerceError('payment_recovery_required', 'Inspect the original payment before authorizing another');
239
+ if (Date.now() >= Date.parse(order.quote.expiresAt))
240
+ throw new CommerceError('quote_expired', 'Review a new quote');
241
+ if (!economicKey || !credentialRef)
242
+ throw new CommerceError('invalid_payment', 'Original payment identity and credential reference required');
243
+ if (this.db.prepare('SELECT id FROM commerce_payment_attempts WHERE economic_key=?').get(economicKey))
244
+ throw new CommerceError('payment_reuse', 'Economic payment is already bound to an order');
245
+ const id = randomUUID();
246
+ this.db.prepare("INSERT INTO commerce_payment_attempts(id,order_id,economic_key,credential_ref,state) VALUES (?,?,?,?,'settling')").run(id, orderId, economicKey, credentialRef);
247
+ if (paymentIdentifier) {
248
+ if (this.db.prepare('SELECT attempt_id FROM commerce_payment_identifiers WHERE caller=? AND identifier=?').get(caller, paymentIdentifier))
249
+ throw new CommerceError('identifier_reuse', 'Payment identifier already belongs to another attempt');
250
+ this.db.prepare('INSERT INTO commerce_payment_identifiers VALUES (?,?,?)').run(caller, paymentIdentifier, id);
251
+ }
252
+ this.db.prepare("UPDATE commerce_orders SET payment_state='settling' WHERE id=?").run(orderId);
253
+ return id;
254
+ });
255
+ }
256
+ recordSettlement(attemptId, state, receipt) {
257
+ return this.transaction(() => {
258
+ const attempt = this.db.prepare('SELECT * FROM commerce_payment_attempts WHERE id=?').get(attemptId);
259
+ if (!attempt)
260
+ throw new CommerceError('attempt_not_found', 'Original payment attempt not found');
261
+ if (attempt.state === 'confirmed' && state !== 'confirmed')
262
+ throw new CommerceError('settlement_conflict', 'Confirmed payment cannot become unpaid');
263
+ if (attempt.state === 'rejected' && state !== 'rejected')
264
+ throw new CommerceError('settlement_conflict', 'Definitive rejection cannot be changed to another outcome');
265
+ this.db.prepare('UPDATE commerce_payment_attempts SET state=?,receipt_json=? WHERE id=?').run(state, JSON.stringify(receipt), attemptId);
266
+ this.db.prepare('UPDATE commerce_orders SET payment_state=? WHERE id=?').run(state, String(attempt.order_id));
267
+ const order = this.order(this.db.prepare('SELECT * FROM commerce_orders WHERE id=?').get(String(attempt.order_id)));
268
+ return state === 'confirmed' ? this.createTaskOnce(order) : undefined;
269
+ });
270
+ }
271
+ claimReady() {
272
+ return this.transaction(() => {
273
+ const row = this.db.prepare("SELECT o.* FROM commerce_orders o JOIN commerce_outbox x ON x.order_id=o.id WHERE x.state='ready' ORDER BY o.rowid LIMIT 1").get();
274
+ if (!row)
275
+ return undefined;
276
+ this.db.prepare("UPDATE commerce_outbox SET state='dispatching' WHERE order_id=?").run(String(row.id));
277
+ this.db.prepare("UPDATE commerce_orders SET execution_state='dispatching' WHERE id=?").run(String(row.id));
278
+ return this.order({ ...row, execution_state: 'dispatching' });
279
+ });
280
+ }
281
+ markExecutionUnknown(orderId) {
282
+ this.transaction(() => {
283
+ this.db.prepare("UPDATE commerce_orders SET execution_state='unknown' WHERE id=? AND execution_state NOT IN ('completed','failed')").run(orderId);
284
+ this.db.prepare("UPDATE commerce_outbox SET state='unknown' WHERE order_id=? AND state!='done'").run(orderId);
285
+ this.markTaskUnknown(orderId);
286
+ });
287
+ }
288
+ markTaskUnknown(orderId) {
289
+ const row = this.db.prepare('SELECT t.task_json FROM commerce_tasks t JOIN commerce_orders o ON o.task_id=t.id WHERE o.id=?').get(orderId);
290
+ if (!row)
291
+ return;
292
+ const task = Task.fromJSON(JSON.parse(String(row.task_json)));
293
+ task.status = { state: TaskState.TASK_STATE_UNSPECIFIED, timestamp: new Date().toISOString(), message: undefined };
294
+ task.metadata = { ...task.metadata, executionState: 'unknown', recoveryRequired: true };
295
+ this.db.prepare('UPDATE commerce_tasks SET task_json=?,updated_at=? WHERE id=?').run(JSON.stringify(Task.toJSON(task)), new Date().toISOString(), task.id);
296
+ }
297
+ recordRemoteTask(orderId, origin, remoteTaskId, contextId = '', interfaceUrl) {
298
+ if (!origin || !remoteTaskId)
299
+ throw new CommerceError('invalid_remote_task', 'Remote origin and task ID required');
300
+ this.transaction(() => {
301
+ const row = this.db.prepare('SELECT * FROM commerce_outbox WHERE order_id=?').get(orderId);
302
+ if (!row)
303
+ throw new CommerceError('order_not_found', 'Order not dispatched');
304
+ if (row.remote_task_id && (row.remote_task_id !== remoteTaskId || row.remote_origin !== origin ||
305
+ (row.remote_context_id && row.remote_context_id !== contextId) || (row.remote_interface && row.remote_interface !== interfaceUrl)))
306
+ throw new CommerceError('remote_task_conflict', 'Cannot replace the original remote task, context or interface');
307
+ this.db.prepare('UPDATE commerce_outbox SET remote_origin=?,remote_task_id=?,remote_context_id=?,remote_interface=COALESCE(remote_interface,?) WHERE order_id=?').run(origin, remoteTaskId, contextId, interfaceUrl ?? null, orderId);
308
+ });
309
+ }
310
+ continuation(row) {
311
+ return row ? { id: String(row.id), orderId: String(row.order_id), messageId: String(row.message_id), input: JSON.parse(String(row.input_json)), patch: JSON.parse(String(row.patch_json)), requestDigest: String(row.request_digest), state: row.state } : undefined;
312
+ }
313
+ continuationFor(orderId, messageId) {
314
+ return this.continuation(this.db.prepare('SELECT * FROM commerce_continuations WHERE order_id=? AND message_id=?').get(orderId, messageId));
315
+ }
316
+ clarificationInput(orderId) {
317
+ const latest = this.db.prepare('SELECT input_json FROM commerce_continuations WHERE order_id=? ORDER BY rowid DESC LIMIT 1').get(orderId);
318
+ const initial = latest ?? this.db.prepare('SELECT input_json FROM commerce_orders WHERE id=?').get(orderId);
319
+ if (!initial)
320
+ throw new CommerceError('order_not_found', 'Original order not found');
321
+ return JSON.parse(String(initial.input_json));
322
+ }
323
+ enqueueContinuation(orderId, caller, messageId, patch, input) {
324
+ return this.transaction(() => {
325
+ const order = this.getOrder(orderId, caller);
326
+ if (!order || !['confirmed', 'not_required'].includes(order.paymentState))
327
+ throw new CommerceError('payment_required', 'Clarification requires the original paid or free order');
328
+ if (!messageId || messageId.length > 128 || messageId === order.messageId)
329
+ throw new CommerceError('invalid_continuation', 'Use a distinct stable clarification message ID');
330
+ const prior = this.continuationFor(orderId, messageId);
331
+ if (prior) {
332
+ if (prior.requestDigest !== digest(patch))
333
+ throw new CommerceError('continuation_conflict', 'Clarification message ID already identifies different input');
334
+ return prior;
335
+ }
336
+ if (!['input_required', 'auth_required'].includes(order.executionState))
337
+ throw new CommerceError('continuation_not_waiting', 'Only a waiting nonterminal task accepts clarification');
338
+ const counts = this.db.prepare("SELECT COUNT(*) AS total,SUM(CASE WHEN state!='done' THEN 1 ELSE 0 END) AS pending FROM commerce_continuations WHERE order_id=?").get(orderId);
339
+ if (Number(counts.pending) > 0)
340
+ throw new CommerceError('continuation_recovery_required', 'Resolve the original pending clarification before sending another');
341
+ if (Number(counts.total) >= 10)
342
+ throw new CommerceError('clarification_limit', 'At most ten clarification rounds are included');
343
+ const id = randomUUID();
344
+ this.db.prepare("INSERT INTO commerce_continuations VALUES (?,?,?,?,?,?,'ready')").run(id, orderId, messageId, digest(patch), JSON.stringify(patch), JSON.stringify(input));
345
+ this.db.prepare("UPDATE commerce_orders SET execution_state='queued' WHERE id=?").run(orderId);
346
+ const row = this.db.prepare('SELECT task_json FROM commerce_tasks WHERE id=?').get(order.taskId);
347
+ const task = Task.fromJSON(JSON.parse(String(row.task_json)));
348
+ task.status = { state: TaskState.TASK_STATE_SUBMITTED, timestamp: new Date().toISOString(), message: undefined };
349
+ task.metadata = { ...task.metadata, clarificationPending: true };
350
+ this.db.prepare('UPDATE commerce_tasks SET task_json=?,updated_at=? WHERE id=?').run(JSON.stringify(Task.toJSON(task)), new Date().toISOString(), task.id);
351
+ return this.continuationFor(orderId, messageId);
352
+ });
353
+ }
354
+ claimContinuation() {
355
+ return this.transaction(() => {
356
+ const row = this.db.prepare("SELECT * FROM commerce_continuations WHERE state='ready' ORDER BY rowid LIMIT 1").get();
357
+ if (!row)
358
+ return undefined;
359
+ this.db.prepare("UPDATE commerce_continuations SET state='dispatching' WHERE id=?").run(String(row.id));
360
+ this.db.prepare("UPDATE commerce_orders SET execution_state='dispatching' WHERE id=?").run(String(row.order_id));
361
+ const order = this.order(this.db.prepare('SELECT * FROM commerce_orders WHERE id=?').get(String(row.order_id)));
362
+ return { order, continuation: this.continuation({ ...row, state: 'dispatching' }) };
363
+ });
364
+ }
365
+ acceptContinuation(id) { this.db.prepare("UPDATE commerce_continuations SET state='done' WHERE id=? AND state='dispatching'").run(id); }
366
+ unknownContinuation(id) { this.db.prepare("UPDATE commerce_continuations SET state='unknown' WHERE id=? AND state='dispatching'").run(id); }
367
+ hasUnresolvedContinuation(orderId) {
368
+ return !!this.db.prepare("SELECT id FROM commerce_continuations WHERE order_id=? AND state IN ('unknown','dispatching') LIMIT 1").get(orderId);
369
+ }
370
+ acknowledgeRecoveredContinuations(orderId, task) {
371
+ const terminal = [TaskState.TASK_STATE_COMPLETED, TaskState.TASK_STATE_FAILED, TaskState.TASK_STATE_CANCELED, TaskState.TASK_STATE_REJECTED].includes(task.status?.state ?? 0);
372
+ const messages = new Set(task.history.map(m => m.messageId));
373
+ for (const row of this.db.prepare("SELECT id,message_id FROM commerce_continuations WHERE order_id=? AND state='unknown'").all(orderId)) {
374
+ if (terminal || messages.has(String(row.message_id)))
375
+ this.db.prepare("UPDATE commerce_continuations SET state='done' WHERE id=?").run(String(row.id));
376
+ }
377
+ }
378
+ recoverInterruptedDispatches() {
379
+ return this.transaction(() => {
380
+ const pending = this.db.prepare("SELECT order_id FROM commerce_outbox WHERE state='dispatching' UNION SELECT order_id FROM commerce_continuations WHERE state='dispatching'").all();
381
+ this.db.prepare("UPDATE commerce_outbox SET state='unknown' WHERE state='dispatching'").run();
382
+ this.db.prepare("UPDATE commerce_continuations SET state='unknown' WHERE state='dispatching'").run();
383
+ for (const row of pending) {
384
+ this.db.prepare("UPDATE commerce_orders SET execution_state='unknown' WHERE id=? AND execution_state NOT IN ('completed','failed')").run(String(row.order_id));
385
+ this.markTaskUnknown(String(row.order_id));
386
+ }
387
+ return pending.length;
388
+ });
389
+ }
390
+ scope(context) {
391
+ if (!context.user?.isAuthenticated || !context.user.userName)
392
+ throw new CommerceError('authentication_required', 'Authenticated task owner required');
393
+ return { owner: context.user.userName, tenant: context.tenant ?? '' };
394
+ }
395
+ async save(task, context) {
396
+ const scope = this.scope(context);
397
+ this.transaction(() => {
398
+ const current = this.db.prepare('SELECT owner,tenant,task_json FROM commerce_tasks WHERE id=?').get(task.id);
399
+ if (current && (current.owner !== scope.owner || current.tenant !== scope.tenant))
400
+ throw new CommerceError('task_owner_conflict', 'Cannot replace another owner task');
401
+ const order = this.db.prepare('SELECT payment_state FROM commerce_orders WHERE task_id=?').get(task.id);
402
+ if (order && !['confirmed', 'not_required'].includes(String(order.payment_state)))
403
+ throw new CommerceError('payment_required', 'Cannot publish task execution before payment');
404
+ const terminalStates = [TaskState.TASK_STATE_COMPLETED, TaskState.TASK_STATE_FAILED, TaskState.TASK_STATE_CANCELED, TaskState.TASK_STATE_REJECTED];
405
+ if (current) {
406
+ const before = Task.fromJSON(JSON.parse(String(current.task_json)));
407
+ if (terminalStates.includes(before.status?.state ?? 0) && before.status?.state !== task.status?.state)
408
+ throw new CommerceError('task_state_conflict', 'A terminal Task cannot change state');
409
+ }
410
+ const serialized = JSON.stringify(Task.toJSON(task));
411
+ if (Buffer.byteLength(serialized) > 4 * 1024 * 1024)
412
+ throw new CommerceError('task_too_large', 'Task snapshot exceeds 4 MiB');
413
+ this.db.prepare('INSERT INTO commerce_tasks VALUES (?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET task_json=excluded.task_json,updated_at=excluded.updated_at').run(task.id, scope.owner, scope.tenant, serialized, new Date().toISOString());
414
+ const terminal = [TaskState.TASK_STATE_COMPLETED, TaskState.TASK_STATE_FAILED, TaskState.TASK_STATE_CANCELED, TaskState.TASK_STATE_REJECTED].includes(task.status?.state ?? 0);
415
+ const waiting = task.status?.state === TaskState.TASK_STATE_INPUT_REQUIRED ? 'input_required' : task.status?.state === TaskState.TASK_STATE_AUTH_REQUIRED ? 'auth_required' : null;
416
+ if (waiting) {
417
+ this.db.prepare('UPDATE commerce_orders SET execution_state=? WHERE task_id=? AND caller=?').run(waiting, task.id, scope.owner);
418
+ this.db.prepare("UPDATE commerce_outbox SET state='done' WHERE order_id IN (SELECT id FROM commerce_orders WHERE task_id=? AND caller=?)").run(task.id, scope.owner);
419
+ }
420
+ else if (!terminal && task.status?.state !== TaskState.TASK_STATE_UNSPECIFIED) {
421
+ this.db.prepare("UPDATE commerce_orders SET execution_state='working' WHERE task_id=? AND caller=?").run(task.id, scope.owner);
422
+ }
423
+ if (terminal) {
424
+ this.db.prepare('UPDATE commerce_orders SET execution_state=? WHERE task_id=? AND caller=?').run(task.status?.state === TaskState.TASK_STATE_COMPLETED ? 'completed' : 'failed', task.id, scope.owner);
425
+ this.db.prepare("UPDATE commerce_outbox SET state='done' WHERE order_id IN (SELECT id FROM commerce_orders WHERE task_id=? AND caller=?)").run(task.id, scope.owner);
426
+ }
427
+ });
428
+ }
429
+ async load(taskId, context) {
430
+ const scope = this.scope(context);
431
+ const row = this.db.prepare('SELECT task_json FROM commerce_tasks WHERE id=? AND owner=? AND tenant=?').get(taskId, scope.owner, scope.tenant);
432
+ return row ? Task.fromJSON(JSON.parse(String(row.task_json))) : undefined;
433
+ }
434
+ async list(params, context) {
435
+ const scope = this.scope(context);
436
+ const pageSize = params.pageSize || 50;
437
+ if (pageSize < 1 || pageSize > 100 || !/^\d*$/.test(params.pageToken) || (params.historyLength !== undefined && params.historyLength < 0))
438
+ throw new CommerceError('invalid_page', 'Invalid task pagination');
439
+ const offset = Number(params.pageToken || '0');
440
+ if (!Number.isSafeInteger(offset))
441
+ throw new CommerceError('invalid_page', 'Invalid task offset');
442
+ const filters = ['owner=?', 'tenant=?'];
443
+ const values = [scope.owner, scope.tenant];
444
+ if (params.contextId) {
445
+ filters.push("json_extract(task_json,'$.contextId')=?");
446
+ values.push(params.contextId);
447
+ }
448
+ if (params.status) {
449
+ filters.push("json_extract(task_json,'$.status.state')=?");
450
+ values.push(TaskState[params.status]);
451
+ }
452
+ if (params.statusTimestampAfter) {
453
+ if (!Number.isFinite(Date.parse(params.statusTimestampAfter)))
454
+ throw new CommerceError('invalid_page', 'Invalid status timestamp');
455
+ filters.push("julianday(json_extract(task_json,'$.status.timestamp'))>=julianday(?)");
456
+ values.push(params.statusTimestampAfter);
457
+ }
458
+ const where = filters.join(' AND ');
459
+ const count = this.db.prepare(`SELECT COUNT(*) AS n FROM commerce_tasks WHERE ${where}`).get(...values);
460
+ const rows = this.db.prepare(`SELECT task_json FROM commerce_tasks WHERE ${where} ORDER BY updated_at DESC,id LIMIT ? OFFSET ?`).all(...values, pageSize, offset);
461
+ const tasks = rows.map(r => Task.fromJSON(JSON.parse(String(r.task_json)))).map(t => ({ ...t, artifacts: params.includeArtifacts ? t.artifacts : [], history: params.historyLength === undefined ? t.history : params.historyLength === 0 ? [] : t.history.slice(-params.historyLength) }));
462
+ const totalSize = Number(count.n);
463
+ return { tasks, nextPageToken: offset + pageSize < totalSize ? String(offset + pageSize) : '', pageSize, totalSize };
464
+ }
465
+ fingerprint(order) { return digest({ caller: order.caller, serviceRevision: order.serviceRevision, offerId: order.offerId, messageId: order.messageId, inputDigest: order.inputDigest }); }
466
+ }
@@ -0,0 +1,9 @@
1
+ import type { StripeProvider } from './mpp.js';
2
+ export interface StripeProviderConfig {
3
+ secretKey: string;
4
+ accountId: string;
5
+ merchantProfile: string;
6
+ mode: 'test' | 'live';
7
+ }
8
+ /** Stripe REST transport used by the official mppx Stripe method. No SPT creation. */
9
+ export declare function stripeProvider(config: StripeProviderConfig, fetchImpl?: typeof fetch): StripeProvider;