@allorc/a423-sdk 0.1.0 → 0.3.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.
@@ -0,0 +1,137 @@
1
+ 'use strict';
2
+ const { Contract, Interface, TypedDataEncoder, Transaction, getAddress, sha256, toUtf8Bytes, ZeroHash } = require('ethers');
3
+ const { hashAgreement } = require('../workflow.cjs');
4
+ const artifact = require('./artifact.json');
5
+ const ABI = artifact.abi;
6
+ const TOKEN_ABI = ['function allowance(address,address) view returns(uint256)','function approve(address,uint256) returns(bool)'];
7
+ const TERMS_TYPES = { Terms: [ ['jobId','bytes32'],['payer','address'],['worker','address'],['verifier','address'],['arbitrator','address'],['amount','uint256'],['workDeadline','uint64'],['reviewDeadline','uint64'],['resolutionDeadline','uint64'],['timeoutWorkerBps','uint16'],['agreementHash','bytes32'] ].map(([name,type])=>({name,type})) };
8
+ const transitions = {
9
+ dispute: {from:['submitted','verified'],roles:['requester','worker','verifier'],to:'disputed',effect:true},
10
+ resolve: {from:['disputed'],roles:['arbitrator'],to:'settled',effect:true},
11
+ timeout: {from:['funded','running','submitted','verified','disputed'],roles:['requester','worker','verifier','arbitrator'],to:'settled',effect:true}
12
+ };
13
+ function check(condition,message) { if(!condition) throw new Error('Escrow: '+message); }
14
+ function positive(value,name) { check(typeof value==='string' && /^[1-9][0-9]*$/.test(value),name+' must be a positive decimal string');const n=BigInt(value);check(n<(1n<<256n),name+' exceeds uint256');return n; }
15
+ function bytes32(value) { check(typeof value==='string' && /^0x[0-9a-fA-F]{64}$/.test(value) && value!==ZeroHash,'nonzero bytes32 required');return value; }
16
+ function escrowTerms(job,{chainId,contractAddress,tokenAddress}) {
17
+ check(job.agreement && hashAgreement(job.agreement.terms)===job.agreement.hash,'agreement hash mismatch');
18
+ const e=job.agreement.terms.escrow;
19
+ check(e && e.chain_id===String(chainId) && getAddress(e.contract)===getAddress(contractAddress) && getAddress(e.token)===getAddress(tokenAddress),'chain, contract or token mismatch');
20
+ const integer=(value,name)=>{check(Number.isSafeInteger(value)&&value>0,name+' must be positive integer seconds');return value;};
21
+ const t={jobId:sha256(toUtf8Bytes('a423:escrow:v1:'+job.id)),payer:getAddress(e.payer),worker:getAddress(e.worker),verifier:getAddress(e.verifier),arbitrator:getAddress(e.arbitrator),amount:positive(e.amount,'amount'),workDeadline:integer(e.work_deadline,'work_deadline'),reviewDeadline:integer(e.review_deadline,'review_deadline'),resolutionDeadline:integer(e.resolution_deadline,'resolution_deadline'),timeoutWorkerBps:e.timeout_worker_bps,agreementHash:bytes32('0x'+job.agreement.hash)};
22
+ check(t.workDeadline<t.reviewDeadline && t.reviewDeadline<t.resolutionDeadline,'deadlines must be increasing');
23
+ check(Number.isInteger(t.timeoutWorkerBps)&&t.timeoutWorkerBps>=0&&t.timeoutWorkerBps<=10000,'invalid timeout allocation');
24
+ check(t.payer!==t.worker && ![t.payer,t.worker].includes(t.arbitrator),'invalid party separation');
25
+ return t;
26
+ }
27
+ function workerConsent(job,configuration) { const terms=escrowTerms(job,configuration);return {domain:{name:'A423Escrow',version:'1',chainId:configuration.chainId,verifyingContract:getAddress(configuration.contractAddress)},types:TERMS_TYPES,value:terms}; }
28
+ /** Test-only journal. Production must persist records and reserve nonces atomically across instances. */
29
+ function createMemoryTransactionStore() {
30
+ const records=new Map(),nonces=new Map(),reservations=new Map();
31
+ return {
32
+ async get(id){return records.has(id)?structuredClone(records.get(id)):null;},
33
+ async create(id,record){if(records.has(id))return false;records.set(id,structuredClone(record));return true;},
34
+ async reserveNonce(scope,minimum,operationId){const key=scope+':'+operationId;if(reservations.has(key))return reservations.get(key);const nonce=Math.max(minimum,nonces.get(scope)||0);nonces.set(scope,nonce+1);reservations.set(key,nonce);return nonce;}
35
+ };
36
+ }
37
+ function createEscrowAdapters({provider,chainId,contractAddress,tokenAddress,signerFor,addressFor,transactions,confirmations,validateArtifact}={}) {
38
+ check(provider && typeof signerFor==='function' && typeof addressFor==='function','provider, signerFor and addressFor required');
39
+ check(Number.isSafeInteger(chainId)&&chainId>0 && Number.isSafeInteger(confirmations)&&confirmations>0,'explicit chain ID and positive confirmations required');
40
+ check(transactions && ['get','create','reserveNonce'].every(k=>typeof transactions[k]==='function'),'durable transaction journal required');
41
+ const config={chainId,contractAddress:getAddress(contractAddress),tokenAddress:getAddress(tokenAddress)};
42
+ const contract=new Contract(config.contractAddress,ABI,provider), iface=new Interface(ABI), token=new Contract(config.tokenAddress,TOKEN_ABI,provider);
43
+ async function context(job) {
44
+ check(Number((await provider.getNetwork()).chainId)===chainId,'RPC chain mismatch');
45
+ check(getAddress(await contract.token())===config.tokenAddress,'contract token mismatch');
46
+ const t=escrowTerms(job,config);
47
+ for(const [role,field] of [['requester','payer'],['worker','worker'],['verifier','verifier'],['arbitrator','arbitrator']]) check(job.participants[role] && getAddress(await addressFor(job.participants[role]))===t[field],role+' wallet mismatch');
48
+ return t;
49
+ }
50
+ function match(snapshot,t) {
51
+ for(const key of Object.keys(t)) check(String(snapshot.terms[key]).toLowerCase()===String(t[key]).toLowerCase(),'on-chain '+key+' mismatch');
52
+ }
53
+ async function receipt(hash) {
54
+ const r=await provider.getTransactionReceipt(hash);
55
+ if(!r)return null;
56
+ const head=await provider.getBlock('latest');
57
+ const block=await provider.getBlock(r.blockNumber);
58
+ if(!block || block.hash!==r.blockHash || head.number-r.blockNumber+1<confirmations)return null;
59
+ return r;
60
+ }
61
+ async function send(ctx,suffix,to,data,reconcile) {
62
+ const id=ctx.operation.id+':'+suffix;
63
+ let record=await transactions.get(id);
64
+ if(!record && reconcile)return {status:'pending',reference:id};
65
+ if(!record) {
66
+ const signer=await signerFor(ctx.operation.actor);
67
+ const address=getAddress(await signer.getAddress());
68
+ check(address===getAddress(await addressFor(ctx.operation.actor)),'signer ownership mismatch');
69
+ const minimum=await provider.getTransactionCount(address,'pending');
70
+ let request;
71
+ try {request=await signer.populateTransaction({to,data,nonce:minimum,chainId});}
72
+ catch(err) {if(err.code==='CALL_EXCEPTION' || err.code==='INSUFFICIENT_FUNDS')return {status:'rejected',reason:'Transaction preflight rejected before signing'};throw err;}
73
+ const nonce=await transactions.reserveNonce(`${chainId}:${address}`,minimum,id);
74
+ request.nonce=nonce;
75
+ const raw=await signer.signTransaction(request);
76
+ const tx=Transaction.from(raw);
77
+ check(tx.from===address && Number(tx.chainId)===chainId && tx.to===getAddress(to) && tx.data===data && tx.nonce===nonce && tx.value===0n,'signed transaction mismatch');
78
+ await transactions.create(id,{hash:tx.hash,raw,to:getAddress(to),data,from:address});
79
+ record=await transactions.get(id);
80
+ }
81
+ check(record.to===getAddress(to) && record.data===data && record.from===getAddress(await addressFor(ctx.operation.actor)),'journal request mismatch');
82
+ let r=await receipt(record.hash);
83
+ if(!r && !reconcile) {
84
+ try {await provider.broadcastTransaction(record.raw);} catch(err) {if(!await provider.getTransaction(record.hash))throw err;}
85
+ r=await receipt(record.hash);
86
+ }
87
+ if(!r)return {status:'pending',reference:record.hash};
88
+ if(r.status!==1)return {status:'rejected',reason:'Transaction reverted',reference:record.hash};
89
+ return {receipt:r};
90
+ }
91
+ async function observe(ctx,t,suffix,method,args,eventName,validate,reconcile) {
92
+ let sent;
93
+ if(ctx.input.transaction_hash) {
94
+ check(/^0x[0-9a-fA-F]{64}$/.test(ctx.input.transaction_hash),'invalid transaction hash');
95
+ const r=await receipt(ctx.input.transaction_hash);
96
+ sent=!r?{status:'pending',reference:ctx.input.transaction_hash}:r.status===1?{receipt:r}:{status:'rejected',reason:'Transaction reverted',reference:ctx.input.transaction_hash};
97
+ } else sent=await send(ctx,suffix,config.contractAddress,iface.encodeFunctionData(method,args),reconcile);
98
+ if(!sent.receipt)return sent;
99
+ const r=sent.receipt;
100
+ const logs=r.logs.filter(log=>getAddress(log.address)===config.contractAddress).map(log=>{try{return iface.parseLog(log);}catch{return null;}});
101
+ const event=logs.find(log=>log?.name===eventName && log.args.jobId===t.jobId);
102
+ check(event,'matching '+eventName+' event missing');
103
+ const snapshot=await contract.getJob(t.jobId,{blockTag:r.blockNumber});match(snapshot,t);validate(snapshot,event);
104
+ return {status:'confirmed',evidence:{chain_id:String(chainId),contract:config.contractAddress,token:config.tokenAddress,job_id:t.jobId,agreement_hash:t.agreementHash,transaction_hash:r.hash,block_number:r.blockNumber,block_hash:r.blockHash,confirmations,event:eventName,amount:t.amount.toString(),payer:t.payer,worker:t.worker,verifier:t.verifier,arbitrator:t.arbitrator,result_hash:snapshot.resultHash,worker_paid:snapshot.workerPaid.toString(),state:Number(snapshot.state),observed_at:new Date().toISOString()}};
105
+ }
106
+ function adapter(run){return {execute:ctx=>run(ctx,false),reconcile:ctx=>run(ctx,true)};}
107
+ const state=(s,n)=>check(Number(s.state)===n,'unexpected escrow state');
108
+ const settled=(s,e,t)=>{check([5,6,7].includes(Number(s.state)),'not settled');check(e.args.workerAmount+e.args.payerAmount===t.amount && e.args.workerAmount===s.workerPaid,'settlement amount mismatch');};
109
+ return {
110
+ fund:adapter(async(ctx,reconcile)=>{
111
+ const t=await context(ctx.job);
112
+ check([ctx.job.participants.requester,ctx.job.participants.worker].every(p=>ctx.job.agreement.accepted_by.includes(p)),'both acceptances required');
113
+ if(typeof ctx.input.worker_signature!=='string')return {status:'rejected',reason:'worker_signature required'};
114
+ // Consent is checked locally as well as by the contract before moving tokens.
115
+ const {verifyTypedData}=require('ethers');const c=workerConsent(ctx.job,config);
116
+ try {if(getAddress(verifyTypedData(c.domain,c.types,c.value,ctx.input.worker_signature))!==t.worker)return {status:'rejected',reason:'Invalid worker consent'};} catch {return {status:'rejected',reason:'Invalid worker consent'};}
117
+ // Never send further approvals if the funding transaction already exists in the journal.
118
+ if(!ctx.input.transaction_hash && !await transactions.get(ctx.operation.id+':fund')) {
119
+ let allowance=await token.allowance(t.payer,config.contractAddress);
120
+ if(allowance<t.amount) {
121
+ if(allowance>0n){const reset=await send(ctx,'approve_zero',config.tokenAddress,token.interface.encodeFunctionData('approve',[config.contractAddress,0]),reconcile);if(!reset.receipt)return reset;}
122
+ const approval=await send(ctx,'approve',config.tokenAddress,token.interface.encodeFunctionData('approve',[config.contractAddress,t.amount]),reconcile);if(!approval.receipt)return approval;
123
+ }
124
+ }
125
+ return observe(ctx,t,'fund','fund',[t,ctx.input.worker_signature],'Funded',(s,e)=>{check(Number(s.state)>0,'missing funded job');check(e.args.amount===t.amount && getAddress(e.args.payer)===t.payer && getAddress(e.args.worker)===t.worker && e.args.agreementHash===t.agreementHash,'funding event mismatch');},reconcile);
126
+ }),
127
+ start:adapter(async ctx=>{const t=await context(ctx.job);const block=await provider.getBlock('latest');const safeNumber=block.number-confirmations+1;check(safeNumber>=0,'insufficient confirmation depth');const safe=await provider.getBlock(safeNumber);const confirmed=await contract.getJob(t.jobId,{blockTag:safeNumber});match(confirmed,t);state(confirmed,1);const s=await contract.getJob(t.jobId,{blockTag:block.number});match(s,t);state(s,1);check(block.timestamp<=t.workDeadline,'work deadline passed');return {status:'confirmed',evidence:{chain_id:String(chainId),contract:config.contractAddress,job_id:t.jobId,block_number:safe.number,block_hash:safe.hash,confirmations,amount:t.amount.toString(),agreement_hash:t.agreementHash}};}),
128
+ submit:adapter(async(ctx,r)=>{const t=await context(ctx.job);const hash=bytes32(ctx.input.result_hash);return observe(ctx,t,'submit','submit',[t.jobId,hash],'Submitted',(s,e)=>{check(Number(s.state)>=2,'missing submission');check(s.resultHash===hash && e.args.resultHash===hash,'artifact commitment mismatch');},r);}),
129
+ verify:adapter(async(ctx,r)=>{const t=await context(ctx.job);const hash=bytes32(ctx.job.submission?.result_hash);if(!r && !await transactions.get(ctx.operation.id+':verify')){check(typeof validateArtifact==='function','validateArtifact callback required');if(await validateArtifact({job:structuredClone(ctx.job),resultHash:hash})!==true)return {status:'rejected',reason:'Artifact verification failed'};}return observe(ctx,t,'verify','verify',[t.jobId,hash],'Verified',(s,e)=>{check(Number(s.state)>=3,'missing verification');check(s.resultHash===hash && e.args.resultHash===hash,'verified artifact mismatch');},r);}),
130
+ release:adapter(async(ctx,r)=>{const t=await context(ctx.job);return observe(ctx,t,'release','release',[t.jobId],'Settled',(s,e)=>{state(s,5);settled(s,e,t);check(e.args.workerAmount===t.amount && Number(e.args.reason)===0,'not a full release');},r);}),
131
+ refund:adapter(async(ctx,r)=>{const t=await context(ctx.job);return observe(ctx,t,'refund','refundExpired',[t.jobId],'Settled',(s,e)=>{state(s,6);settled(s,e,t);check(e.args.payerAmount===t.amount && Number(e.args.reason)===1,'not an expiry refund');},r);}),
132
+ dispute:adapter(async(ctx,r)=>{const t=await context(ctx.job);const reason=bytes32(ctx.input.reason_hash);return observe(ctx,t,'dispute','dispute',[t.jobId,reason],'Disputed',(s,e)=>{check(Number(s.state)>=4,'missing dispute');check(e.args.reasonHash===reason,'reason mismatch');},r);}),
133
+ resolve:adapter(async(ctx,r)=>{const t=await context(ctx.job);check(typeof ctx.input.worker_amount==='string' && /^(0|[1-9][0-9]*)$/.test(ctx.input.worker_amount),'worker_amount must be decimal string');const amount=BigInt(ctx.input.worker_amount);check(amount<=t.amount,'resolution exceeds escrow');return observe(ctx,t,'resolve','resolve',[t.jobId,amount],'Settled',(s,e)=>{settled(s,e,t);check(e.args.workerAmount===amount && Number(e.args.reason)===2,'resolution mismatch');},r);}),
134
+ timeout:adapter(async(ctx,r)=>{const t=await context(ctx.job);const method=['funded','running'].includes(ctx.job.state)?'refundExpired':'timeout';return observe(ctx,t,'timeout',method,[t.jobId],'Settled',(s,e)=>{settled(s,e,t);const expected=method==='refundExpired'?0n:(t.amount*BigInt(t.timeoutWorkerBps))/10000n;check(e.args.workerAmount===expected && Number(e.args.reason)===(method==='refundExpired'?1:3),'timeout allocation mismatch');},r);})
135
+ };
136
+ }
137
+ module.exports={createEscrowAdapters,createMemoryTransactionStore,workerConsent,escrowTerms,escrowTransitions:transitions,ESCROW_ABI:ABI,ESCROW_BYTECODE:artifact.bytecode};
@@ -0,0 +1,36 @@
1
+ import type { Provider, Signer, TypedDataField, InterfaceAbi } from 'ethers';
2
+ import type { WorkflowJob, AdapterContext, AdapterResult, Transition } from '../workflow.js';
3
+ export interface EscrowConfiguration { chainId: number; contractAddress: string; tokenAddress: string }
4
+ export interface ContractTerms {
5
+ jobId: string; payer: string; worker: string; verifier: string; arbitrator: string;
6
+ amount: bigint; workDeadline: number; reviewDeadline: number; resolutionDeadline: number;
7
+ timeoutWorkerBps: number; agreementHash: string;
8
+ }
9
+ export interface TransactionRecord { hash: string; raw: string; to: string; data: string; from: string }
10
+ export interface TransactionStore {
11
+ get(id: string): Promise<TransactionRecord | null>;
12
+ /** Atomic insert if absent; caller reads winner before broadcasting. */
13
+ create(id: string, record: TransactionRecord): Promise<boolean>;
14
+ /** Atomically reserve a nonce per chain/address; same operationId always returns same nonce. */
15
+ reserveNonce(scope: string, minimum: number, operationId: string): Promise<number>;
16
+ }
17
+ export interface EscrowAdapter { execute(context: AdapterContext): Promise<AdapterResult>; reconcile(context: AdapterContext): Promise<AdapterResult> }
18
+ export interface EscrowAdapterOptions extends EscrowConfiguration {
19
+ provider: Provider;
20
+ /** Requires populateTransaction + offline signTransaction support; no silent wallet approval. */
21
+ signerFor(actor: string): Promise<Signer>;
22
+ addressFor(actor: string): Promise<string>;
23
+ transactions: TransactionStore;
24
+ confirmations: number;
25
+ validateArtifact?(context: { job: WorkflowJob; resultHash: string }): Promise<boolean>;
26
+ }
27
+ export function createEscrowAdapters(options: EscrowAdapterOptions): Record<'fund'|'start'|'submit'|'verify'|'release'|'refund'|'dispute'|'resolve'|'timeout', EscrowAdapter>;
28
+ export function createMemoryTransactionStore(): TransactionStore;
29
+ export function escrowTerms(job: WorkflowJob, config: EscrowConfiguration): ContractTerms;
30
+ export function workerConsent(job: WorkflowJob, config: EscrowConfiguration): {
31
+ domain: { name: string; version: string; chainId: number; verifyingContract: string };
32
+ types: Record<string, TypedDataField[]>; value: ContractTerms;
33
+ };
34
+ export const escrowTransitions: Record<string, Transition>;
35
+ export const ESCROW_ABI: InterfaceAbi;
36
+ export const ESCROW_BYTECODE: string;
@@ -0,0 +1,36 @@
1
+ import type { Provider, Signer, TypedDataField, InterfaceAbi } from 'ethers';
2
+ import type { WorkflowJob, AdapterContext, AdapterResult, Transition } from '../workflow.js';
3
+ export interface EscrowConfiguration { chainId: number; contractAddress: string; tokenAddress: string }
4
+ export interface ContractTerms {
5
+ jobId: string; payer: string; worker: string; verifier: string; arbitrator: string;
6
+ amount: bigint; workDeadline: number; reviewDeadline: number; resolutionDeadline: number;
7
+ timeoutWorkerBps: number; agreementHash: string;
8
+ }
9
+ export interface TransactionRecord { hash: string; raw: string; to: string; data: string; from: string }
10
+ export interface TransactionStore {
11
+ get(id: string): Promise<TransactionRecord | null>;
12
+ /** Atomic insert if absent; caller reads winner before broadcasting. */
13
+ create(id: string, record: TransactionRecord): Promise<boolean>;
14
+ /** Atomically reserve a nonce per chain/address; same operationId always returns same nonce. */
15
+ reserveNonce(scope: string, minimum: number, operationId: string): Promise<number>;
16
+ }
17
+ export interface EscrowAdapter { execute(context: AdapterContext): Promise<AdapterResult>; reconcile(context: AdapterContext): Promise<AdapterResult> }
18
+ export interface EscrowAdapterOptions extends EscrowConfiguration {
19
+ provider: Provider;
20
+ /** Requires populateTransaction + offline signTransaction support; no silent wallet approval. */
21
+ signerFor(actor: string): Promise<Signer>;
22
+ addressFor(actor: string): Promise<string>;
23
+ transactions: TransactionStore;
24
+ confirmations: number;
25
+ validateArtifact?(context: { job: WorkflowJob; resultHash: string }): Promise<boolean>;
26
+ }
27
+ export function createEscrowAdapters(options: EscrowAdapterOptions): Record<'fund'|'start'|'submit'|'verify'|'release'|'refund'|'dispute'|'resolve'|'timeout', EscrowAdapter>;
28
+ export function createMemoryTransactionStore(): TransactionStore;
29
+ export function escrowTerms(job: WorkflowJob, config: EscrowConfiguration): ContractTerms;
30
+ export function workerConsent(job: WorkflowJob, config: EscrowConfiguration): {
31
+ domain: { name: string; version: string; chainId: number; verifyingContract: string };
32
+ types: Record<string, TypedDataField[]>; value: ContractTerms;
33
+ };
34
+ export const escrowTransitions: Record<string, Transition>;
35
+ export const ESCROW_ABI: InterfaceAbi;
36
+ export const ESCROW_BYTECODE: string;
@@ -0,0 +1,2 @@
1
+ import escrow from './index.cjs';
2
+ export const { createEscrowAdapters, createMemoryTransactionStore, workerConsent, escrowTerms, escrowTransitions, ESCROW_ABI, ESCROW_BYTECODE } = escrow;
@@ -0,0 +1,215 @@
1
+ 'use strict';
2
+ const { createHash, randomUUID } = require('node:crypto');
3
+ const { A423Error } = require('./index.cjs');
4
+ const copy = value => structuredClone(value);
5
+ const error = (code, message, status = 409) => { throw new A423Error(code, message, status); };
6
+ function canonical(value) {
7
+ if (value === null || typeof value === 'boolean' || typeof value === 'string') return JSON.stringify(value);
8
+ if (typeof value === 'number' && Number.isFinite(value)) return JSON.stringify(value);
9
+ if (Array.isArray(value)) {
10
+ if (Object.keys(value).length !== value.length) throw new TypeError('Use dense JSON arrays');
11
+ return '[' + value.map(item => canonical(item)).join(',') + ']';
12
+ }
13
+ if (value && Object.getPrototypeOf(value) === Object.prototype) return '{' + Object.keys(value).sort().map(k => JSON.stringify(k) + ':' + canonical(value[k])).join(',') + '}';
14
+ throw new TypeError('Use JSON values only (no undefined, bigint, class instances or nonfinite numbers)');
15
+ }
16
+ function digest(value) { return createHash('sha256').update(canonical(value)).digest('hex'); }
17
+ function identifier(value) { if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(value)) throw new TypeError('IDs must contain 1–128 letters, digits, underscores or hyphens'); return value; }
18
+ const RULES = {
19
+ quote: { from: ['draft', 'quoted', 'accepted'], roles: ['requester', 'worker'], to: 'quoted' },
20
+ accept: { from: ['quoted'], roles: ['requester', 'worker'], to: 'quoted' },
21
+ fund: { from: ['accepted'], roles: ['requester'], to: 'funded', effect: true },
22
+ start: { from: ['funded'], roles: ['worker'], to: 'running', effect: true },
23
+ submit: { from: ['running'], roles: ['worker'], to: 'submitted', effect: true },
24
+ verify: { from: ['submitted'], roles: ['verifier'], to: 'verified', effect: true },
25
+ release: { from: ['verified'], roles: ['verifier'], to: 'paid', effect: true },
26
+ refund: { from: ['funded', 'running'], roles: ['requester'], to: 'refunded', effect: true },
27
+ cancel: { from: ['draft', 'quoted', 'accepted'], roles: ['requester'], to: 'cancelled' },
28
+ expire: { from: ['draft', 'quoted', 'accepted'], roles: ['requester', 'worker'], to: 'expired', effect: true },
29
+ dispute: { from: ['submitted', 'verified'], roles: ['requester', 'worker', 'verifier'], to: 'disputed' },
30
+ resolve: { from: ['disputed'], roles: ['verifier'], to: 'resolved', effect: true }
31
+ };
32
+ /** Pure canonical JSON SHA-256. This is an integrity commitment, not authentication. */
33
+ function hashAgreement(terms) { return digest(terms); }
34
+ function createWorkflow({ store, adapters = {}, authorize, validateTerms, transitions = {} } = {}) {
35
+ if (!store || ['get', 'create', 'compareAndSet'].some(k => typeof store[k] !== 'function')) throw new TypeError('Supply an atomic workflow store');
36
+ const rules = copy(RULES);
37
+ for (const [action, rule] of Object.entries(transitions)) {
38
+ identifier(action);
39
+ if (!rule || !Array.isArray(rule.from) || !rule.from.length || !Array.isArray(rule.roles) || !rule.roles.length || typeof rule.to !== 'string' || !rule.to) throw new TypeError('Transitions require from, roles and to');
40
+ rules[action] = copy(rule);
41
+ }
42
+ const stamp = () => new Date().toISOString();
43
+ async function save(job, next, type, actor, data = {}) {
44
+ next.version = job.version + 1; next.updated_at = stamp();
45
+ next.events.push({ sequence: next.events.length + 1, type, actor_id: actor, at: next.updated_at, data: copy(data) });
46
+ if (!await store.compareAndSet(job.id, job.version, next)) error('CONFLICT', 'Reload and retry', 409);
47
+ return next;
48
+ }
49
+ async function get(id, actor) {
50
+ identifier(id); identifier(actor);
51
+ const job = await store.get(id);
52
+ if (!job || !Object.values(job.participants).includes(actor)) error('NOT_FOUND', 'Job not found', 404);
53
+ return copy(job);
54
+ }
55
+ async function allowed(job, action, actor, input, rule) {
56
+ if (!rule.roles.some(role => job.participants[role] === actor)) error('FORBIDDEN', 'Actor cannot perform this action', 403);
57
+ if (authorize && await authorize({ job: copy(job), action, actor, input: copy(input) }) !== true) error('FORBIDDEN', 'Application policy rejected action', 403);
58
+ }
59
+ async function complete(job, result) {
60
+ const pending = job.pending;
61
+ if (!result || !['confirmed', 'pending', 'rejected'].includes(result.status)) error('INVALID_ADAPTER_RESULT', 'Adapter must return confirmed, pending or rejected', 502);
62
+ canonical(result);
63
+ if (result.status === 'pending') {
64
+ const next = copy(job); next.pending.observation = copy(result);
65
+ return save(job, next, pending.action + '.pending', pending.actor, result);
66
+ }
67
+ if (result.status === 'rejected') {
68
+ const next = copy(job); next.pending = null;
69
+ next.operations[pending.key] = { request_hash: pending.request_hash, status: 'rejected', result: copy(result) };
70
+ return save(job, next, pending.action + '.rejected', pending.actor, result);
71
+ }
72
+ if (!result.evidence || typeof result.evidence !== 'object' || Array.isArray(result.evidence)) error('INVALID_ADAPTER_RESULT', 'Confirmed effects require structured evidence', 502);
73
+ const next = copy(job); next.pending = null; next.state = pending.target;
74
+ next.operations[pending.key] = { request_hash: pending.request_hash, status: 'confirmed', result: copy(result) };
75
+ next.evidence.push({ action: pending.action, operation_id: pending.id, evidence: copy(result.evidence) });
76
+ if (pending.action === 'submit') next.submission = copy(pending.input);
77
+ return save(job, next, pending.action + '.confirmed', pending.actor, result.evidence);
78
+ }
79
+ async function execute(job, reconcile = false) {
80
+ const pending = job.pending;
81
+ const adapter = adapters[pending.action];
82
+ const fn = reconcile ? adapter?.reconcile : (typeof adapter === 'function' ? adapter : adapter?.execute);
83
+ if (typeof fn !== 'function') error('ADAPTER_REQUIRED', `Configure ${pending.action}${reconcile ? '.reconcile' : ''}`, 501);
84
+ // Persisted idempotency key is reused even after crashes. Adapters must deduplicate durably.
85
+ const result = await fn({ job: copy(job), input: copy(pending.input), operation: copy(pending) });
86
+ return complete(job, result);
87
+ }
88
+ return {
89
+ async create({ id = randomUUID(), participants, metadata = {} }, actor) {
90
+ identifier(id); identifier(actor);
91
+ if (!participants || !participants.requester || !participants.worker || !participants.verifier) throw new TypeError('Supply requester, worker and verifier participants');
92
+ Object.values(participants).forEach(identifier);
93
+ if (participants.requester === participants.worker || actor !== participants.requester) error('FORBIDDEN', 'Requester must create the job and differ from worker', 403);
94
+ canonical(metadata); canonical(participants);
95
+ const at = stamp();
96
+ const job = { id, protocol: 'a423.workflow.v1', version: 1, state: 'draft', participants: copy(participants), metadata: copy(metadata), agreement: null, submission: null, pending: null, operations: {}, evidence: [], events: [{ sequence: 1, type: 'created', actor_id: actor, at, data: {} }], created_at: at, updated_at: at };
97
+ if (authorize && await authorize({ job: copy(job), action: 'create', actor, input: {} }) !== true) error('FORBIDDEN', 'Application policy rejected creation', 403);
98
+ if (!await store.create(job)) error('CONFLICT', 'Job ID already exists');
99
+ return copy(job);
100
+ },
101
+ get,
102
+ async act(id, action, input, { actor, idempotencyKey } = {}) {
103
+ identifier(actor); identifier(idempotencyKey); identifier(action); canonical(input);
104
+ let job = await get(id, actor);
105
+ const rule = rules[action];
106
+ if (!rule) error('UNKNOWN_ACTION', 'Unknown workflow action', 400);
107
+ await allowed(job, action, actor, input, rule);
108
+ const key = digest([actor, idempotencyKey]);
109
+ const requestHash = digest([action, input]);
110
+ if (Object.hasOwn(job.operations, key)) {
111
+ if (job.operations[key].request_hash !== requestHash) error('IDEMPOTENCY_CONFLICT', 'Key used for another request');
112
+ return job;
113
+ }
114
+ if (job.pending) {
115
+ if (job.pending.key !== key || job.pending.request_hash !== requestHash) error('OPERATION_PENDING', 'Reconcile or retry the pending operation first');
116
+ return execute(job);
117
+ }
118
+ if (!rule.from.includes(job.state)) error('INVALID_STATE', `Cannot ${action} from ${job.state}`);
119
+ let next = copy(job);
120
+ if (action === 'quote') {
121
+ if (!input || !input.terms || typeof input.terms !== 'object' || Array.isArray(input.terms)) error('INVALID_INPUT', 'Supply a terms object', 400);
122
+ if (validateTerms) await validateTerms(copy(input.terms));
123
+ next.agreement = { revision: (job.agreement?.revision || 0) + 1, terms: copy(input.terms), hash: hashAgreement(input.terms), accepted_by: [] };
124
+ }
125
+ if (action === 'accept') {
126
+ if (!input || input.agreement_hash !== job.agreement.hash || input.revision !== job.agreement.revision) error('AGREEMENT_MISMATCH', 'Accept the current agreement hash and revision');
127
+ next.agreement.accepted_by = [...new Set([...job.agreement.accepted_by, actor])];
128
+ }
129
+ if (rule.effect) {
130
+ if (!adapters[action]) error('ADAPTER_REQUIRED', `Configure ${action} adapter`, 501);
131
+ next.pending = { id: `a423:${id}:${key}`, key, action, actor, input: copy(input), request_hash: requestHash, target: rule.to, created_at: stamp(), observation: null };
132
+ job = await save(job, next, action + '.requested', actor);
133
+ return execute(job);
134
+ }
135
+ next.state = action === 'accept' && [job.participants.requester, job.participants.worker].every(p => next.agreement.accepted_by.includes(p)) ? 'accepted' : rule.to;
136
+ next.operations[key] = { request_hash: requestHash, status: 'confirmed', result: { status: 'confirmed' } };
137
+ return save(job, next, action, actor, input);
138
+ },
139
+ async reconcile(id, actor) {
140
+ const job = await get(id, actor);
141
+ if (!job.pending) return job;
142
+ await allowed(job, job.pending.action, actor, job.pending.input, rules[job.pending.action]);
143
+ return execute(job, true);
144
+ },
145
+ async receipts(id, actor) {
146
+ const job = await get(id, actor);
147
+ return { protocol: job.protocol, job_id: id, state: job.state, agreement: job.agreement, submission: job.submission, evidence: job.evidence, events: job.events, pending: job.pending };
148
+ }
149
+ };
150
+ }
151
+ function createWorkflowHandler({ workflow, authenticate, basePath = '/api/a423', maxBodyBytes = 65536, onError } = {}) {
152
+ if (!workflow || typeof authenticate !== 'function' || !/^\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+$/.test(basePath)) throw new TypeError('Supply workflow, authenticate and valid basePath');
153
+ if (!Number.isSafeInteger(maxBodyBytes) || maxBodyBytes < 1 || maxBodyBytes > 1048576) throw new TypeError('Invalid maxBodyBytes');
154
+ const respond = (value, status = 200) => Response.json(value, { status, headers: { 'cache-control': 'no-store' } });
155
+ return async request => {
156
+ try {
157
+ const path = new URL(request.url).pathname;
158
+ if (!path.startsWith(basePath + '/')) error('NOT_FOUND', 'Route not found', 404);
159
+ const match = /^\/jobs(?:\/([A-Za-z0-9_-]{1,128})(?:\/(actions|reconcile|receipts))?)?$/.exec(path.slice(basePath.length));
160
+ if (!match) error('NOT_FOUND', 'Route not found', 404);
161
+ const [, id, action] = match;
162
+ const method = !id || action === 'actions' || action === 'reconcile' ? 'POST' : 'GET';
163
+ if (request.method !== method) { const r = respond({ error: { code: 'METHOD_NOT_ALLOWED', message: `Use ${method}` } }, 405); r.headers.set('allow', method); return r; }
164
+ const actor = await authenticate(request);
165
+ if (!actor) error('UNAUTHENTICATED', 'Authentication required', 401);
166
+ let body;
167
+ if (method === 'POST') {
168
+ if (!(request.headers.get('content-type') || '').toLowerCase().startsWith('application/json')) error('INVALID_INPUT', 'Use application/json', 415);
169
+ const reader = request.body?.getReader(); if (!reader) error('INVALID_INPUT', 'JSON body required', 400);
170
+ let size = 0; const chunks = [];
171
+ try { while (true) { const { done, value } = await reader.read(); if (done) break; size += value.byteLength; if (size > maxBodyBytes) { await reader.cancel(); error('BODY_TOO_LARGE', 'Body too large', 413); } chunks.push(Buffer.from(value)); } } finally { reader.releaseLock(); }
172
+ try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { error('INVALID_INPUT', 'Invalid JSON', 400); }
173
+ }
174
+ if (!id) return respond({ job: await workflow.create(body, actor) }, 201);
175
+ if (action === 'actions') return respond({ job: await workflow.act(id, body.action, body.input ?? {}, { actor, idempotencyKey: request.headers.get('idempotency-key') }) });
176
+ if (action === 'reconcile') return respond({ job: await workflow.reconcile(id, actor) });
177
+ if (action === 'receipts') return respond(await workflow.receipts(id, actor));
178
+ return respond({ job: await workflow.get(id, actor) });
179
+ } catch (err) {
180
+ if (err instanceof A423Error) return respond({ error: { code: err.code, message: err.message } }, err.status);
181
+ if (err instanceof TypeError) return respond({ error: { code: 'INVALID_INPUT', message: 'Invalid workflow input' } }, 400);
182
+ if (onError) { try { await onError(err); } catch {} }
183
+ return respond({ error: { code: 'INTERNAL_ERROR', message: 'Workflow operation failed; inspect and reconcile pending operations' } }, 500);
184
+ }
185
+ };
186
+ }
187
+ module.exports = { createWorkflow, createWorkflowHandler, hashAgreement };
188
+
189
+ function createWorkflowClient({ baseUrl, headers, fetch: fetchImpl = globalThis.fetch, timeoutMs = 30000 } = {}) {
190
+ const url = new URL(baseUrl);
191
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || typeof fetchImpl !== 'function' || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new TypeError('Invalid client options');
192
+ const base = url.href.replace(/\/$/, '');
193
+ async function request(path, body, key) {
194
+ const h = new Headers(typeof headers === 'function' ? await headers() : headers);
195
+ h.set('accept', 'application/json');
196
+ if (body !== undefined) h.set('content-type', 'application/json');
197
+ if (key !== undefined) h.set('idempotency-key', identifier(key));
198
+ const response = await fetchImpl(base + path, { method: body === undefined ? 'GET' : 'POST', headers: h, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs), redirect: 'error' });
199
+ const reader = response.body?.getReader(); if (!reader) error('INVALID_RESPONSE', 'Missing response body', 502);
200
+ const chunks = []; let size = 0;
201
+ try { while (true) { const {done,value} = await reader.read(); if(done) break; size += value.byteLength; if(size > 1048576) { await reader.cancel(); error('INVALID_RESPONSE','Response exceeds 1 MiB',502); } chunks.push(Buffer.from(value)); } } finally { reader.releaseLock(); }
202
+ let data; try { data=JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { error('INVALID_RESPONSE','Invalid JSON response',502); }
203
+ if (!response.ok) error(data.error?.code || 'HTTP_ERROR',data.error?.message || 'Request failed',response.status);
204
+ return data;
205
+ }
206
+ const path = id => '/jobs/' + identifier(id);
207
+ return {
208
+ async create(input) { return (await request('/jobs',input)).job; },
209
+ async get(id) { return (await request(path(id))).job; },
210
+ async act(id,action,input,{idempotencyKey}={}) { identifier(action);identifier(idempotencyKey);return (await request(path(id)+'/actions',{action,input},idempotencyKey)).job; },
211
+ async reconcile(id) { return (await request(path(id)+'/reconcile',{})).job; },
212
+ async receipts(id) { return request(path(id)+'/receipts'); }
213
+ };
214
+ }
215
+ module.exports.createWorkflowClient = createWorkflowClient;
@@ -0,0 +1,57 @@
1
+ export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
2
+ export interface Agreement { revision: number; terms: { [key: string]: Json }; hash: string; accepted_by: string[] }
3
+ export interface Operation { id: string; key: string; action: string; actor: string; input: Json; request_hash: string; target: string; created_at: string; observation: AdapterResult | null }
4
+ export interface AdapterResult { status: 'pending' | 'confirmed' | 'rejected'; evidence?: { [key: string]: Json }; [key: string]: Json | undefined }
5
+ export interface WorkflowJob {
6
+ id: string; protocol: 'a423.workflow.v1'; version: number; state: string;
7
+ participants: { requester: string; worker: string; verifier: string; [role: string]: string };
8
+ metadata: Json; agreement: Agreement | null; submission: Json;
9
+ pending: Operation | null;
10
+ operations: Record<string, { request_hash: string; status: string; result: AdapterResult }>;
11
+ evidence: Array<{ action: string; operation_id: string; evidence: { [key: string]: Json } }>;
12
+ events: Array<{ sequence: number; type: string; actor_id: string; at: string; data: Json }>;
13
+ created_at: string; updated_at: string;
14
+ }
15
+ export interface WorkflowStore {
16
+ get(id: string): Promise<WorkflowJob | null>;
17
+ create(job: WorkflowJob): Promise<boolean>;
18
+ compareAndSet(id: string, expectedVersion: number, job: WorkflowJob): Promise<boolean>;
19
+ }
20
+ export interface AdapterContext { job: WorkflowJob; input: Json; operation: Operation }
21
+ export type Effect = (context: AdapterContext) => Promise<AdapterResult>;
22
+ export interface Transition { from: string[]; roles: string[]; to: string; effect?: boolean }
23
+ export interface WorkflowOptions {
24
+ store: WorkflowStore;
25
+ adapters?: Record<string, Effect | { execute: Effect; reconcile?: Effect }>;
26
+ /** Return true to allow; authenticated actor ID must be supplied by your application. */
27
+ authorize?(context: { job: WorkflowJob; action: string; actor: string; input: Json }): boolean | Promise<boolean>;
28
+ /** Throw to reject application-specific terms. */
29
+ validateTerms?(terms: { [key: string]: Json }): void | Promise<void>;
30
+ /** Explicit overrides replace the full rule; application is responsible for its invariants. */
31
+ transitions?: Record<string, Transition>;
32
+ }
33
+ export interface Workflow {
34
+ create(input: { id?: string; participants: WorkflowJob['participants']; metadata?: Json }, actor: string): Promise<WorkflowJob>;
35
+ get(id: string, actor: string): Promise<WorkflowJob>;
36
+ act(id: string, action: string, input: Json, options: { actor: string; idempotencyKey: string }): Promise<WorkflowJob>;
37
+ reconcile(id: string, actor: string): Promise<WorkflowJob>;
38
+ receipts(id: string, actor: string): Promise<Pick<WorkflowJob, 'protocol' | 'state' | 'agreement' | 'submission' | 'evidence' | 'events' | 'pending'> & { job_id: string }>;
39
+ }
40
+ export function hashAgreement(terms: Json): string;
41
+ export function createWorkflow(options: WorkflowOptions): Workflow;
42
+ export function createWorkflowHandler(options: {
43
+ workflow: Workflow; authenticate(request: Request): string | null | Promise<string | null>;
44
+ basePath?: string; maxBodyBytes?: number; onError?(error: unknown): void | Promise<void>;
45
+ }): (request: Request) => Promise<Response>;
46
+ export interface WorkflowClient {
47
+ /** Supply a stable id so creation retries can be resolved with get(id). */
48
+ create(input: Parameters<Workflow['create']>[0]): Promise<WorkflowJob>;
49
+ get(id: string): Promise<WorkflowJob>;
50
+ act(id: string, action: string, input: Json, options: { idempotencyKey: string }): Promise<WorkflowJob>;
51
+ reconcile(id: string): Promise<WorkflowJob>;
52
+ receipts(id: string): ReturnType<Workflow['receipts']>;
53
+ }
54
+ export function createWorkflowClient(options: {
55
+ baseUrl: string; headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
56
+ fetch?: typeof globalThis.fetch; timeoutMs?: number;
57
+ }): WorkflowClient;
@@ -0,0 +1,2 @@
1
+ import workflow from './workflow.cjs';
2
+ export const { createWorkflow, createWorkflowHandler, createWorkflowClient, hashAgreement } = workflow;