@allorc/a423-sdk 0.3.0 → 0.4.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.
- package/CHANGELOG.md +10 -0
- package/ESCROW.md +7 -6
- package/INTEGRATION.md +8 -2
- package/LEGACY.md +251 -0
- package/OPERATIONS.md +182 -0
- package/README.md +65 -317
- package/SECURITY.md +8 -7
- package/contracts/A423Escrow.sol +5 -1
- package/contracts/standard-input.json +80 -0
- package/examples/operations-dashboard.html +16 -0
- package/examples/operations-server.mjs +19 -0
- package/package.json +35 -5
- package/scripts/deploy-escrow.mjs +24 -0
- package/scripts/operations-worker.mjs +22 -0
- package/src/authority/index.cjs +81 -0
- package/src/authority/index.d.ts +24 -0
- package/src/authority/index.mjs +2 -0
- package/src/escrow/artifact.json +1 -1
- package/src/escrow/index.cjs +22 -9
- package/src/escrow/index.d.mts +13 -1
- package/src/escrow/index.d.ts +13 -1
- package/src/escrow/index.mjs +1 -1
- package/src/escrow/services.cjs +119 -0
- package/src/operations/application.cjs +38 -0
- package/src/operations/factory.cjs +33 -0
- package/src/operations/index.cjs +105 -0
- package/src/operations/index.d.ts +29 -0
- package/src/operations/index.mjs +2 -0
- package/src/storage/index.cjs +83 -0
- package/src/storage/index.d.ts +37 -0
- package/src/storage/index.mjs +2 -0
- package/src/workflow.cjs +14 -2
- package/src/workflow.d.ts +3 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const {createWorkflow,hashAgreement}=require('../workflow.cjs');
|
|
3
|
+
const {createCredentialAuthenticator,createAuthorityHandler}=require('../authority/index.cjs');
|
|
4
|
+
function createEscrowApplication({storage,authority,escrows,signerFor,addressFor,limitsFor,approveSigning,validateArtifact,execute,keeperEnabled=false,allowAuthorityRequest=async()=>false,requireIndependentArbitrator=true}={}){
|
|
5
|
+
const {createEscrowRegistry,createEvidenceRevalidator,escrowTerms,workerConsent,escrowTransitions}=require('../escrow/index.cjs');
|
|
6
|
+
const {validateTask,validateEscrowEvidence,createSpendingAuthorizer,createExpiryAdapter,createApplicationHandler,createChatTools}=require('./index.cjs');
|
|
7
|
+
if(!storage||!authority||!Array.isArray(escrows)||!escrows.length||typeof approveSigning!=='function'||typeof limitsFor!=='function')throw new TypeError('Explicit storage, authority, escrow configurations and spending/signing approvals required');
|
|
8
|
+
const registry=new Map(escrows.map(c=>[String(c.chainId)+':'+c.contractAddress.toLowerCase(),c]));
|
|
9
|
+
const configFor=job=>{const e=job.agreement?.terms.escrow;const c=registry.get(e?.chain_id+':'+e?.contract?.toLowerCase());if(!c)throw Error('Unregistered escrow');return c;};
|
|
10
|
+
const checkPolicy=async context=>{
|
|
11
|
+
if(typeof authority.isActive!=='function'||await authority.isActive(context.actor)!==true)return false;
|
|
12
|
+
if((await storage.jobs.getSafety(context.job.id))?.blocked)return false;
|
|
13
|
+
const e=context.job.agreement.terms.escrow;
|
|
14
|
+
if(requireIndependentArbitrator&&[e.payer,e.worker,e.verifier].map(a=>a.toLowerCase()).includes(e.arbitrator.toLowerCase()))return false;
|
|
15
|
+
return await approveSigning(context)===true;
|
|
16
|
+
};
|
|
17
|
+
const authorizeTransaction=createSpendingAuthorizer({spending:storage.spending,limitsFor,authorize:checkPolicy});
|
|
18
|
+
const adapters=createEscrowRegistry(escrows.map(c=>({...c,signerFor,addressFor,transactions:storage.transactions,authorizeTransaction,validateArtifact})));
|
|
19
|
+
const workflow=createWorkflow({store:storage.jobs,adapters:{...adapters,expire:createExpiryAdapter()},transitions:escrowTransitions,
|
|
20
|
+
authorize:async({actor})=>authority.isActive(actor),
|
|
21
|
+
validateTerms:terms=>{validateTask(terms.task);const job={id:'terms_validation',agreement:{terms,hash:hashAgreement(terms)}};escrowTerms(job,configFor(job));},
|
|
22
|
+
validateEvidence:({evidence})=>{if(evidence.transaction_hash)validateEscrowEvidence(evidence);}
|
|
23
|
+
});
|
|
24
|
+
const validators=new Map(escrows.map(c=>[String(c.chainId)+':'+c.contractAddress.toLowerCase(),createEvidenceRevalidator(c)]));
|
|
25
|
+
const revalidate=(job,evidence)=>validators.get(String(configFor(job).chainId)+':'+configFor(job).contractAddress.toLowerCase())(job,evidence);
|
|
26
|
+
const authenticate=createCredentialAuthenticator({authority});
|
|
27
|
+
const applicationOptions={workflow,store:storage.jobs,queue:storage.queue,authenticate,revalidate,consentFor:async job=>workerConsent(job,configFor(job)),authorityHandler:createAuthorityHandler({authority,allowRequest:allowAuthorityRequest})};
|
|
28
|
+
const workerOptions={authority,actorFor:async job=>job.pending?.actor||job.participants.requester,allowExecute:keeperEnabled,
|
|
29
|
+
execute,beforeExecute:async job=>{await workflow.assertSafe(job.id,job.participants.worker);if(!await authority.isActive(job.participants.worker))return false;const result=await adapters.start.execute({job,input:{},operation:{actor:job.participants.worker}});return result.status==='confirmed';}
|
|
30
|
+
};
|
|
31
|
+
return {workflow,applicationOptions,workerOptions,handler:createApplicationHandler(applicationOptions),chatTools:actor=>createChatTools({workflow,store:storage.jobs,queue:storage.queue,actor})};
|
|
32
|
+
}
|
|
33
|
+
module.exports={createEscrowApplication};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const {createHash,createHmac,randomUUID}=require('node:crypto');
|
|
3
|
+
const hash=x=>createHash('sha256').update(x).digest('hex');
|
|
4
|
+
function assert(ok,message){if(!ok)throw new TypeError(message);}
|
|
5
|
+
function validateTask(task){
|
|
6
|
+
assert(task&&typeof task==='object'&&!Array.isArray(task),'Task required');
|
|
7
|
+
for(const name of ['id','type','description'])assert(typeof task[name]==='string'&&task[name].trim()&&task[name].length<=16000,'Invalid task '+name);
|
|
8
|
+
assert(Array.isArray(task.deliverables)&&task.deliverables.length>0&&task.deliverables.length<=100,'Deliverables required');
|
|
9
|
+
const ids=new Set();for(const d of task.deliverables){assert(d&&typeof d.id==='string'&&!ids.has(d.id),'Unique deliverable IDs required');ids.add(d.id);assert(typeof d.media_type==='string'&&typeof d.description==='string','Deliverable media_type and description required');}
|
|
10
|
+
assert(task.acceptance&&typeof task.acceptance.validator==='string'&&typeof task.acceptance.version==='string'&&task.acceptance.criteria&&typeof task.acceptance.criteria==='object','Versioned acceptance criteria required');
|
|
11
|
+
JSON.stringify(task);return structuredClone(task);
|
|
12
|
+
}
|
|
13
|
+
function validateEscrowEvidence(e){
|
|
14
|
+
assert(e&&typeof e==='object','Escrow evidence required');
|
|
15
|
+
for(const key of ['chain_id','amount','worker_paid'])assert(typeof e[key]==='string'&&/^(0|[1-9][0-9]*)$/.test(e[key]),'Invalid evidence '+key);
|
|
16
|
+
for(const key of ['contract','token','payer','worker','verifier','arbitrator'])assert(/^0x[0-9a-fA-F]{40}$/.test(e[key]),'Invalid evidence '+key);
|
|
17
|
+
for(const key of ['job_id','agreement_hash','transaction_hash','block_hash','result_hash'])assert(/^0x[0-9a-fA-F]{64}$/.test(e[key]),'Invalid evidence '+key);
|
|
18
|
+
assert(Number.isSafeInteger(e.block_number)&&e.block_number>=0&&Number.isSafeInteger(e.confirmations)&&e.confirmations>0,'Invalid evidence block');
|
|
19
|
+
assert(['Funded','Submitted','Verified','Disputed','Settled'].includes(e.event),'Invalid evidence event');
|
|
20
|
+
assert(Number.isInteger(e.state)&&e.state>=1&&e.state<=7&&BigInt(e.worker_paid)<=BigInt(e.amount),'Invalid evidence allocation/state');
|
|
21
|
+
return structuredClone(e);
|
|
22
|
+
}
|
|
23
|
+
function createSpendingAuthorizer({spending,limitsFor,authorize}={}){
|
|
24
|
+
assert(spending&&typeof limitsFor==='function'&&typeof authorize==='function','Spending store, limitsFor and authorize required');
|
|
25
|
+
return async context=>{
|
|
26
|
+
if(await authorize(context)!==true)return false;
|
|
27
|
+
if(['fund','approve','approve_zero'].includes(context.step)){
|
|
28
|
+
const e=context.job.agreement.terms.escrow,limits=await limitsFor(context);
|
|
29
|
+
try {await spending.reserve({...limits,id:hash(JSON.stringify([e.chain_id,e.contract.toLowerCase(),context.job.id])),scope:JSON.stringify([e.chain_id,e.payer.toLowerCase(),e.token.toLowerCase()]),amount:e.amount});}catch(error){if(error.code==='SPENDING_LIMIT_EXCEEDED')return false;throw error;}
|
|
30
|
+
}
|
|
31
|
+
return true;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** Resume is deliberately separate from observe-only reconcile; caller must authorize execution. */
|
|
35
|
+
async function runMaintenanceOnce({workflow,store,actorFor,allowExecute=false,now=Date.now()}={}){
|
|
36
|
+
const results=[];let after='';
|
|
37
|
+
while(true){const jobs=await store.list({after,limit:100});if(!jobs.length)break;after=jobs.at(-1).id;
|
|
38
|
+
for(const job of jobs){try{
|
|
39
|
+
const actor=await actorFor(job);if(!actor)continue;
|
|
40
|
+
let result=job;
|
|
41
|
+
if(job.pending){result=await workflow.reconcile(job.id,actor);if(result.pending&&allowExecute)result=await workflow.resume(job.id,actor);}
|
|
42
|
+
else if(job.agreement){const e=job.agreement.terms.escrow;const seconds=Math.floor(now/1000);let action;
|
|
43
|
+
if(e&&['funded','running'].includes(job.state)&&seconds>e.work_deadline)action='timeout';
|
|
44
|
+
if(e&&['submitted','verified','disputed'].includes(job.state)&&seconds>e.resolution_deadline)action='timeout';
|
|
45
|
+
const expiry=job.agreement.terms.expires_at;
|
|
46
|
+
if(['quoted','accepted'].includes(job.state)&&Number.isSafeInteger(expiry)&&seconds>expiry)action='expire';
|
|
47
|
+
if(action&&allowExecute)result=await workflow.act(job.id,action,{}, {actor,idempotencyKey:'keeper_'+hash(job.id+':'+action)});
|
|
48
|
+
}
|
|
49
|
+
results.push({id:job.id,state:result.state,pending:!!result.pending});
|
|
50
|
+
}catch(error){results.push({id:job.id,error:error.message});}}
|
|
51
|
+
}
|
|
52
|
+
return results;
|
|
53
|
+
}
|
|
54
|
+
function createExpiryAdapter({now=()=>Date.now()}={}){const run=async({job})=>{
|
|
55
|
+
const expiry=job.agreement?.terms.expires_at;
|
|
56
|
+
if(!Number.isSafeInteger(expiry)||Math.floor(now()/1000)<=expiry)return {status:'rejected',reason:'Agreement has not expired'};
|
|
57
|
+
return {status:'confirmed',evidence:{expires_at:expiry,observed_at:new Date(now()).toISOString()}};
|
|
58
|
+
};return {execute:run,reconcile:run};}
|
|
59
|
+
async function runExecutionOnce({workflow,queue,execute,beforeExecute,leaseMs=30000}={}){
|
|
60
|
+
const item=await queue.claim({kind:'execution',leaseMs});if(!item)return null;
|
|
61
|
+
const heartbeat=setInterval(()=>{queue.heartbeat(item.id,item.lease,{leaseMs}).catch(()=>{});},Math.max(1,Math.floor(leaseMs/3)));
|
|
62
|
+
try{
|
|
63
|
+
let result=item.result;
|
|
64
|
+
if(!result){const job=await workflow.assertSafe(item.payload.job_id,item.payload.actor);assert(job.state==='running','Work requires running state');validateTask(job.agreement.terms.task);assert(typeof beforeExecute==='function'&&await beforeExecute(job)===true,'Execution eligibility check required');result=await execute({job,idempotencyKey:item.id});assert(await queue.checkpoint(item.id,item.lease,result),'Execution lease lost');}
|
|
65
|
+
assert(typeof result.result_hash==='string','Execution must return artifact commitment');
|
|
66
|
+
const job=await workflow.act(item.payload.job_id,'submit',result,{actor:item.payload.actor,idempotencyKey:'execution_'+hash(item.id)});
|
|
67
|
+
assert(await queue.complete(item.id,item.lease,{job_id:job.id,state:job.state}),'Execution lease lost');return job;
|
|
68
|
+
}catch(error){await queue.retry(item.id,item.lease,{delayMs:1000});throw error;}finally{clearInterval(heartbeat);}
|
|
69
|
+
}
|
|
70
|
+
async function enqueueRunningJobs({store,queue}){let after='',count=0;while(true){const jobs=await store.list({after,limit:100});if(!jobs.length)break;after=jobs.at(-1).id;for(const j of jobs)if(j.state==='running'&&!j.pending){const event=j.events.filter(e=>e.type==='start.confirmed').at(-1);if(event&&await queue.enqueue({id:'execution_'+hash(j.id+':'+event.sequence),kind:'execution',payload:{job_id:j.id,actor:j.participants.worker}}))count++;}}return count;}
|
|
71
|
+
function createEventStream({store,authenticate,pollMs=1000,maxDurationMs=25000}={}){
|
|
72
|
+
return async request=>{
|
|
73
|
+
const actor=await authenticate(request);if(!actor)return Response.json({error:'unauthenticated'},{status:401});
|
|
74
|
+
let cursor=Number(request.headers.get('last-event-id')||new URL(request.url).searchParams.get('after')||0);if(!Number.isSafeInteger(cursor)||cursor<0)return Response.json({error:'invalid cursor'},{status:400});
|
|
75
|
+
let closed=false,timer;const encoder=new TextEncoder(),began=Date.now();
|
|
76
|
+
const stream=new ReadableStream({start(controller){const tick=async()=>{if(closed)return;try{const events=await store.events({actor,after:cursor,limit:100});for(const event of events){cursor=event.cursor;controller.enqueue(encoder.encode(`id: ${cursor}\nevent: a423\ndata: ${JSON.stringify(event)}\n\n`));}if(request.signal.aborted||Date.now()-began>=maxDurationMs){closed=true;controller.close();return;}controller.enqueue(encoder.encode(': heartbeat\n\n'));timer=setTimeout(tick,pollMs);}catch(e){closed=true;controller.error(e);}};void tick();},cancel(){closed=true;clearTimeout(timer);}});
|
|
77
|
+
return new Response(stream,{headers:{'content-type':'text/event-stream','cache-control':'no-store','x-accel-buffering':'no'}});
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function createWebhookDispatcher({queue,kv,subscriptions,secretFor,fetch:fetchImpl=globalThis.fetch,timeoutMs=10000}={}){
|
|
81
|
+
assert(queue&&kv&&Array.isArray(subscriptions)&&typeof secretFor==='function','Webhook configuration required');
|
|
82
|
+
const targets=new Map();for(const s of subscriptions){const u=new URL(s.url);assert(u.protocol==='https:'&&!u.username&&!u.password&&!u.hash,'Use explicitly configured HTTPS webhook destinations');targets.set(s.id,{...s,url:u.href});}
|
|
83
|
+
return {
|
|
84
|
+
async project(store){for(const s of targets.values()){const events=await store.events({actor:s.actor,after:(await kv.get('webhook-cursors',s.id))?.cursor||s.after||0,limit:1000});for(const event of events){await queue.enqueue({id:'webhook_'+hash(s.id+':'+event.cursor),kind:'webhook',payload:{subscription:s.id,event}});await kv.put('webhook-cursors',s.id,{cursor:event.cursor});}}},
|
|
85
|
+
async deliverOne(){const item=await queue.claim({kind:'webhook',leaseMs:timeoutMs+5000});if(!item)return null;try{
|
|
86
|
+
const target=targets.get(item.payload.subscription);assert(target,'Subscription removed');const body=JSON.stringify(item.payload.event),timestamp=String(Math.floor(Date.now()/1000));const secret=await secretFor(target.id);assert(typeof secret==='string'&&secret.length>=32,'Webhook signing secret must have at least 32 characters');
|
|
87
|
+
const signature=createHmac('sha256',secret).update(timestamp+'.'+body).digest('hex');const response=await fetchImpl(target.url,{method:'POST',body,headers:{'content-type':'application/json','a423-delivery-id':item.id,'a423-timestamp':timestamp,'a423-signature':'v1='+signature},redirect:'error',signal:AbortSignal.timeout(timeoutMs)});
|
|
88
|
+
await response.body?.cancel();if(!response.ok)throw Error('Webhook HTTP '+response.status);await queue.complete(item.id,item.lease,{status:response.status});return {id:item.id,status:'delivered'};
|
|
89
|
+
}catch(error){await queue.retry(item.id,item.lease,{delayMs:Math.min(3600000,1000*2**Math.min(item.attempts,12))});return {id:item.id,status:'retry',error:error.message};}}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
async function watchEvidenceOnce({store,revalidate}){const results=[];let after='';while(true){const jobs=await store.list({after,limit:100});if(!jobs.length)break;after=jobs.at(-1).id;for(const job of jobs){if(!job.evidence.length)continue;const checks=[];for(const item of job.evidence)if(item.evidence.transaction_hash){try{checks.push(await revalidate(job,item.evidence));}catch(error){checks.push({status:'unavailable',message:'Evidence provider unavailable'});}}
|
|
93
|
+
const blocked=checks.some(c=>c.status!=='valid');const safety={blocked,checks,checked_at:new Date().toISOString()};await store.setSafety(job.id,safety);results.push({id:job.id,...safety});}}return results;}
|
|
94
|
+
async function freshReceipts({workflow,id,actor,revalidate}){const job=await workflow.get(id,actor);const checks=[];for(const item of job.evidence)if(item.evidence.transaction_hash){const result=await revalidate(job,item.evidence);assert(result.status==='valid','Receipt evidence is not currently valid: '+result.status);checks.push(result);}return {...await workflow.receipts(id,actor),revalidated_at:new Date().toISOString(),checks};}
|
|
95
|
+
async function renderReceiptPdf(receipt){const {PDFDocument,StandardFonts}=require('pdf-lib');const pdf=await PDFDocument.create(),font=await pdf.embedFont(StandardFonts.Courier);let page,y;const newPage=()=>{page=pdf.addPage([595,842]);y=800;};newPage();const text=JSON.stringify(receipt,null,2).replace(/[^\x20-\x7e\n]/g,'?');for(const line of ['A423 Evidence Receipt',...text.split('\n')])for(let i=0;i<Math.max(1,line.length);i+=88){if(y<40)newPage();page.drawText(line.slice(i,i+88),{x:32,y,size:9,font});y-=12;}return pdf.save();}
|
|
96
|
+
function planLegacyMigration(records){return records.map(record=>({id:record.id,state:record.state,automatic:false,reason:['funded','running','submitted','verified','paid','settlement_pending','completed'].includes(String(record.state).toLowerCase())?'Existing custody/evidence must stay with original contract; do not relabel':'Requires reviewed participant and accepted-terms mapping; create a new job with provenance'}));}
|
|
97
|
+
module.exports={validateTask,validateEscrowEvidence,createSpendingAuthorizer,runMaintenanceOnce,createExpiryAdapter,runExecutionOnce,enqueueRunningJobs,createEventStream,createWebhookDispatcher,watchEvidenceOnce,freshReceipts,renderReceiptPdf,planLegacyMigration};
|
|
98
|
+
|
|
99
|
+
async function migrateUnfundedLegacy({records,workflow,map,actor}){
|
|
100
|
+
const created=[];for(const source of records){assert(['offered','draft','quoted','accepted'].includes(String(source.state).toLowerCase()),'Funded/terminal legacy records cannot be automatically migrated');const target=await map(structuredClone(source));assert(target.id&&target.id!==source.id,'Use a new explicit job ID');const job=await workflow.create({...target,metadata:{...target.metadata,migration:{source_id:source.id,source_state:source.state,source_hash:hash(JSON.stringify(source)),accepted_terms_carried:false}}},actor);created.push(job);}return created;
|
|
101
|
+
}
|
|
102
|
+
module.exports.migrateUnfundedLegacy=migrateUnfundedLegacy;
|
|
103
|
+
Object.assign(module.exports,require('./application.cjs'));
|
|
104
|
+
|
|
105
|
+
Object.assign(module.exports,require('./factory.cjs'));
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Workflow, WorkflowJob, Json } from '../workflow.js';
|
|
2
|
+
import type { SqliteJobStore, DurableQueue, KeyValueStore, SpendingStore } from '../storage/index.js';
|
|
3
|
+
export interface Task {
|
|
4
|
+
id: string; type: string; description: string;
|
|
5
|
+
deliverables: Array<{id: string;media_type: string;description: string}>;
|
|
6
|
+
acceptance: {validator: string;version: string;criteria: {[key: string]: Json}};
|
|
7
|
+
inputs?: Json;
|
|
8
|
+
}
|
|
9
|
+
export interface EvidenceCheck {status: 'valid' | 'invalid' | 'insufficient_confirmations' | 'unavailable';[key: string]: unknown}
|
|
10
|
+
export type Revalidator = (job: WorkflowJob,evidence: any) => Promise<EvidenceCheck>;
|
|
11
|
+
export interface SigningContext {job: WorkflowJob;operation: any;actor: string;step: string;to: string;data: string;phase: 'sign' | 'broadcast'}
|
|
12
|
+
export function validateTask(task: unknown): Task;
|
|
13
|
+
export function validateEscrowEvidence(evidence: unknown): {[key: string]: Json};
|
|
14
|
+
export function createSpendingAuthorizer(options: {spending: SpendingStore;limitsFor(context: SigningContext): Promise<{perTransaction: string;daily: string;total: string}>;authorize(context: SigningContext): Promise<boolean>}): (context: SigningContext) => Promise<boolean>;
|
|
15
|
+
export function runMaintenanceOnce(options: {workflow: Workflow;store: SqliteJobStore;actorFor(job: WorkflowJob): Promise<string | null>;allowExecute?: boolean;now?: number}): Promise<Array<{id: string;state?: string;pending?: boolean;error?: string}>>;
|
|
16
|
+
export function createExpiryAdapter(options?: {now?(): number}): {execute(context: {job: WorkflowJob}): Promise<any>;reconcile(context: {job: WorkflowJob}): Promise<any>};
|
|
17
|
+
export function enqueueRunningJobs(options: {store: SqliteJobStore;queue: DurableQueue}): Promise<number>;
|
|
18
|
+
export function runExecutionOnce(options: {workflow: Workflow;queue: DurableQueue;execute(context: {job: WorkflowJob;idempotencyKey: string}): Promise<{result_hash: string;[key: string]: Json}>;beforeExecute(job: WorkflowJob): Promise<boolean>;leaseMs?: number}): Promise<WorkflowJob | null>;
|
|
19
|
+
export function createEventStream(options: {store: SqliteJobStore;authenticate(request: Request): Promise<string | null>;pollMs?: number;maxDurationMs?: number}): (request: Request) => Promise<Response>;
|
|
20
|
+
export function createWebhookDispatcher(options: {queue: DurableQueue;kv: KeyValueStore;subscriptions: Array<{id: string;actor: string;url: string;after?: number}>;secretFor(id: string): Promise<string>;fetch?: typeof fetch;timeoutMs?: number}): {project(store: SqliteJobStore): Promise<void>;deliverOne(): Promise<any>};
|
|
21
|
+
export function watchEvidenceOnce(options: {store: SqliteJobStore;revalidate: Revalidator}): Promise<any[]>;
|
|
22
|
+
export function freshReceipts(options: {workflow: Workflow;id: string;actor: string;revalidate: Revalidator}): Promise<object>;
|
|
23
|
+
export function renderReceiptPdf(receipt: object): Promise<Uint8Array>;
|
|
24
|
+
export function planLegacyMigration(records: Array<{id: string;state: string}>): Array<{id: string;state: string;automatic: false;reason: string}>;
|
|
25
|
+
export function migrateUnfundedLegacy(options: {records: Array<{id: string;state: string}>;workflow: Workflow;actor: string;map(source: any): Promise<Parameters<Workflow['create']>[0]>}): Promise<WorkflowJob[]>;
|
|
26
|
+
export function createChatTools(options: {workflow: Workflow;store: SqliteJobStore;queue: DurableQueue;actor: string}): Record<string,(input: any) => Promise<WorkflowJob>>;
|
|
27
|
+
export function createApplicationHandler(options: {workflow: Workflow;store: SqliteJobStore;queue: DurableQueue;authenticate(request: Request): Promise<string | null>;revalidate: Revalidator;consentFor?(job: WorkflowJob): Promise<object>;legacyState?(actor: string): Promise<object>;authorityHandler?(request: Request): Promise<Response>;basePath?: string}): (request: Request) => Promise<Response>;
|
|
28
|
+
|
|
29
|
+
export function createEscrowApplication(options: {storage: import('../storage/index.js').SqliteStorage;authority: import('../authority/index.js').Authority;escrows: Array<any>;signerFor(actor: string): Promise<any>;addressFor(actor: string): Promise<string>;limitsFor(context: SigningContext): Promise<{perTransaction: string;daily: string;total: string}>;approveSigning(context: SigningContext): Promise<boolean>;validateArtifact(context: {job: WorkflowJob;resultHash: string}): Promise<boolean>;execute?(context: {job: WorkflowJob;idempotencyKey: string}): Promise<{result_hash: string;[key: string]: Json}>;keeperEnabled?: boolean;allowAuthorityRequest?(request: Request): Promise<boolean>;requireIndependentArbitrator?: boolean}): {workflow: Workflow;applicationOptions: Parameters<typeof createApplicationHandler>[0];workerOptions: any;handler: (request: Request) => Promise<Response>;chatTools(actor: string): ReturnType<typeof createChatTools>};
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import api from './index.cjs';
|
|
2
|
+
export const { validateTask, validateEscrowEvidence, createSpendingAuthorizer, runMaintenanceOnce, createExpiryAdapter, runExecutionOnce, enqueueRunningJobs, createEventStream, createWebhookDispatcher, watchEvidenceOnce, freshReceipts, renderReceiptPdf, planLegacyMigration, migrateUnfundedLegacy, createChatTools, createApplicationHandler, createEscrowApplication } = api;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
3
|
+
const { randomUUID } = require('node:crypto');
|
|
4
|
+
const { mkdirSync, chmodSync } = require('node:fs');
|
|
5
|
+
const { dirname } = require('node:path');
|
|
6
|
+
const json=JSON.stringify, parse=JSON.parse;
|
|
7
|
+
/** Single-host durable SQLite drivers. All writers must use this file on local disk. */
|
|
8
|
+
function createSqliteStorage({filename,busyTimeoutMs=5000}={}) {
|
|
9
|
+
if(typeof filename!=='string'||!filename||!Number.isSafeInteger(busyTimeoutMs)||busyTimeoutMs<0||busyTimeoutMs>60000)throw new TypeError('Supply filename and valid busy timeout');
|
|
10
|
+
if(filename!==':memory:')mkdirSync(dirname(filename),{recursive:true,mode:0o700});
|
|
11
|
+
const db=new DatabaseSync(filename);
|
|
12
|
+
if(filename!==':memory:')chmodSync(filename,0o600);
|
|
13
|
+
db.exec(`PRAGMA busy_timeout=${busyTimeoutMs}; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;
|
|
14
|
+
CREATE TABLE IF NOT EXISTS a423_jobs(id TEXT PRIMARY KEY,version INTEGER NOT NULL,payload TEXT NOT NULL);
|
|
15
|
+
CREATE TABLE IF NOT EXISTS a423_events(cursor INTEGER PRIMARY KEY AUTOINCREMENT,job_id TEXT NOT NULL,sequence INTEGER NOT NULL,payload TEXT NOT NULL,UNIQUE(job_id,sequence));
|
|
16
|
+
CREATE TABLE IF NOT EXISTS a423_transactions(id TEXT PRIMARY KEY,payload TEXT NOT NULL);
|
|
17
|
+
CREATE TABLE IF NOT EXISTS a423_nonces(scope TEXT PRIMARY KEY,next_nonce INTEGER NOT NULL);
|
|
18
|
+
CREATE TABLE IF NOT EXISTS a423_nonce_reservations(scope TEXT NOT NULL,operation_id TEXT NOT NULL,nonce INTEGER NOT NULL,PRIMARY KEY(scope,operation_id),UNIQUE(scope,nonce));
|
|
19
|
+
CREATE TABLE IF NOT EXISTS a423_kv(namespace TEXT NOT NULL,id TEXT NOT NULL,payload TEXT NOT NULL,PRIMARY KEY(namespace,id));
|
|
20
|
+
CREATE TABLE IF NOT EXISTS a423_queue(id TEXT PRIMARY KEY,kind TEXT NOT NULL,payload TEXT NOT NULL,status TEXT NOT NULL,available_at INTEGER NOT NULL,lease TEXT,lease_until INTEGER NOT NULL DEFAULT 0,attempts INTEGER NOT NULL DEFAULT 0,result TEXT);
|
|
21
|
+
CREATE INDEX IF NOT EXISTS a423_queue_ready ON a423_queue(status,available_at,lease_until);
|
|
22
|
+
CREATE TABLE IF NOT EXISTS a423_spend(id TEXT PRIMARY KEY,scope TEXT NOT NULL,amount TEXT NOT NULL,day TEXT NOT NULL,at INTEGER NOT NULL);
|
|
23
|
+
CREATE INDEX IF NOT EXISTS a423_spend_scope ON a423_spend(scope,day);`);
|
|
24
|
+
const atomic=fn=>{db.exec('BEGIN IMMEDIATE');try{const out=fn();db.exec('COMMIT');return out;}catch(e){db.exec('ROLLBACK');throw e;}};
|
|
25
|
+
const kv={
|
|
26
|
+
async get(namespace,id){const row=db.prepare('SELECT payload FROM a423_kv WHERE namespace=? AND id=?').get(namespace,id);return row?parse(row.payload):null;},
|
|
27
|
+
async put(namespace,id,value){db.prepare('INSERT INTO a423_kv VALUES(?,?,?) ON CONFLICT(namespace,id) DO UPDATE SET payload=excluded.payload').run(namespace,id,json(value));},
|
|
28
|
+
async create(namespace,id,value){return !!db.prepare('INSERT OR IGNORE INTO a423_kv VALUES(?,?,?)').run(namespace,id,json(value)).changes;},
|
|
29
|
+
async take(namespace,id){return atomic(()=>{const row=db.prepare('SELECT payload FROM a423_kv WHERE namespace=? AND id=?').get(namespace,id);db.prepare('DELETE FROM a423_kv WHERE namespace=? AND id=?').run(namespace,id);return row?parse(row.payload):null;});},
|
|
30
|
+
async list(namespace){return db.prepare('SELECT id,payload FROM a423_kv WHERE namespace=? ORDER BY id').all(namespace).map(r=>({id:r.id,value:parse(r.payload)}));}
|
|
31
|
+
};
|
|
32
|
+
const project=job=>{
|
|
33
|
+
for(const event of job.events)db.prepare('INSERT OR IGNORE INTO a423_events(job_id,sequence,payload) VALUES(?,?,?)').run(job.id,event.sequence,json({...event,job_id:job.id}));
|
|
34
|
+
};
|
|
35
|
+
const jobs={
|
|
36
|
+
async get(id){const r=db.prepare('SELECT payload FROM a423_jobs WHERE id=?').get(id);return r?parse(r.payload):null;},
|
|
37
|
+
async create(job){return atomic(()=>{const r=db.prepare('INSERT OR IGNORE INTO a423_jobs VALUES(?,?,?)').run(job.id,job.version,json(job));if(r.changes)project(job);return !!r.changes;});},
|
|
38
|
+
async compareAndSet(id,version,job){if(job.id!==id||job.version!==version+1)throw new Error('Invalid CAS record');return atomic(()=>{const r=db.prepare('UPDATE a423_jobs SET version=?,payload=? WHERE id=? AND version=?').run(job.version,json(job),id,version);if(r.changes)project(job);return !!r.changes;});},
|
|
39
|
+
async list({actor,limit=100,after=''}={}){if(!Number.isInteger(limit)||limit<1||limit>1000)throw new TypeError('Invalid limit');const rows=db.prepare('SELECT payload FROM a423_jobs WHERE id>? ORDER BY id').all(after).map(r=>parse(r.payload));return rows.filter(j=>!actor||Object.values(j.participants).includes(actor)).slice(0,limit);},
|
|
40
|
+
async events({actor,after=0,limit=100}={}){if(!actor||!Number.isSafeInteger(after)||after<0||!Number.isSafeInteger(limit)||limit<1||limit>1000)throw new TypeError('Invalid event query');return db.prepare('SELECT cursor,job_id,payload FROM a423_events WHERE cursor>? ORDER BY cursor').all(after).filter(r=>{const j=db.prepare('SELECT payload FROM a423_jobs WHERE id=?').get(r.job_id);return j&&Object.values(parse(j.payload).participants).includes(actor);}).slice(0,limit).map(r=>({cursor:r.cursor,...parse(r.payload)}));},
|
|
41
|
+
async getSafety(id){return kv.get('safety',id);},
|
|
42
|
+
async setSafety(id,value){return kv.put('safety',id,value);}
|
|
43
|
+
};
|
|
44
|
+
const transactions={
|
|
45
|
+
async get(id){const r=db.prepare('SELECT payload FROM a423_transactions WHERE id=?').get(id);return r?parse(r.payload):null;},
|
|
46
|
+
async create(id,record){return !!db.prepare('INSERT OR IGNORE INTO a423_transactions VALUES(?,?)').run(id,json(record)).changes;},
|
|
47
|
+
async list(){return db.prepare('SELECT id,payload FROM a423_transactions ORDER BY id').all().map(r=>({id:r.id,...parse(r.payload)}));},
|
|
48
|
+
async appendReplacement(id,expectedHash,replacement){return atomic(()=>{const row=db.prepare('SELECT payload FROM a423_transactions WHERE id=?').get(id);if(!row)throw Error('Unknown transaction');const record=parse(row.payload);if((record.replacements?.at(-1)?.hash||record.hash)!==expectedHash)throw Error('Replacement conflict');record.replacements=[...(record.replacements||[]),replacement];db.prepare('UPDATE a423_transactions SET payload=? WHERE id=?').run(json(record),id);});},
|
|
49
|
+
async reserveNonce(scope,minimum,operationId){if(!Number.isSafeInteger(minimum)||minimum<0)throw new TypeError('Invalid nonce');return atomic(()=>{const old=db.prepare('SELECT nonce FROM a423_nonce_reservations WHERE scope=? AND operation_id=?').get(scope,operationId);if(old)return old.nonce;const row=db.prepare('SELECT next_nonce FROM a423_nonces WHERE scope=?').get(scope);const nonce=Math.max(minimum,row?.next_nonce||0);db.prepare('INSERT INTO a423_nonces VALUES(?,?) ON CONFLICT(scope) DO UPDATE SET next_nonce=excluded.next_nonce').run(scope,nonce+1);db.prepare('INSERT INTO a423_nonce_reservations VALUES(?,?,?)').run(scope,operationId,nonce);return nonce;});}
|
|
50
|
+
};
|
|
51
|
+
const queue={
|
|
52
|
+
async enqueue({id,kind,payload,availableAt=Date.now()}){if(!id||!kind||!Number.isSafeInteger(availableAt))throw new TypeError('Invalid queue item');return !!db.prepare("INSERT OR IGNORE INTO a423_queue(id,kind,payload,status,available_at) VALUES(?,?,?,'ready',?)").run(id,kind,json(payload),availableAt).changes;},
|
|
53
|
+
async claim({kind,leaseMs=30000,now=Date.now()}={}){if(!Number.isSafeInteger(leaseMs)||leaseMs<1||!Number.isSafeInteger(now))throw new TypeError('Invalid lease');return atomic(()=>{const row=db.prepare("SELECT * FROM a423_queue WHERE kind=? AND ((status='ready' AND available_at<=?) OR (status='leased' AND lease_until<=?)) ORDER BY available_at,id LIMIT 1").get(kind,now,now);if(!row)return null;const lease=randomUUID();db.prepare("UPDATE a423_queue SET status='leased',lease=?,lease_until=?,attempts=attempts+1 WHERE id=?").run(lease,now+leaseMs,row.id);return {id:row.id,kind:row.kind,payload:parse(row.payload),lease,attempts:row.attempts+1,result:row.result?parse(row.result):null};});},
|
|
54
|
+
async heartbeat(id,lease,{leaseMs=30000,now=Date.now()}={}){return !!db.prepare("UPDATE a423_queue SET lease_until=? WHERE id=? AND lease=? AND status='leased' AND lease_until>?").run(now+leaseMs,id,lease,now).changes;},
|
|
55
|
+
async checkpoint(id,lease,result,{now=Date.now()}={}){return !!db.prepare("UPDATE a423_queue SET result=? WHERE id=? AND lease=? AND status='leased' AND lease_until>?").run(json(result),id,lease,now).changes;},
|
|
56
|
+
async complete(id,lease,result,{now=Date.now()}={}){return !!db.prepare("UPDATE a423_queue SET status='done',result=? WHERE id=? AND lease=? AND status='leased' AND lease_until>?").run(json(result),id,lease,now).changes;},
|
|
57
|
+
async retry(id,lease,{delayMs=1000,now=Date.now(),maxAttempts=10}={}){return !!db.prepare("UPDATE a423_queue SET status=CASE WHEN attempts>=? THEN 'failed' ELSE 'ready' END, available_at=?,lease=NULL,lease_until=0 WHERE id=? AND lease=? AND status='leased' AND lease_until>?").run(maxAttempts,now+delayMs,id,lease,now).changes;},
|
|
58
|
+
async get(id){const r=db.prepare('SELECT * FROM a423_queue WHERE id=?').get(id);return r?{id:r.id,kind:r.kind,payload:parse(r.payload),status:r.status,attempts:r.attempts,result:r.result?parse(r.result):null}:null;}
|
|
59
|
+
};
|
|
60
|
+
const spending={
|
|
61
|
+
async reserve({id,scope,amount,perTransaction,daily,total,now=Date.now()}){
|
|
62
|
+
const value=x=>{if(typeof x!=='string'||!/^(0|[1-9][0-9]*)$/.test(x))throw new TypeError('Use raw decimal amount strings');return BigInt(x);};
|
|
63
|
+
const n=value(amount),day=new Date(now).toISOString().slice(0,10);if(!id||!scope)throw new TypeError('Spend identity/scope required');
|
|
64
|
+
return atomic(()=>{const old=db.prepare('SELECT * FROM a423_spend WHERE id=?').get(id);if(old){if(old.scope!==scope||old.amount!==amount)throw Error('Spending idempotency conflict');}
|
|
65
|
+
const rows=db.prepare('SELECT amount,day FROM a423_spend WHERE scope=? AND id<>?').all(scope,id);const all=rows.reduce((a,r)=>a+BigInt(r.amount),0n),today=rows.filter(r=>r.day===day).reduce((a,r)=>a+BigInt(r.amount),0n);
|
|
66
|
+
if(perTransaction===undefined||daily===undefined||total===undefined)throw Error('Explicit per-transaction, daily and total caps required');
|
|
67
|
+
if(n>value(perTransaction)||n+today>value(daily)||n+all>value(total)){const error=Error('Spending limit exceeded');error.code='SPENDING_LIMIT_EXCEEDED';throw error;}
|
|
68
|
+
db.prepare('INSERT INTO a423_spend VALUES(?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET day=excluded.day,at=excluded.at').run(id,scope,amount,day,now);return true;});
|
|
69
|
+
},
|
|
70
|
+
// Conservative reservations persist after approvals and uncertain outcomes. Release only after external reconciliation.
|
|
71
|
+
async list(scope){return db.prepare('SELECT * FROM a423_spend WHERE scope=? ORDER BY at,id').all(scope);},
|
|
72
|
+
async release(id){db.prepare('DELETE FROM a423_spend WHERE id=?').run(id);}
|
|
73
|
+
};
|
|
74
|
+
return {jobs,transactions,queue,spending,kv,
|
|
75
|
+
async resetDevelopmentData({chainId,confirm,assertChainReset,includeAuthority=false,dryRun=true}={}){
|
|
76
|
+
if(![1337,31337].includes(chainId)||confirm!=='RESET_LOCAL_A423'||typeof assertChainReset!=='function'||await assertChainReset()!==true)throw Error('Reset requires confirmed local chain reset');
|
|
77
|
+
const tables=['a423_jobs','a423_events','a423_transactions','a423_nonces','a423_nonce_reservations','a423_queue','a423_spend'];
|
|
78
|
+
const counts=Object.fromEntries(tables.map(name=>[name,db.prepare('SELECT COUNT(*) AS n FROM '+name).get().n]));
|
|
79
|
+
if(!dryRun)atomic(()=>{for(const name of tables)db.exec('DELETE FROM '+name);if(includeAuthority)db.exec('DELETE FROM a423_kv');else db.exec("DELETE FROM a423_kv WHERE namespace NOT LIKE 'authority:%'");});
|
|
80
|
+
return {dryRun,counts,includeAuthority};
|
|
81
|
+
},close(){db.close();}};
|
|
82
|
+
}
|
|
83
|
+
module.exports={createSqliteStorage};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { WorkflowStore, WorkflowJob, Json } from '../workflow.js';
|
|
2
|
+
export interface TransactionRecord {hash: string;raw: string;to: string;data: string;from: string;created_at?: string;replacements?: TransactionRecord[]}
|
|
3
|
+
export interface TransactionStore {get(id: string): Promise<TransactionRecord | null>;create(id: string,record: TransactionRecord): Promise<boolean>;reserveNonce(scope: string,minimum: number,operationId: string): Promise<number>;list(): Promise<Array<TransactionRecord & {id: string}>>;appendReplacement(id: string,expectedHash: string,replacement: TransactionRecord): Promise<void>}
|
|
4
|
+
export interface Safety { blocked: boolean; checks: Array<{status: string; [key: string]: unknown}>; checked_at: string }
|
|
5
|
+
export interface SqliteJobStore extends WorkflowStore {
|
|
6
|
+
list(options?: {actor?: string; after?: string; limit?: number}): Promise<WorkflowJob[]>;
|
|
7
|
+
events(options: {actor: string; after?: number; limit?: number}): Promise<Array<{cursor: number; job_id: string; sequence: number; type: string; actor_id: string; at: string; data: Json}>>;
|
|
8
|
+
getSafety(id: string): Promise<Safety | null>;
|
|
9
|
+
setSafety(id: string, value: Safety): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export interface KeyValueStore {
|
|
12
|
+
get(namespace: string,id: string): Promise<any>;
|
|
13
|
+
put(namespace: string,id: string,value: unknown): Promise<void>;
|
|
14
|
+
create(namespace: string,id: string,value: unknown): Promise<boolean>;
|
|
15
|
+
take(namespace: string,id: string): Promise<any>;
|
|
16
|
+
list(namespace: string): Promise<Array<{id: string; value: any}>>;
|
|
17
|
+
}
|
|
18
|
+
export interface QueueItem {id: string; kind: string; payload: any; lease: string; attempts: number; result: any}
|
|
19
|
+
export interface DurableQueue {
|
|
20
|
+
enqueue(input: {id: string; kind: string; payload: unknown; availableAt?: number}): Promise<boolean>;
|
|
21
|
+
claim(options: {kind: string; leaseMs?: number; now?: number}): Promise<QueueItem | null>;
|
|
22
|
+
heartbeat(id: string, lease: string, options?: {leaseMs?: number; now?: number}): Promise<boolean>;
|
|
23
|
+
checkpoint(id: string, lease: string, result: unknown, options?: {now?: number}): Promise<boolean>;
|
|
24
|
+
complete(id: string, lease: string, result: unknown, options?: {now?: number}): Promise<boolean>;
|
|
25
|
+
retry(id: string, lease: string, options?: {delayMs?: number; now?: number; maxAttempts?: number}): Promise<boolean>;
|
|
26
|
+
get(id: string): Promise<{id: string; kind: string; payload: unknown; status: string; attempts: number; result: unknown} | null>;
|
|
27
|
+
}
|
|
28
|
+
export interface SpendingStore {
|
|
29
|
+
reserve(input: {id: string;scope: string;amount: string;perTransaction: string;daily: string;total: string;now?: number}): Promise<boolean>;
|
|
30
|
+
release(id: string): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
export interface SqliteStorage {
|
|
33
|
+
jobs: SqliteJobStore; transactions: TransactionStore; kv: KeyValueStore; queue: DurableQueue; spending: SpendingStore;
|
|
34
|
+
resetDevelopmentData(options: {chainId: number;confirm: 'RESET_LOCAL_A423';assertChainReset(): Promise<boolean>;includeAuthority?: boolean;dryRun?: boolean}): Promise<{dryRun: boolean;counts: Record<string,number>;includeAuthority: boolean}>;
|
|
35
|
+
close(): void;
|
|
36
|
+
}
|
|
37
|
+
export function createSqliteStorage(options: {filename: string;busyTimeoutMs?: number}): SqliteStorage;
|
package/src/workflow.cjs
CHANGED
|
@@ -31,7 +31,7 @@ const RULES = {
|
|
|
31
31
|
};
|
|
32
32
|
/** Pure canonical JSON SHA-256. This is an integrity commitment, not authentication. */
|
|
33
33
|
function hashAgreement(terms) { return digest(terms); }
|
|
34
|
-
function createWorkflow({ store, adapters = {}, authorize, validateTerms, transitions = {} } = {}) {
|
|
34
|
+
function createWorkflow({ store, adapters = {}, authorize, validateTerms, validateEvidence, transitions = {} } = {}) {
|
|
35
35
|
if (!store || ['get', 'create', 'compareAndSet'].some(k => typeof store[k] !== 'function')) throw new TypeError('Supply an atomic workflow store');
|
|
36
36
|
const rules = copy(RULES);
|
|
37
37
|
for (const [action, rule] of Object.entries(transitions)) {
|
|
@@ -70,6 +70,7 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, transi
|
|
|
70
70
|
return save(job, next, pending.action + '.rejected', pending.actor, result);
|
|
71
71
|
}
|
|
72
72
|
if (!result.evidence || typeof result.evidence !== 'object' || Array.isArray(result.evidence)) error('INVALID_ADAPTER_RESULT', 'Confirmed effects require structured evidence', 502);
|
|
73
|
+
if(validateEvidence)await validateEvidence({job:copy(job),action:pending.action,evidence:copy(result.evidence)});
|
|
73
74
|
const next = copy(job); next.pending = null; next.state = pending.target;
|
|
74
75
|
next.operations[pending.key] = { request_hash: pending.request_hash, status: 'confirmed', result: copy(result) };
|
|
75
76
|
next.evidence.push({ action: pending.action, operation_id: pending.id, evidence: copy(result.evidence) });
|
|
@@ -99,10 +100,13 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, transi
|
|
|
99
100
|
return copy(job);
|
|
100
101
|
},
|
|
101
102
|
get,
|
|
103
|
+
async assertSafe(id,actor){const job=await get(id,actor);const safety=store.getSafety?await store.getSafety(id):null;if(safety?.blocked)error('SAFETY_HOLD','Evidence requires reconciliation',409);return job;},
|
|
102
104
|
async act(id, action, input, { actor, idempotencyKey } = {}) {
|
|
103
105
|
identifier(actor); identifier(idempotencyKey); identifier(action); canonical(input);
|
|
104
106
|
let job = await get(id, actor);
|
|
105
|
-
const
|
|
107
|
+
const safety=store.getSafety ? await store.getSafety(id) : null;
|
|
108
|
+
if(safety?.blocked) error('SAFETY_HOLD','Evidence requires reconciliation before further actions',409);
|
|
109
|
+
const rule = Object.hasOwn(rules,action) ? rules[action] : null;
|
|
106
110
|
if (!rule) error('UNKNOWN_ACTION', 'Unknown workflow action', 400);
|
|
107
111
|
await allowed(job, action, actor, input, rule);
|
|
108
112
|
const key = digest([actor, idempotencyKey]);
|
|
@@ -136,6 +140,14 @@ function createWorkflow({ store, adapters = {}, authorize, validateTerms, transi
|
|
|
136
140
|
next.operations[key] = { request_hash: requestHash, status: 'confirmed', result: { status: 'confirmed' } };
|
|
137
141
|
return save(job, next, action, actor, input);
|
|
138
142
|
},
|
|
143
|
+
async resume(id, actor) {
|
|
144
|
+
const job=await get(id,actor);
|
|
145
|
+
if(!job.pending)return job;
|
|
146
|
+
const safety=store.getSafety ? await store.getSafety(id) : null;
|
|
147
|
+
if(safety?.blocked)error('SAFETY_HOLD','Evidence requires reconciliation',409);
|
|
148
|
+
await allowed(job,job.pending.action,actor,job.pending.input,rules[job.pending.action]);
|
|
149
|
+
return execute(job);
|
|
150
|
+
},
|
|
139
151
|
async reconcile(id, actor) {
|
|
140
152
|
const job = await get(id, actor);
|
|
141
153
|
if (!job.pending) return job;
|
package/src/workflow.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ export interface WorkflowOptions {
|
|
|
26
26
|
/** Return true to allow; authenticated actor ID must be supplied by your application. */
|
|
27
27
|
authorize?(context: { job: WorkflowJob; action: string; actor: string; input: Json }): boolean | Promise<boolean>;
|
|
28
28
|
/** Throw to reject application-specific terms. */
|
|
29
|
+
validateEvidence?(context: {job: WorkflowJob;action: string;evidence: {[key: string]: Json}}): void | Promise<void>;
|
|
29
30
|
validateTerms?(terms: { [key: string]: Json }): void | Promise<void>;
|
|
30
31
|
/** Explicit overrides replace the full rule; application is responsible for its invariants. */
|
|
31
32
|
transitions?: Record<string, Transition>;
|
|
@@ -33,7 +34,9 @@ export interface WorkflowOptions {
|
|
|
33
34
|
export interface Workflow {
|
|
34
35
|
create(input: { id?: string; participants: WorkflowJob['participants']; metadata?: Json }, actor: string): Promise<WorkflowJob>;
|
|
35
36
|
get(id: string, actor: string): Promise<WorkflowJob>;
|
|
37
|
+
assertSafe(id: string, actor: string): Promise<WorkflowJob>;
|
|
36
38
|
act(id: string, action: string, input: Json, options: { actor: string; idempotencyKey: string }): Promise<WorkflowJob>;
|
|
39
|
+
resume(id: string, actor: string): Promise<WorkflowJob>;
|
|
37
40
|
reconcile(id: string, actor: string): Promise<WorkflowJob>;
|
|
38
41
|
receipts(id: string, actor: string): Promise<Pick<WorkflowJob, 'protocol' | 'state' | 'agreement' | 'submission' | 'evidence' | 'events' | 'pending'> & { job_id: string }>;
|
|
39
42
|
}
|