@allorc/a423-sdk 0.0.0-stage → 0.2.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/INTEGRATION.md +63 -0
- package/LICENSE +21 -0
- package/README.md +348 -2
- package/SECURITY.md +9 -0
- package/examples/client.mjs +19 -0
- package/examples/nextjs/app/api/a423/[...path]/route.ts +5 -0
- package/examples/nextjs/lib/a423.ts +31 -0
- package/package.json +61 -4
- package/src/index.cjs +242 -0
- package/src/index.d.ts +67 -0
- package/src/index.mjs +2 -0
- package/src/workflow.cjs +215 -0
- package/src/workflow.d.ts +57 -0
- package/src/workflow.mjs +2 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
- Add opt-in `@allorc/a423-sdk/workflow` exports: workflow engine, Next.js-compatible handler, typed HTTP client and canonical agreement hashing.
|
|
6
|
+
- Add versioned quotes, bilateral acceptance, role checks, application policies and configurable transitions.
|
|
7
|
+
- Cover funding, execution gate, submission, verification, release, refund, cancellation, expiry, disputes and resolution through explicit adapters.
|
|
8
|
+
- Persist pending operations before effects, stable provider idempotency keys, reconciliation observations and structured evidence.
|
|
9
|
+
- Preserve the 0.1 root API unchanged. Its approval/settlement behaviour is distinct from the new workflow.
|
|
10
|
+
- Add migration instructions and lifecycle/recovery/HTTP tests. No chain adapter, database driver, signer, queue or PDF renderer is bundled.
|
package/INTEGRATION.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Integration instructions for the MVP coding agent
|
|
2
|
+
|
|
3
|
+
Install after the maintainer publishes:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install @allorc/a423-sdk@0.2.0
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
For testing before publication, install the maintainer's `npm pack` tarball instead. Do not silently install 0.1 and assume the workflow entry point exists.
|
|
10
|
+
|
|
11
|
+
## Task
|
|
12
|
+
|
|
13
|
+
Integrate the opt-in `@allorc/a423-sdk/workflow` API into the existing MVP. Inspect the installed README, declarations and exports first. Preserve the existing app until a complete equivalent journey passes. Do not introduce demo keys, positional signer lookup, unknown-agent fallback, or authentication based only on x-agent-id. Do not deploy or move real funds as part of integration tests.
|
|
14
|
+
|
|
15
|
+
1. Implement a durable `WorkflowStore` in the app's database using atomic create and version-checked replacement. Store the whole workflow record together. Keep legacy data separate; explicitly migrate records only after reviewing the mapping. Use stable application-assigned job IDs; recover duplicate creates by loading and comparing the record.
|
|
16
|
+
2. Implement authentication and bind authenticated agent IDs to the application's wallet/signer registry. Keep `@allorc/identity` as public identity derivation only. Configure participants as requester, worker and verifier. Reject unknown agents before signing anything.
|
|
17
|
+
3. Define the application's JSON agreement terms and `validateTerms`: task specification, price, raw amount string, token/chain/contract, participant wallets, deadline, artifact requirements and verification/refund/dispute policy. Preserve decimal metadata. Use the returned SHA-256 agreement hash and revision for bilateral acceptance. Do not substitute this for the existing contract's keccak spec hash: explicitly map and verify both formats where needed.
|
|
18
|
+
4. Implement `fund.execute/reconcile`. Use `operation.id` for persistent deduplication; save provider transaction identifiers before waiting. Validate successful funding event plus actual escrow fields against the agreed terms and trusted chain/contract configuration. Return pending while unconfirmed, confirmed with structured observations only when policy passes, and rejected only on definitive failure. Confirm how the actual ABI exposes specHash; do not skip the comparison silently.
|
|
19
|
+
5. Implement `start` as a fresh eligibility gate. Fail closed if escrow configuration, evidence or chain access is missing. Match funding and assigned worker before scheduling execution. Dispatch actual work through the app's durable queue using a deduplicated run key. The SDK itself does not execute an AI agent.
|
|
20
|
+
6. Implement one submission path: store the actual artifact bytes, hash them, persist the manifest/reference, submit the required on-chain commitment, and wait/reconcile before confirming. If durable execution starts asynchronously, it must not report submitted just because the queue accepted it.
|
|
21
|
+
7. Implement `verify`: validate the exact committed artifact against accepted criteria. Only return confirmed after checks pass. A definitive failure returns rejected, then a separately authorized dispute action records the dispute. Record check results and validator version. If revisions are needed, add explicit transitions and define how they interact with already submitted commitments.
|
|
22
|
+
8. Implement `release.execute/reconcile`, checking caller role, recorded verification and actual payment evidence. Verify exact amount/recipient and relevant event. The SDK state guard does not change contract rules; document whether the contract itself requires submission. Never infer successful payment from a broadcast hash alone.
|
|
23
|
+
9. Implement `refund.execute/reconcile` based on actual contract eligibility. The default rule allows requesting a refund while funded/running; the adapter must enforce the deadline and absence of submission. Submitted disputes require an explicit supported resolution policy. Implement `resolve` only when that policy exists; confirmed resolution evidence must state whether money was released, refunded or remains locked. `resolved` alone does not imply payment.
|
|
24
|
+
10. Add application deadline/expiry checks to authorization and the `expire` adapter. Schedule sweeps with authorized roles. Cancellation only applies before funding in the default workflow. Do not claim automatic sweeps or a dispute timeout exist if they are not implemented.
|
|
25
|
+
11. Configure each adapter's `reconcile` to inspect the provider using durable operation records. Unknown outcomes remain pending. Poll/reconcile in a durable worker. Handle configured confirmations, disappearing observations and reorgs according to the app's policy; the SDK does not include a chain watcher. Add explicit corrective transitions/policies if required rather than editing records outside CAS.
|
|
26
|
+
12. Expose `createWorkflowHandler` through a Node-runtime Next.js catch-all route. Use `createWorkflowClient` or documented HTTP endpoints externally. The old root API and new workflow API use distinct models; migrate the client deliberately.
|
|
27
|
+
13. Build machine-readable receipts through `workflow.receipts`. Revalidate chain evidence before generating chain-backed PDFs; derive amounts/participants from validated observations and use actual token decimals. Distinguish cached observations from freshly checked evidence.
|
|
28
|
+
|
|
29
|
+
## Adapter shape
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
const adapters = {
|
|
33
|
+
fund: {
|
|
34
|
+
async execute({ job, input, operation }) {
|
|
35
|
+
// Deduplicate operation.id durably, enforce agreed terms and submit/observe.
|
|
36
|
+
return { status: 'pending', reference: 'your-provider-operation-id' };
|
|
37
|
+
},
|
|
38
|
+
async reconcile({ job, operation }) {
|
|
39
|
+
// Query your saved provider operation. Do not blindly resend.
|
|
40
|
+
return { status: 'confirmed', evidence: { /* independently validated fields */ } };
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
// start, submit, verify, release, refund, expire, resolve as applicable.
|
|
44
|
+
};
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
This is a structural example, not a working escrow adapter. Never return confirmed unconditionally in an actual integration.
|
|
48
|
+
|
|
49
|
+
## Required validation before replacing MVP routes
|
|
50
|
+
|
|
51
|
+
- Happy path: create → quote → both accept same revision/hash → fund confirmed → start gate → submit confirmed → verify → release confirmed → JSON/PDF evidence.
|
|
52
|
+
- Quotes: revised terms clear acceptances; stale revision rejected; funding prevents edits; deadline policy rejects expired quotes.
|
|
53
|
+
- Permissions: anonymous/unknown/unrelated actors blocked; each role enforced; identity hash alone cannot authenticate.
|
|
54
|
+
- Funding: wrong amount, token, chain, contract, payer, worker, verifier, deadline or spec commitment blocks work; missing escrow configuration fails closed.
|
|
55
|
+
- Effects: duplicate requests and simultaneous retries produce one provider operation; differing input with reused key rejected; crashes before/after broadcast and before final DB save recover by reconciliation.
|
|
56
|
+
- Submission: asynchronous execution cannot prematurely submit; bytes match committed digest; missing/failed chain submission stays pending/rejected.
|
|
57
|
+
- Verification: bad output never enables release; dispute recorded; unsupported resolution leaves explicit unresolved state.
|
|
58
|
+
- Refund: before deadline rejected; after deadline/unsubmitted succeeds; submitted work obeys contract restrictions.
|
|
59
|
+
- Exceptions: pre-funding cancellation, unfunded expiry, definitive provider rejection and long pending operations.
|
|
60
|
+
- Receipts: correct raw units/decimals, matching funding/payment/refund events and artifact linkage; provider outage is distinguished from absent evidence.
|
|
61
|
+
- Custom use case: prove a second task type or non-chain provider works without modifying SDK internals.
|
|
62
|
+
|
|
63
|
+
Report changed files, adapter capabilities, test results and unsupported contract guarantees. Keep real-chain tests opt-in. Do not claim every possible workflow or payment provider is implemented merely because adapter hooks exist.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Allorc contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,349 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Configurable workflows in 0.2.0
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The new `@allorc/a423-sdk/workflow` entry point provides middleware for agent job lifecycles. It supports different task types and payment systems through application-supplied policies and adapters. It does not assume a particular blockchain, token, wallet, artifact format, database or AI framework. This is experimental software with a reference lifecycle; it does not promise every possible business workflow.
|
|
4
|
+
|
|
5
|
+
The original root exports remain compatible. Choose the workflow entry point for the full lifecycle; do not mix its records with the original root API. See [integration and migration instructions](./INTEGRATION.md).
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { createWorkflow, createWorkflowHandler } from '@allorc/a423-sdk/workflow';
|
|
9
|
+
|
|
10
|
+
const workflow = createWorkflow({
|
|
11
|
+
store: durableWorkflowStore,
|
|
12
|
+
adapters: applicationAdapters,
|
|
13
|
+
validateTerms: validateYourTerms,
|
|
14
|
+
authorize: checkYourPolicy,
|
|
15
|
+
});
|
|
16
|
+
const handler = createWorkflowHandler({ workflow, authenticate: authenticateRequest });
|
|
17
|
+
// app/api/a423/[...path]/route.ts
|
|
18
|
+
export const runtime = 'nodejs';
|
|
19
|
+
export { handler as GET, handler as POST };
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The names `durableWorkflowStore`, `applicationAdapters`, `validateYourTerms`, `checkYourPolicy` and `authenticateRequest` are application implementations, not SDK exports. Configure them once in a server module and import the handler into your route.
|
|
23
|
+
|
|
24
|
+
## Workflow coverage
|
|
25
|
+
|
|
26
|
+
| Action | From | Default role | Result | Required adapter |
|
|
27
|
+
|---|---|---|---|---|
|
|
28
|
+
| create | — | requester | draft | none |
|
|
29
|
+
| quote | draft / quoted / accepted | requester or worker | quoted, acceptances reset | none |
|
|
30
|
+
| accept | quoted | requester and worker separately | accepted after both | none |
|
|
31
|
+
| fund | accepted | requester | funded | fund |
|
|
32
|
+
| start | funded | worker | running | start (funding/work gate) |
|
|
33
|
+
| submit | running | worker | submitted | submit |
|
|
34
|
+
| verify | submitted | verifier | verified | verify |
|
|
35
|
+
| release | verified | verifier | paid | release |
|
|
36
|
+
| refund | funded / running | requester | refunded | refund |
|
|
37
|
+
| cancel | draft / quoted / accepted | requester | cancelled | none |
|
|
38
|
+
| expire | draft / quoted / accepted | requester or worker | expired | expire |
|
|
39
|
+
| dispute | submitted / verified | any of the three parties | disputed | none |
|
|
40
|
+
| resolve | disputed | verifier | resolved | resolve |
|
|
41
|
+
|
|
42
|
+
`resolved` records a completed resolution process; it does not mean paid or refunded. The resolution adapter must record its actual outcome in evidence. Refund eligibility, quote expiry, deadlines, spending limits, asset rules and verification semantics belong in application policies/adapters. There is no automatic timer. A scheduler calls the API using an authenticated participant or an explicitly configured scheduler role.
|
|
43
|
+
|
|
44
|
+
### Terms and acceptance
|
|
45
|
+
|
|
46
|
+
Use JSON terms for your domain: task specification, price, asset, raw amount, deadline, wallet addresses, verifier policy and artifact expectations. Put every term you want committed into this object, including parties and settlement configuration where relevant. The SDK does not invent or validate those domain fields; `validateTerms` should throw on invalid terms. Use decimal strings for raw token amounts, not floating point or JSON bigint.
|
|
47
|
+
|
|
48
|
+
The SDK hashes sorted-key canonical JSON with SHA-256 (array order preserved, no whitespace). Hash inputs must be plain JSON; avoid cyclic structures and sparse arrays. This is the SDK's own format, not a claim of RFC 8785 compatibility. Names and IDs are case-sensitive. A quote stores revision, terms, hash and acceptances. Accept requires both `agreement_hash` and `revision`; editing a quote clears acceptance. Terms cannot be revised after funding begins under the default rules. Hashes establish integrity, not caller identity or legal consent.
|
|
49
|
+
|
|
50
|
+
### Adapter contract and recovery
|
|
51
|
+
|
|
52
|
+
Each effect adapter is either an async function or `{ execute, reconcile }`. It receives `{ job, input, operation }` and returns:
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
{ status: 'pending', reference: 'provider-operation-reference' }
|
|
56
|
+
{ status: 'confirmed', evidence: { /* domain-specific verified observations */ } }
|
|
57
|
+
{ status: 'rejected', reason: 'definitive rejection' }
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Confirmed results require an evidence object. The SDK trusts the adapter's verdict; it cannot independently establish payment, artifact quality or chain finality. An arbitrary reference is not payment proof. Chain adapters should compare configured chain/contract, job ID, amount, token, participants, deadline and commitments against the accepted terms, plus relevant successful transaction events and the chosen finality policy. `start` must recheck eligibility before work is dispatched.
|
|
61
|
+
|
|
62
|
+
The SDK atomically stores a pending operation before invoking an effect. Its `operation.id` is stable across retries: use it as the provider idempotency key. **Adapters must durably deduplicate effects, including concurrent calls.** Compare-and-set does not make an external payment exactly-once. If execution throws or returns an invalid result, pending remains; inspect/reconcile it. A pending result retains its observation. A rejected result clears pending without advancing state; repeat the same action key returns the stored job, so use a new key only after a definitive rejection and a deliberate new attempt.
|
|
63
|
+
|
|
64
|
+
`reconcile(id, actor)` calls only the pending adapter's `reconcile`, never its execute method. That implementation should inspect known provider operations, not blindly repeat a transaction. Supply reconciliation for every external effect. Reuse the same adapter configuration and transitions across restarts. Reconciliation can record additional pending observations. No other action runs while an operation remains pending. If a final database save fails, recovery relies on the same persisted operation and provider key.
|
|
65
|
+
|
|
66
|
+
The SDK does not run agents or background queues. Persist intent and schedule work in adapters; use a durable worker to submit actual output. Do not mark work submitted merely because a worker was queued. Custom transitions can add milestones, rejection/revision loops, manual review, nonpayment completion, or alternative arbitration. Override whole rules with `{ from, roles, to, effect }`; applications assume responsibility for the resulting invariants. Effect adapters must be trusted server code.
|
|
67
|
+
|
|
68
|
+
### Storage, permissions and receipts
|
|
69
|
+
|
|
70
|
+
A workflow store implements `get`, atomic insert-if-absent `create`, and atomic version-checked `compareAndSet`. Persist the entire record (state, pending operation, evidence, events, idempotency history) atomically. Use a separate table/namespace from legacy records. The root `createMemoryStore()` also works structurally at runtime for development, but is not durable or suitable for distributed deployment; a typed workflow store should implement the interface from the workflow entry point.
|
|
71
|
+
|
|
72
|
+
The handler requires authentication. It obtains actor identity from your callback, never from action bodies. Default role checks always run; `authorize` adds application policy and must return exactly true. Creation requires requester authentication, and requester must differ from worker. The verifier may be the requester or a separate participant. Authentication, wallet/key custody, identity-to-wallet binding and proof of ownership remain application responsibilities. `@allorc/identity` only derives public identifiers.
|
|
73
|
+
|
|
74
|
+
Receipts are JSON snapshots of agreement, submission, stored adapter evidence, events and pending state. They are not freshly queried chain proofs or signed attestations. Render PDFs separately, and revalidate provider evidence if fresh verification is required. Protect credentials and never put secrets in terms, evidence, metadata or events: all participants can read these fields. Records/events grow over time; production storage should enforce application quotas. The HTTP client limits responses to 1 MiB.
|
|
75
|
+
|
|
76
|
+
### HTTP and client
|
|
77
|
+
|
|
78
|
+
| Method | Path beneath `/api/a423` | Body |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| POST | `/jobs` | `{ id, participants, metadata? }` |
|
|
81
|
+
| GET | `/jobs/:id` | — |
|
|
82
|
+
| POST | `/jobs/:id/actions` | `{ action, input }` plus `Idempotency-Key` |
|
|
83
|
+
| POST | `/jobs/:id/reconcile` | `{}` |
|
|
84
|
+
| GET | `/jobs/:id/receipts` | — |
|
|
85
|
+
|
|
86
|
+
The handler uses standard Web Request/Response, bounded streaming JSON (64 KiB default), explicit errors, and no background HTTP work. IDs and idempotency keys use 1–128 ASCII letters/digits/underscores/hyphens. Create with a stable application-assigned ID; duplicate creation returns conflict, then retrieve and compare the existing record. Every action needs an actor-scoped key; reusing it with different input fails. Transient conflicts should reload and retry using the same key. Authentication callbacks must inspect headers/cookies or a cloned request if they need the body.
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import { createWorkflowClient } from '@allorc/a423-sdk/workflow';
|
|
90
|
+
const client = createWorkflowClient({
|
|
91
|
+
baseUrl: 'https://your-app.example/api/a423',
|
|
92
|
+
headers: () => ({ Authorization: `Bearer ${serverCredential}` }),
|
|
93
|
+
});
|
|
94
|
+
const quote = await client.act(jobId, 'quote', { terms }, { idempotencyKey: 'quote_1' });
|
|
95
|
+
await client.act(jobId, 'accept', {
|
|
96
|
+
agreement_hash: quote.agreement!.hash,
|
|
97
|
+
revision: quote.agreement!.revision,
|
|
98
|
+
}, { idempotencyKey: 'accept_1' });
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Each participant needs its own credentials. The client does not automatically retry. Default timeout is 30 seconds; adapters should return pending quickly for long operations. Use TLS and appropriate CSRF protection for cookie-authenticated mutations.
|
|
102
|
+
|
|
103
|
+
## Original 0.1 API documentation
|
|
104
|
+
|
|
105
|
+
# @allorc/a423-sdk
|
|
106
|
+
|
|
107
|
+
One experimental package for exposing an agent job API from a Next.js App Router application. Configure a handler, export it from a catch-all route, and use the typed client to offer, accept, submit and approve work.
|
|
108
|
+
|
|
109
|
+
**Status:** local experimental v0.1.0. The npm package has not been published by this checkout. Source repository: https://github.com/Allorc-official/A423-SDK. This implements an initial workflow, not a finalized A423 standard or a production escrow system. Node.js 22+. ESM, CommonJS and TypeScript declarations; zero runtime dependencies.
|
|
110
|
+
|
|
111
|
+
## Quick setup in Next.js
|
|
112
|
+
|
|
113
|
+
Until publication, install the local package into your existing Next.js application:
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
npm install /absolute/path/to/servers/server/a423_sdk
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
After publication: `npm install @allorc/a423-sdk`.
|
|
120
|
+
|
|
121
|
+
Create `lib/a423.ts`:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import 'server-only';
|
|
125
|
+
import { createA423Handler, createMemoryStore } from '@allorc/a423-sdk';
|
|
126
|
+
|
|
127
|
+
// Development only. Replace with a durable StateStore before deployment.
|
|
128
|
+
const store = createMemoryStore();
|
|
129
|
+
const token = process.env.A423_API_TOKEN;
|
|
130
|
+
const actorId = process.env.A423_AGENT_ID;
|
|
131
|
+
if (!token || !actorId) throw new Error('Configure A423_API_TOKEN and A423_AGENT_ID');
|
|
132
|
+
|
|
133
|
+
export const a423 = createA423Handler({
|
|
134
|
+
store,
|
|
135
|
+
authenticate(request) {
|
|
136
|
+
return request.headers.get('authorization') === `Bearer ${token}`
|
|
137
|
+
? actorId
|
|
138
|
+
: null;
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
This minimal authentication example maps one secret credential to one public actor ID. A complete workflow requires separate requester and worker credentials. The two-actor example is in `examples/nextjs/lib/a423.ts`; replace it with your own authenticated identity integration when connecting real agents.
|
|
144
|
+
|
|
145
|
+
Create `app/api/a423/[...path]/route.ts`:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
import { a423 } from '@/lib/a423';
|
|
149
|
+
|
|
150
|
+
export const runtime = 'nodejs';
|
|
151
|
+
export const GET = a423;
|
|
152
|
+
export const POST = a423;
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The `@/` import assumes your application's usual root alias; adjust it if your app uses `src/` or relative imports. This is one catch-all route, not one file per action. Do not run it in the Edge runtime. The SDK does not start a listener or publish routes on its own: Next.js exposes the route you configure.
|
|
156
|
+
|
|
157
|
+
Keep credentials in server environment variables without `NEXT_PUBLIC_`. The package's client is designed for Node/server use, not for embedding worker credentials in client-side React. Your existing Next.js application supplies Next.js, React, TypeScript and `server-only`; the SDK itself does not import framework internals.
|
|
158
|
+
|
|
159
|
+
## Use the typed client
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
import { createA423Client } from '@allorc/a423-sdk';
|
|
163
|
+
|
|
164
|
+
const requester = createA423Client({
|
|
165
|
+
baseUrl: 'https://your-app.example/api/a423',
|
|
166
|
+
headers: { authorization: `Bearer ${requesterToken}` }
|
|
167
|
+
});
|
|
168
|
+
const worker = createA423Client({
|
|
169
|
+
baseUrl: 'https://your-app.example/api/a423',
|
|
170
|
+
headers: { authorization: `Bearer ${workerToken}` }
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const job = await requester.offer({
|
|
174
|
+
worker_id: workerAgentId,
|
|
175
|
+
title: 'Prepare a short brief',
|
|
176
|
+
description: 'Research the topic and return a deliverable link.',
|
|
177
|
+
price: { amount_minor: 500, currency: 'USD' }
|
|
178
|
+
}, { idempotencyKey: 'brief-request-001' });
|
|
179
|
+
|
|
180
|
+
await worker.accept(job.id);
|
|
181
|
+
await worker.submit(job.id, { deliverable: 'https://example.com/brief.md' });
|
|
182
|
+
await requester.approve(job.id);
|
|
183
|
+
const receipts = await requester.receipts(job.id);
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`requesterToken`, `workerToken` and `workerAgentId` come from your application, not the SDK. The requester and worker are distinct authenticated actors. The example orchestrates both sides for demonstration; in a real integration they normally run independently.
|
|
187
|
+
|
|
188
|
+
The price is an agreed term: 500 USD minor units means $5.00 when your adapter uses USD cents. Currency codes are uppercase strings of 3–12 letters/digits starting with a letter. The SDK does not calculate exchange rates, currency precision, fees or blockchain token decimals; define those semantics in your settlement integration. `amount_minor` must be a nonnegative safe integer. Zero-price jobs are allowed.
|
|
189
|
+
|
|
190
|
+
A deliverable is an opaque string, such as a URL, repository reference or storage path. The SDK stores it and never downloads or executes it. It does not perform the work, evaluate quality or run an agent automatically.
|
|
191
|
+
|
|
192
|
+
Client methods return `Job`, except `receipts`, which returns `Receipts`. `headers` may be an async function for fresh credentials. `fetch` can be injected for tests or custom networking. Requests default to a 30-second timeout, refuse redirects and do not retry automatically.
|
|
193
|
+
|
|
194
|
+
## HTTP API
|
|
195
|
+
|
|
196
|
+
Every request requires successful `authenticate(request)`. POSTs use `Content-Type: application/json`.
|
|
197
|
+
|
|
198
|
+
| Method | Route | Body | Who can call |
|
|
199
|
+
| --- | --- | --- | --- |
|
|
200
|
+
| POST | `/api/a423/offers` | `{worker_id,title,description,price}` and `Idempotency-Key` header | Authenticated requester |
|
|
201
|
+
| GET | `/api/a423/jobs/:id` | None | Requester or assigned worker |
|
|
202
|
+
| POST | `/api/a423/jobs/:id/accept` | `{}` | Assigned worker |
|
|
203
|
+
| POST | `/api/a423/jobs/:id/submit` | `{deliverable}` | Assigned worker |
|
|
204
|
+
| POST | `/api/a423/jobs/:id/approve` | `{}` | Requester |
|
|
205
|
+
| GET | `/api/a423/jobs/:id/receipts` | None | Requester or assigned worker |
|
|
206
|
+
|
|
207
|
+
Offer creation returns HTTP 201 with `{job}`; an identical retry returns 200 with the existing job. Other actions return `{job}`. Receipts return `{job_id,state,settlement,events}`. Responses use `Cache-Control: no-store`. Unknown jobs and jobs belonging to other actors both return 404.
|
|
208
|
+
|
|
209
|
+
External agents can use plain HTTP without installing the SDK. For example:
|
|
210
|
+
|
|
211
|
+
```sh
|
|
212
|
+
curl https://your-app.example/api/a423/offers \
|
|
213
|
+
-H "Authorization: Bearer $A423_REQUESTER_TOKEN" \
|
|
214
|
+
-H 'Content-Type: application/json' \
|
|
215
|
+
-H 'Idempotency-Key: brief-request-001' \
|
|
216
|
+
-d '{"worker_id":"worker-agent-id","title":"Brief","description":"Prepare a short brief","price":{"amount_minor":500,"currency":"USD"}}'
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Adjust `basePath` if you mount at a different URL. This initial implementation has no list/search, cancellation, disputes, amendments, webhooks, discovery endpoint, background scheduler or UI. Poll known job IDs or coordinate workers through your own queue. Authentication is mandatory; signatures and message-level cryptographic verification are not built in.
|
|
220
|
+
|
|
221
|
+
## Workflow and retry behaviour
|
|
222
|
+
|
|
223
|
+
A job follows `offered → accepted → submitted → completed`. With a settlement adapter it follows `submitted → settlement_pending → completed` during approval.
|
|
224
|
+
|
|
225
|
+
The server checks the actor and current state before each change. Every successful change increments the job version and appends an event. Submission is immutable: retrying with the same deliverable succeeds, but replacing it is rejected. Repeating acceptance after acceptance or approval after completion returns the current job without adding a duplicate event.
|
|
226
|
+
|
|
227
|
+
Offer IDs are deterministic SHA-256 values scoped by protocol version, authenticated requester ID and `Idempotency-Key`. Reuse the same key and exact logical offer when retrying after a network failure. Reusing it with different offer details returns 409. Store the key on your side; a freshly generated key means a new offer. Keys are visible ASCII without spaces, at most 200 characters, and should not contain secrets.
|
|
228
|
+
|
|
229
|
+
Storage changes use compare-and-set. Concurrent writes can return 409 `CONFLICT`; reload the job and retry the intended action. The SDK does not keep an HTTP request open for the duration of a task. Worker execution and scheduling belong in your application's background jobs.
|
|
230
|
+
|
|
231
|
+
## Storage adapter
|
|
232
|
+
|
|
233
|
+
`store` is required. `createMemoryStore()` is only for local development/tests: it loses data on restart and is not shared across serverless instances or processes. There is no silent in-memory default.
|
|
234
|
+
|
|
235
|
+
Implement the `StateStore` interface against your database:
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
interface StateStore {
|
|
239
|
+
get(id: string): Promise<Job | null>;
|
|
240
|
+
create(job: Job): Promise<boolean>;
|
|
241
|
+
compareAndSet(id: string, expectedVersion: number, job: Job): Promise<boolean>;
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
`create` must atomically insert only if the ID is absent; enforce a unique ID constraint. `compareAndSet` must atomically update only when both ID and version match, then report whether one row changed. Store job fields and events in the same transaction. A read followed by an unconditional update is not sufficient. Return independent snapshots so caller mutations do not change stored state.
|
|
246
|
+
|
|
247
|
+
For example, a relational adapter can use an ID primary key, a version column and a JSON document. Create with insert-on-conflict-do-nothing; update using `WHERE id = ? AND version = ?`. This is a contract, not a shipped SQL adapter. Durable storage must also support pending settlement recovery.
|
|
248
|
+
|
|
249
|
+
## Identity and authentication
|
|
250
|
+
|
|
251
|
+
`authenticate(request)` is the integration point for your identity system. It returns an authenticated actor's public agent ID or `null` to reject. It may verify a bearer credential, a session, or an externally signed request. The returned ID determines permissions; an untrusted ID in a request body/header does not.
|
|
252
|
+
|
|
253
|
+
`@allorc/identity` 0.3.0 can form a public agent ID:
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
import { VERSION, createIdentity } from '@allorc/identity';
|
|
257
|
+
|
|
258
|
+
const identity = createIdentity({
|
|
259
|
+
version: VERSION,
|
|
260
|
+
organization_public_key: 'externally-supplied-public-value',
|
|
261
|
+
agent_reference: 'worker-42',
|
|
262
|
+
agent_name: 'Alice',
|
|
263
|
+
agent_email: 'alice@example.com'
|
|
264
|
+
});
|
|
265
|
+
// Register identity.agent_id with your credential/authority system separately.
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Install that optional package separately when needed. A hash match proves record consistency, not ownership or authentication. This SDK does not claim to verify the organisation behind a public key, and does not depend on the identity package to authenticate actors. Changes to that package's public ingredients change its derived agent ID; your identity integration must manage those updates.
|
|
269
|
+
|
|
270
|
+
For cookie-based authentication, your integration must also enforce its CSRF/origin policy. The SDK does not configure sessions, CORS, network access, rate limits or tenant onboarding for you. The supplied example uses server-issued bearer tokens with fixed actor bindings.
|
|
271
|
+
|
|
272
|
+
## Optional settlement adapter
|
|
273
|
+
|
|
274
|
+
Without an adapter, approval marks the job completed with:
|
|
275
|
+
|
|
276
|
+
```json
|
|
277
|
+
{"status":"not_configured","reference":null}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
**This records task approval and moves no money.** A configured adapter is called only after requester approval and after the job is persisted as `settlement_pending`:
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
const handler = createA423Handler({
|
|
284
|
+
store,
|
|
285
|
+
authenticate,
|
|
286
|
+
settlement: {
|
|
287
|
+
async settle({ job, idempotencyKey }) {
|
|
288
|
+
// Invoke your own escrow/payment service with this stable key.
|
|
289
|
+
// Persistently deduplicate that key, including concurrent invocations.
|
|
290
|
+
const result = await yourSettlementService.release({
|
|
291
|
+
jobId: job.id,
|
|
292
|
+
requesterId: job.requester_id,
|
|
293
|
+
workerId: job.worker_id,
|
|
294
|
+
price: job.price,
|
|
295
|
+
idempotencyKey
|
|
296
|
+
});
|
|
297
|
+
return { reference: result.receiptId };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
`yourSettlementService` is your external integration, not an included export. The SDK passes `a423:settle:<job-id>` as a stable provider idempotency key. Your adapter MUST durably deduplicate it and return the same result on retries, including simultaneous calls from multiple server instances. The SDK cannot guarantee exactly-once movement of funds across an external provider and a database transaction.
|
|
304
|
+
|
|
305
|
+
On a provider error the server returns 502 and leaves the job pending; retry `approve` as the requester. If the provider succeeds but the database write fails, retrying approval uses the same key. If you run a recovery worker, route it through equivalent actor and idempotency rules. No automatic retry queue is included.
|
|
306
|
+
|
|
307
|
+
This is not funded escrow: the SDK does not reserve funds at offer/acceptance, validate wallets, sign financial authorisations, collect mandates or guarantee payment. Your adapter must enforce those rules before moving funds. `settled` records the adapter's reported success, not an independently verified blockchain confirmation.
|
|
308
|
+
|
|
309
|
+
## Errors and limits
|
|
310
|
+
|
|
311
|
+
Errors are `{error:{code,message}}`. The client throws `A423Error` with `code` and `status` for HTTP failures. Network/timeout errors propagate from fetch. Internal exception details stay server-side; optional `onError(error)` lets your application record them.
|
|
312
|
+
|
|
313
|
+
| HTTP status | Typical code |
|
|
314
|
+
| --- | --- |
|
|
315
|
+
| 400 | `INVALID_INPUT`, `INVALID_JSON` |
|
|
316
|
+
| 401 | `UNAUTHENTICATED` |
|
|
317
|
+
| 403 | `FORBIDDEN` |
|
|
318
|
+
| 404 | `NOT_FOUND` |
|
|
319
|
+
| 405 | `METHOD_NOT_ALLOWED` |
|
|
320
|
+
| 409 | `INVALID_STATE`, `CONFLICT`, `IDEMPOTENCY_CONFLICT` |
|
|
321
|
+
| 413 | `BODY_TOO_LARGE` |
|
|
322
|
+
| 415 | `UNSUPPORTED_MEDIA_TYPE` |
|
|
323
|
+
| 500 | `INTERNAL_ERROR` |
|
|
324
|
+
| 502 | `SETTLEMENT_UNAVAILABLE` |
|
|
325
|
+
|
|
326
|
+
Request bodies default to 64 KiB and are limited while streaming, even without `Content-Length`; `maxBodyBytes` can set 1 byte through 1 MiB. Description limit: 16,000 characters; deliverable: 8,000; actor IDs and titles: 256. Client response bodies are limited to 1 MiB. Bound requests at your ingress too. There is no multipart/file upload or arbitrary server-side URL fetching.
|
|
327
|
+
|
|
328
|
+
## Package exports
|
|
329
|
+
|
|
330
|
+
| Export | Purpose |
|
|
331
|
+
| --- | --- |
|
|
332
|
+
| `createA423Handler(options)` | Standard Web Request/Response handler usable in Next.js routes |
|
|
333
|
+
| `createA423Client(options)` | Typed HTTP client |
|
|
334
|
+
| `createMemoryStore()` | Explicit local development store |
|
|
335
|
+
| `A423Error` | Structured client/protocol error |
|
|
336
|
+
| `PROTOCOL_VERSION` | `a423.experimental.v1` |
|
|
337
|
+
|
|
338
|
+
ESM and CommonJS expose the same names. Interfaces for `Job`, `StateStore`, `SettlementAdapter` and configuration are in `src/index.d.ts`. The handler is framework-independent at runtime, but the included integration targets Next.js App Router. Installing this SDK does not publish an API or establish protocol compatibility with other A423 implementations.
|
|
339
|
+
|
|
340
|
+
## Development
|
|
341
|
+
|
|
342
|
+
```sh
|
|
343
|
+
npm test
|
|
344
|
+
npm run pack:check
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
Tests exercise the complete workflow through actual Web Request/Response objects, permissions, malformed input, streaming limits, concurrent updates, retry semantics and settlement recovery. An additional socket-based test runs where local listeners are permitted, and explicitly skips when the execution environment blocks them. Next.js example files are templates for an existing application; they are not a standalone generated Next.js app.
|
|
348
|
+
|
|
349
|
+
No runtime dependencies or build step. Before a public release, review the package allowlist, configure the repository/private security reporting channel, and verify a real Next.js deployment and durable adapter. Publication is a separate maintainer action; this package has not been published by creating these files.
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
Experimental SDK; no independent audit or stable support commitment. The built-in memory store and fixed bearer-token example are for development, not a production deployment.
|
|
4
|
+
|
|
5
|
+
Authentication must be supplied. Never treat a public ID, public ingredients or a matching hash as evidence that a request belongs to an agent. Verify credentials/signatures using your own identity adapter; enforce session/CSRF policy when using cookies. Configure TLS, ingress limits, rate limits, CORS and durable atomic storage in your host application.
|
|
6
|
+
|
|
7
|
+
Settlement adapters must durably deduplicate the SDK's stable key across retries and concurrent calls. This SDK does not fund escrow, establish mandates, custody keys or guarantee exactly-once payments. Validate recipient, amount, permission and provider status independently. Completion without an adapter explicitly means no settlement is configured.
|
|
8
|
+
|
|
9
|
+
Do not include tokens, private keys, customer data or live exploit details in public reports. Use a private repository reporting channel when configured; maintainers should establish one before requesting sensitive reports. Never log credentials. No private security contact is invented by this initial package.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Run after starting the Next.js example and setting the same server credentials.
|
|
2
|
+
import { createA423Client } from '../src/index.mjs';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
for (const name of ['A423_REQUESTER_TOKEN', 'A423_WORKER_TOKEN', 'A423_WORKER_ID']) {
|
|
6
|
+
if (!process.env[name]) throw new Error(`Set ${name} before running this example`);
|
|
7
|
+
}
|
|
8
|
+
const baseUrl = process.env.A423_BASE_URL || 'http://localhost:3000/api/a423';
|
|
9
|
+
const requester = createA423Client({ baseUrl, headers: { authorization: `Bearer ${process.env.A423_REQUESTER_TOKEN}` } });
|
|
10
|
+
const worker = createA423Client({ baseUrl, headers: { authorization: `Bearer ${process.env.A423_WORKER_TOKEN}` } });
|
|
11
|
+
const job = await requester.offer({
|
|
12
|
+
worker_id: process.env.A423_WORKER_ID,
|
|
13
|
+
title: 'Prepare a short brief', description: 'Return a link to the completed brief.',
|
|
14
|
+
price: { amount_minor: 500, currency: 'USD' }
|
|
15
|
+
}, { idempotencyKey: randomUUID() }); // Persist this key if retrying this logical offer.
|
|
16
|
+
await worker.accept(job.id);
|
|
17
|
+
await worker.submit(job.id, { deliverable: 'https://example.com/brief.md' });
|
|
18
|
+
await requester.approve(job.id);
|
|
19
|
+
console.log(JSON.stringify(await requester.receipts(job.id), null, 2));
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
import { createA423Handler, createMemoryStore } from '@allorc/a423-sdk';
|
|
3
|
+
|
|
4
|
+
// DEMO ONLY: replace with your database-backed StateStore before production.
|
|
5
|
+
const store = createMemoryStore();
|
|
6
|
+
|
|
7
|
+
const requesterToken = process.env.A423_REQUESTER_TOKEN;
|
|
8
|
+
const workerToken = process.env.A423_WORKER_TOKEN;
|
|
9
|
+
const requesterId = process.env.A423_REQUESTER_ID;
|
|
10
|
+
const workerId = process.env.A423_WORKER_ID;
|
|
11
|
+
if (!requesterToken || !workerToken || !requesterId || !workerId ||
|
|
12
|
+
requesterToken === workerToken || requesterId === workerId) {
|
|
13
|
+
throw new Error('Set distinct A423_REQUESTER_TOKEN, A423_WORKER_TOKEN, A423_REQUESTER_ID and A423_WORKER_ID server environment values');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const a423 = createA423Handler({
|
|
17
|
+
store,
|
|
18
|
+
// A demo mapping from private server-issued credentials to public agent IDs.
|
|
19
|
+
// Replace with your real authentication/identity adapter. Do not expose tokens to React.
|
|
20
|
+
authenticate(request) {
|
|
21
|
+
const header = request.headers.get('authorization');
|
|
22
|
+
if (header === `Bearer ${requesterToken}`) return requesterId;
|
|
23
|
+
if (header === `Bearer ${workerToken}`) return workerId;
|
|
24
|
+
return null;
|
|
25
|
+
},
|
|
26
|
+
onError(error) {
|
|
27
|
+
// Send to your own server logger; avoid including credentials.
|
|
28
|
+
console.error('A423 server error', error instanceof Error ? error.name : 'UnknownError');
|
|
29
|
+
}
|
|
30
|
+
// No settlement adapter: completion records approval only; it moves no money.
|
|
31
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@allorc/a423-sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Experimental Next.js-friendly agent job SDK with typed HTTP client and pluggable storage, authentication and settlement",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"main": "./src/index.cjs",
|
|
8
|
+
"types": "./src/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./src/index.d.ts",
|
|
12
|
+
"import": "./src/index.mjs",
|
|
13
|
+
"require": "./src/index.cjs"
|
|
14
|
+
},
|
|
15
|
+
"./workflow": {
|
|
16
|
+
"types": "./src/workflow.d.ts",
|
|
17
|
+
"import": "./src/workflow.mjs",
|
|
18
|
+
"require": "./src/workflow.cjs"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"src/index.cjs",
|
|
23
|
+
"src/index.mjs",
|
|
24
|
+
"src/index.d.ts",
|
|
25
|
+
"examples/client.mjs",
|
|
26
|
+
"examples/nextjs/lib/a423.ts",
|
|
27
|
+
"examples/nextjs/app/api/a423/*/route.ts",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE",
|
|
30
|
+
"SECURITY.md",
|
|
31
|
+
"src/workflow.cjs",
|
|
32
|
+
"src/workflow.mjs",
|
|
33
|
+
"src/workflow.d.ts",
|
|
34
|
+
"INTEGRATION.md",
|
|
35
|
+
"CHANGELOG.md"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=22"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"test": "node --test",
|
|
42
|
+
"pack:check": "npm pack --dry-run --ignore-scripts",
|
|
43
|
+
"prepublishOnly": "npm test"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
},
|
|
48
|
+
"keywords": [
|
|
49
|
+
"nextjs",
|
|
50
|
+
"agent",
|
|
51
|
+
"a423",
|
|
52
|
+
"sdk",
|
|
53
|
+
"experimental"
|
|
54
|
+
],
|
|
55
|
+
"repository": {
|
|
56
|
+
"type": "git",
|
|
57
|
+
"url": "git+https://github.com/Allorc-official/A423-SDK.git"
|
|
58
|
+
},
|
|
59
|
+
"homepage": "https://github.com/Allorc-official/A423-SDK#readme",
|
|
60
|
+
"bugs": {
|
|
61
|
+
"url": "https://github.com/Allorc-official/A423-SDK/issues"
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/index.cjs
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { createHash, randomUUID } = require('node:crypto');
|
|
3
|
+
const PROTOCOL_VERSION = 'a423.experimental.v1';
|
|
4
|
+
const MAX_BODY_BYTES = 65536;
|
|
5
|
+
const MAX_RESULT_BYTES = 1048576;
|
|
6
|
+
|
|
7
|
+
class A423Error extends Error {
|
|
8
|
+
constructor(code, message, status = 400) {
|
|
9
|
+
super(message); this.name = 'A423Error'; this.code = code; this.status = status;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function fail(code, message, status = 400) { throw new A423Error(code, message, status); }
|
|
13
|
+
function object(value, required, optional = []) {
|
|
14
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) fail('INVALID_INPUT', 'Expected an object');
|
|
15
|
+
const keys = Object.keys(value);
|
|
16
|
+
if (required.some(k => !Object.hasOwn(value, k)) || keys.some(k => ![...required, ...optional].includes(k))) {
|
|
17
|
+
fail('INVALID_INPUT', `Expected fields: ${required.join(', ')}${optional.length ? `; optional: ${optional.join(', ')}` : ''}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function text(value, name, max = 256) {
|
|
21
|
+
if (typeof value !== 'string' || !value.trim() || value.length > max || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value)) {
|
|
22
|
+
fail('INVALID_INPUT', `${name} must be nonempty text of at most ${max} characters`);
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
function offerInput(value) {
|
|
27
|
+
object(value, ['worker_id', 'title', 'description', 'price']);
|
|
28
|
+
object(value.price, ['amount_minor', 'currency']);
|
|
29
|
+
if (!Number.isSafeInteger(value.price.amount_minor) || value.price.amount_minor < 0) fail('INVALID_INPUT', 'amount_minor must be a nonnegative safe integer');
|
|
30
|
+
if (typeof value.price.currency !== 'string' || !/^[A-Z][A-Z0-9]{2,11}$/.test(value.price.currency)) fail('INVALID_INPUT', 'currency must be an uppercase currency code');
|
|
31
|
+
return { worker_id: text(value.worker_id, 'worker_id'), title: text(value.title, 'title'),
|
|
32
|
+
description: text(value.description, 'description', 16000), price: { amount_minor: value.price.amount_minor, currency: value.price.currency } };
|
|
33
|
+
}
|
|
34
|
+
function submissionInput(value) {
|
|
35
|
+
object(value, ['deliverable']);
|
|
36
|
+
return { deliverable: text(value.deliverable, 'deliverable', 8000) };
|
|
37
|
+
}
|
|
38
|
+
function hash(value) { return createHash('sha256').update(JSON.stringify(value)).digest('hex'); }
|
|
39
|
+
function clone(value) { return structuredClone(value); }
|
|
40
|
+
function json(value, status = 200) {
|
|
41
|
+
return Response.json(value, { status, headers: { 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' } });
|
|
42
|
+
}
|
|
43
|
+
async function readJson(request, limit) {
|
|
44
|
+
if (!(request.headers.get('content-type') || '').split(';')[0].trim().toLowerCase().endsWith('/json')) {
|
|
45
|
+
fail('UNSUPPORTED_MEDIA_TYPE', 'Use Content-Type: application/json', 415);
|
|
46
|
+
}
|
|
47
|
+
const length = request.headers.get('content-length');
|
|
48
|
+
if (length !== null && (!/^\d+$/.test(length) || Number(length) > limit)) fail('BODY_TOO_LARGE', 'Request body exceeds the configured limit', 413);
|
|
49
|
+
if (!request.body) fail('INVALID_JSON', 'A JSON body is required');
|
|
50
|
+
const reader = request.body.getReader(); const chunks = []; let size = 0;
|
|
51
|
+
try {
|
|
52
|
+
while (true) {
|
|
53
|
+
const { done, value } = await reader.read();
|
|
54
|
+
if (done) break;
|
|
55
|
+
size += value.byteLength;
|
|
56
|
+
if (size > limit) { await reader.cancel(); fail('BODY_TOO_LARGE', 'Request body exceeds the configured limit', 413); }
|
|
57
|
+
chunks.push(Buffer.from(value));
|
|
58
|
+
}
|
|
59
|
+
} finally { reader.releaseLock(); }
|
|
60
|
+
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); }
|
|
61
|
+
catch { fail('INVALID_JSON', 'Request body must contain valid JSON'); }
|
|
62
|
+
}
|
|
63
|
+
/** Development only: no persistence or coordination between processes/serverless instances. */
|
|
64
|
+
function createMemoryStore() {
|
|
65
|
+
const jobs = new Map();
|
|
66
|
+
return {
|
|
67
|
+
async get(id) { return jobs.has(id) ? clone(jobs.get(id)) : null; },
|
|
68
|
+
async create(job) { if (jobs.has(job.id)) return false; jobs.set(job.id, clone(job)); return true; },
|
|
69
|
+
async compareAndSet(id, expectedVersion, job) {
|
|
70
|
+
if (!jobs.has(id) || jobs.get(id).version !== expectedVersion) return false;
|
|
71
|
+
jobs.set(id, clone(job)); return true;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function createA423Handler({ store, authenticate, settlement, basePath = '/api/a423', maxBodyBytes = MAX_BODY_BYTES, onError } = {}) {
|
|
76
|
+
if (!store || ['get', 'create', 'compareAndSet'].some(k => typeof store[k] !== 'function')) throw new TypeError('Supply a store implementing get, create and atomic compareAndSet');
|
|
77
|
+
if (typeof authenticate !== 'function') throw new TypeError('Supply authenticate(request); there is no implicit anonymous access');
|
|
78
|
+
if (settlement && typeof settlement.settle !== 'function') throw new TypeError('settlement must implement settle');
|
|
79
|
+
if (typeof basePath !== 'string' || !/^\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+$/.test(basePath)) throw new TypeError('basePath must be an absolute path without a trailing slash');
|
|
80
|
+
if (!Number.isSafeInteger(maxBodyBytes) || maxBodyBytes < 1 || maxBodyBytes > MAX_RESULT_BYTES) throw new TypeError('maxBodyBytes must be between 1 and 1048576');
|
|
81
|
+
if (onError !== undefined && typeof onError !== 'function') throw new TypeError('onError must be a function');
|
|
82
|
+
const event = (job, type, actorId, data = {}) => ({ id: randomUUID(), sequence: job.events.length + 1, type, actor_id: actorId, at: new Date().toISOString(), data });
|
|
83
|
+
async function save(previous, next, type, actorId, data) {
|
|
84
|
+
next.version = previous.version + 1;
|
|
85
|
+
next.updated_at = new Date().toISOString();
|
|
86
|
+
next.events.push(event(next, type, actorId, data));
|
|
87
|
+
if (!await store.compareAndSet(previous.id, previous.version, next)) fail('CONFLICT', 'Job changed concurrently; reload and retry', 409);
|
|
88
|
+
return next;
|
|
89
|
+
}
|
|
90
|
+
async function load(id, actorId) {
|
|
91
|
+
const job = await store.get(id);
|
|
92
|
+
// Do not disclose a job's existence to unrelated actors.
|
|
93
|
+
if (!job || ![job.requester_id, job.worker_id].includes(actorId)) fail('NOT_FOUND', 'Job not found', 404);
|
|
94
|
+
return job;
|
|
95
|
+
}
|
|
96
|
+
return async function handler(request) {
|
|
97
|
+
try {
|
|
98
|
+
const path = new URL(request.url).pathname;
|
|
99
|
+
if (!path.startsWith(basePath + '/')) fail('NOT_FOUND', 'Route not found', 404);
|
|
100
|
+
const relative = path.slice(basePath.length);
|
|
101
|
+
const match = /^\/jobs\/([a-zA-Z0-9_-]{1,128})(?:\/(accept|submit|approve|receipts))?$/.exec(relative);
|
|
102
|
+
if (relative !== '/offers' && !match) fail('NOT_FOUND', 'Route not found', 404);
|
|
103
|
+
const expectedMethod = relative === '/offers' || (match && ['accept', 'submit', 'approve'].includes(match[2])) ? 'POST' : 'GET';
|
|
104
|
+
if (request.method !== expectedMethod) {
|
|
105
|
+
const response = json({ error: { code: 'METHOD_NOT_ALLOWED', message: `Use ${expectedMethod}` } }, 405);
|
|
106
|
+
response.headers.set('allow', expectedMethod); return response;
|
|
107
|
+
}
|
|
108
|
+
const actorId = await authenticate(request);
|
|
109
|
+
if (actorId === null || actorId === undefined) fail('UNAUTHENTICATED', 'Authentication required', 401);
|
|
110
|
+
text(actorId, 'authenticated actor ID');
|
|
111
|
+
if (relative === '/offers') {
|
|
112
|
+
const input = offerInput(await readJson(request, maxBodyBytes));
|
|
113
|
+
if (input.worker_id === actorId) fail('INVALID_INPUT', 'Requester and worker must be different actors');
|
|
114
|
+
const idempotencyKey = text(request.headers.get('idempotency-key'), 'Idempotency-Key', 200);
|
|
115
|
+
if (!/^[\x21-\x7e]+$/.test(idempotencyKey)) fail('INVALID_INPUT', 'Idempotency-Key must use visible ASCII without spaces');
|
|
116
|
+
const id = 'job_' + hash([PROTOCOL_VERSION, actorId, idempotencyKey]);
|
|
117
|
+
const requestHash = hash(input);
|
|
118
|
+
const at = new Date().toISOString();
|
|
119
|
+
const job = { protocol: PROTOCOL_VERSION, id, version: 1, state: 'offered', requester_id: actorId,
|
|
120
|
+
...input, request_hash: requestHash, deliverable: null, settlement: null, created_at: at, updated_at: at, events: [] };
|
|
121
|
+
job.events.push(event(job, 'offered', actorId));
|
|
122
|
+
if (await store.create(job)) return json({ job }, 201);
|
|
123
|
+
const existing = await load(id, actorId);
|
|
124
|
+
if (existing.request_hash !== requestHash) fail('IDEMPOTENCY_CONFLICT', 'Idempotency-Key was already used with different offer details', 409);
|
|
125
|
+
return json({ job: existing });
|
|
126
|
+
}
|
|
127
|
+
const [, id, action] = match;
|
|
128
|
+
let job = await load(id, actorId);
|
|
129
|
+
if (action === 'receipts') return json({ job_id: job.id, state: job.state, settlement: job.settlement, events: job.events });
|
|
130
|
+
if (!action) return json({ job });
|
|
131
|
+
const body = await readJson(request, maxBodyBytes);
|
|
132
|
+
if (action === 'accept') {
|
|
133
|
+
object(body, []);
|
|
134
|
+
if (actorId !== job.worker_id) fail('FORBIDDEN', 'Only the assigned worker can accept', 403);
|
|
135
|
+
if (job.state !== 'offered') {
|
|
136
|
+
if (['accepted', 'submitted', 'settlement_pending', 'completed'].includes(job.state)) return json({ job });
|
|
137
|
+
fail('INVALID_STATE', 'Job must be offered', 409);
|
|
138
|
+
}
|
|
139
|
+
const next = clone(job); next.state = 'accepted';
|
|
140
|
+
job = await save(job, next, 'accepted', actorId);
|
|
141
|
+
return json({ job });
|
|
142
|
+
}
|
|
143
|
+
if (action === 'submit') {
|
|
144
|
+
const input = submissionInput(body);
|
|
145
|
+
if (actorId !== job.worker_id) fail('FORBIDDEN', 'Only the assigned worker can submit', 403);
|
|
146
|
+
if (job.state !== 'accepted') {
|
|
147
|
+
if (['submitted', 'settlement_pending', 'completed'].includes(job.state) && job.deliverable === input.deliverable) return json({ job });
|
|
148
|
+
fail('INVALID_STATE', 'Job must be accepted; a submitted deliverable cannot be replaced', 409);
|
|
149
|
+
}
|
|
150
|
+
const next = clone(job); next.state = 'submitted'; next.deliverable = input.deliverable;
|
|
151
|
+
return json({ job: await save(job, next, 'submitted', actorId, input) });
|
|
152
|
+
}
|
|
153
|
+
object(body, []);
|
|
154
|
+
if (actorId !== job.requester_id) fail('FORBIDDEN', 'Only the requester can approve', 403);
|
|
155
|
+
if (job.state === 'completed') return json({ job });
|
|
156
|
+
if (!['submitted', 'settlement_pending'].includes(job.state)) fail('INVALID_STATE', 'Job must be submitted before approval', 409);
|
|
157
|
+
if (!settlement) {
|
|
158
|
+
const next = clone(job); next.state = 'completed';
|
|
159
|
+
next.settlement = { status: 'not_configured', reference: null };
|
|
160
|
+
return json({ job: await save(job, next, 'completed', actorId, { settlement: next.settlement }) });
|
|
161
|
+
}
|
|
162
|
+
if (job.state === 'submitted') {
|
|
163
|
+
const next = clone(job); next.state = 'settlement_pending';
|
|
164
|
+
job = await save(job, next, 'settlement_pending', actorId);
|
|
165
|
+
}
|
|
166
|
+
// The adapter MUST deduplicate this stable key across retries/processes.
|
|
167
|
+
const idempotencyKey = `a423:settle:${job.id}`;
|
|
168
|
+
let result;
|
|
169
|
+
try {
|
|
170
|
+
result = await settlement.settle({ job: clone(job), idempotencyKey });
|
|
171
|
+
object(result, ['reference']); text(result.reference, 'settlement reference', 2048);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (onError) { try { await onError(error); } catch {} }
|
|
174
|
+
fail('SETTLEMENT_UNAVAILABLE', 'Settlement remains pending; retry approval with the same job', 502);
|
|
175
|
+
}
|
|
176
|
+
const next = clone(job); next.state = 'completed';
|
|
177
|
+
next.settlement = { status: 'settled', reference: result.reference };
|
|
178
|
+
try { return json({ job: await save(job, next, 'completed', actorId, { settlement: next.settlement }) }); }
|
|
179
|
+
catch (error) {
|
|
180
|
+
if (error instanceof A423Error && error.code === 'CONFLICT') {
|
|
181
|
+
const latest = await load(id, actorId);
|
|
182
|
+
if (latest.state === 'completed') return json({ job: latest });
|
|
183
|
+
}
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (error instanceof A423Error) return json({ error: { code: error.code, message: error.message } }, error.status);
|
|
188
|
+
if (onError) { try { await onError(error); } catch {} }
|
|
189
|
+
return json({ error: { code: 'INTERNAL_ERROR', message: 'Unable to process the request' } }, 500);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function createA423Client({ baseUrl, headers, fetch: fetchImpl = globalThis.fetch, timeoutMs = 30000 } = {}) {
|
|
195
|
+
const url = new URL(baseUrl);
|
|
196
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new TypeError('baseUrl must be an HTTP(S) URL without credentials, query or fragment');
|
|
197
|
+
if (typeof fetchImpl !== 'function') throw new TypeError('Supply a fetch implementation');
|
|
198
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new TypeError('timeoutMs must be a positive integer');
|
|
199
|
+
const base = url.href.replace(/\/$/, '');
|
|
200
|
+
async function call(path, method = 'GET', body, idempotencyKey) {
|
|
201
|
+
const supplied = typeof headers === 'function' ? await headers() : headers;
|
|
202
|
+
const requestHeaders = new Headers(supplied);
|
|
203
|
+
requestHeaders.set('accept', 'application/json');
|
|
204
|
+
if (body !== undefined) requestHeaders.set('content-type', 'application/json');
|
|
205
|
+
if (idempotencyKey !== undefined) requestHeaders.set('idempotency-key', idempotencyKey);
|
|
206
|
+
const response = await fetchImpl(base + path, { method, headers: requestHeaders, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs), redirect: 'error' });
|
|
207
|
+
const data = await readClientJson(response);
|
|
208
|
+
if (!response.ok) throw new A423Error(data?.error?.code || 'HTTP_ERROR', data?.error?.message || `HTTP ${response.status}`, response.status);
|
|
209
|
+
return data;
|
|
210
|
+
}
|
|
211
|
+
function jobPath(id) {
|
|
212
|
+
if (typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new TypeError('Invalid job ID');
|
|
213
|
+
return '/jobs/' + id;
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
async offer(input, { idempotencyKey } = {}) {
|
|
217
|
+
if (typeof idempotencyKey !== 'string' || !idempotencyKey) throw new TypeError('Supply an idempotencyKey for offer retries');
|
|
218
|
+
return (await call('/offers', 'POST', input, idempotencyKey)).job;
|
|
219
|
+
},
|
|
220
|
+
async getJob(id) { return (await call(jobPath(id))).job; },
|
|
221
|
+
async accept(id) { return (await call(jobPath(id) + '/accept', 'POST', {})).job; },
|
|
222
|
+
async submit(id, input) { return (await call(jobPath(id) + '/submit', 'POST', input)).job; },
|
|
223
|
+
async approve(id) { return (await call(jobPath(id) + '/approve', 'POST', {})).job; },
|
|
224
|
+
async receipts(id) { return call(jobPath(id) + '/receipts'); }
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
async function readClientJson(response) {
|
|
228
|
+
const reader = response.body?.getReader();
|
|
229
|
+
if (!reader) throw new A423Error('INVALID_RESPONSE', 'Server returned no response body', 502);
|
|
230
|
+
const chunks = []; let size = 0;
|
|
231
|
+
try {
|
|
232
|
+
while (true) {
|
|
233
|
+
const { done, value } = await reader.read(); if (done) break;
|
|
234
|
+
size += value.byteLength;
|
|
235
|
+
if (size > MAX_RESULT_BYTES) { await reader.cancel(); throw new A423Error('INVALID_RESPONSE', 'Server response exceeds 1 MiB', 502); }
|
|
236
|
+
chunks.push(Buffer.from(value));
|
|
237
|
+
}
|
|
238
|
+
} finally { reader.releaseLock(); }
|
|
239
|
+
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); }
|
|
240
|
+
catch { throw new A423Error('INVALID_RESPONSE', 'Server returned invalid JSON', 502); }
|
|
241
|
+
}
|
|
242
|
+
module.exports = { PROTOCOL_VERSION, A423Error, createMemoryStore, createA423Handler, createA423Client };
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** Experimental A423 workflow, not a finalized interoperable protocol. Node.js 22+. */
|
|
2
|
+
export const PROTOCOL_VERSION: 'a423.experimental.v1';
|
|
3
|
+
export type JobState = 'offered' | 'accepted' | 'submitted' | 'settlement_pending' | 'completed';
|
|
4
|
+
export interface Price { amount_minor: number; currency: string }
|
|
5
|
+
export interface OfferInput { worker_id: string; title: string; description: string; price: Price }
|
|
6
|
+
export interface SubmissionInput { deliverable: string }
|
|
7
|
+
export interface SettlementResult { status: 'settled' | 'not_configured'; reference: string | null }
|
|
8
|
+
export interface JobEvent {
|
|
9
|
+
id: string; sequence: number; type: JobState; actor_id: string;
|
|
10
|
+
at: string; data: { deliverable?: string; settlement?: SettlementResult };
|
|
11
|
+
}
|
|
12
|
+
export interface Job extends OfferInput {
|
|
13
|
+
protocol: typeof PROTOCOL_VERSION;
|
|
14
|
+
id: string; version: number; state: JobState; requester_id: string;
|
|
15
|
+
request_hash: string; deliverable: string | null; settlement: SettlementResult | null;
|
|
16
|
+
created_at: string; updated_at: string; events: JobEvent[];
|
|
17
|
+
}
|
|
18
|
+
export interface Receipts { job_id: string; state: JobState; settlement: SettlementResult | null; events: JobEvent[] }
|
|
19
|
+
/** All writes must be atomic across instances. Jobs and events are saved together. */
|
|
20
|
+
export interface StateStore {
|
|
21
|
+
get(id: string): Promise<Job | null>;
|
|
22
|
+
/** Atomically insert only if absent. Return true iff inserted. */
|
|
23
|
+
create(job: Job): Promise<boolean>;
|
|
24
|
+
/** Atomically replace only if version matches. Return true iff replaced. */
|
|
25
|
+
compareAndSet(id: string, expectedVersion: number, job: Job): Promise<boolean>;
|
|
26
|
+
}
|
|
27
|
+
export interface SettlementAdapter {
|
|
28
|
+
/** MUST persistently deduplicate idempotencyKey and return the same reference on retry. */
|
|
29
|
+
settle(input: { job: Job; idempotencyKey: string }): Promise<{ reference: string }>;
|
|
30
|
+
}
|
|
31
|
+
export interface HandlerOptions {
|
|
32
|
+
store: StateStore;
|
|
33
|
+
/** Return the authenticated actor's agent ID, or null to reject. Never trust a caller-supplied ID alone. */
|
|
34
|
+
authenticate(request: Request): Promise<string | null> | string | null;
|
|
35
|
+
settlement?: SettlementAdapter;
|
|
36
|
+
/** Default /api/a423; no trailing slash. */
|
|
37
|
+
basePath?: string;
|
|
38
|
+
/** Default 65536; max 1048576. Enforced while streaming, not just through Content-Length. */
|
|
39
|
+
maxBodyBytes?: number;
|
|
40
|
+
/** Receives internal errors for your server logs. Errors are not exposed in HTTP responses. */
|
|
41
|
+
onError?(error: unknown): void | Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
export class A423Error extends Error {
|
|
44
|
+
code: string;
|
|
45
|
+
status: number;
|
|
46
|
+
constructor(code: string, message: string, status?: number);
|
|
47
|
+
}
|
|
48
|
+
/** Development/testing only. Does not persist across processes, restarts or serverless instances. */
|
|
49
|
+
export function createMemoryStore(): StateStore;
|
|
50
|
+
export function createA423Handler(options: HandlerOptions): (request: Request) => Promise<Response>;
|
|
51
|
+
export interface ClientOptions {
|
|
52
|
+
/** Full API root, e.g. https://example.com/api/a423. */
|
|
53
|
+
baseUrl: string;
|
|
54
|
+
headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
|
|
55
|
+
fetch?: typeof globalThis.fetch;
|
|
56
|
+
/** Default 30000. No implicit retries. */
|
|
57
|
+
timeoutMs?: number;
|
|
58
|
+
}
|
|
59
|
+
export interface A423Client {
|
|
60
|
+
offer(input: OfferInput, options: { idempotencyKey: string }): Promise<Job>;
|
|
61
|
+
getJob(id: string): Promise<Job>;
|
|
62
|
+
accept(id: string): Promise<Job>;
|
|
63
|
+
submit(id: string, input: SubmissionInput): Promise<Job>;
|
|
64
|
+
approve(id: string): Promise<Job>;
|
|
65
|
+
receipts(id: string): Promise<Receipts>;
|
|
66
|
+
}
|
|
67
|
+
export function createA423Client(options: ClientOptions): A423Client;
|
package/src/index.mjs
ADDED
package/src/workflow.cjs
ADDED
|
@@ -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;
|
package/src/workflow.mjs
ADDED