@allorc/a423-sdk 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,245 @@
1
- # Temporary Holding Version
1
+ # @allorc/a423-sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 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.
4
+
5
+ **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.
6
+
7
+ ## Quick setup in Next.js
8
+
9
+ Until publication, install the local package into your existing Next.js application:
10
+
11
+ ```sh
12
+ npm install /absolute/path/to/servers/server/a423_sdk
13
+ ```
14
+
15
+ After publication: `npm install @allorc/a423-sdk`.
16
+
17
+ Create `lib/a423.ts`:
18
+
19
+ ```ts
20
+ import 'server-only';
21
+ import { createA423Handler, createMemoryStore } from '@allorc/a423-sdk';
22
+
23
+ // Development only. Replace with a durable StateStore before deployment.
24
+ const store = createMemoryStore();
25
+ const token = process.env.A423_API_TOKEN;
26
+ const actorId = process.env.A423_AGENT_ID;
27
+ if (!token || !actorId) throw new Error('Configure A423_API_TOKEN and A423_AGENT_ID');
28
+
29
+ export const a423 = createA423Handler({
30
+ store,
31
+ authenticate(request) {
32
+ return request.headers.get('authorization') === `Bearer ${token}`
33
+ ? actorId
34
+ : null;
35
+ }
36
+ });
37
+ ```
38
+
39
+ 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.
40
+
41
+ Create `app/api/a423/[...path]/route.ts`:
42
+
43
+ ```ts
44
+ import { a423 } from '@/lib/a423';
45
+
46
+ export const runtime = 'nodejs';
47
+ export const GET = a423;
48
+ export const POST = a423;
49
+ ```
50
+
51
+ 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.
52
+
53
+ 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.
54
+
55
+ ## Use the typed client
56
+
57
+ ```ts
58
+ import { createA423Client } from '@allorc/a423-sdk';
59
+
60
+ const requester = createA423Client({
61
+ baseUrl: 'https://your-app.example/api/a423',
62
+ headers: { authorization: `Bearer ${requesterToken}` }
63
+ });
64
+ const worker = createA423Client({
65
+ baseUrl: 'https://your-app.example/api/a423',
66
+ headers: { authorization: `Bearer ${workerToken}` }
67
+ });
68
+
69
+ const job = await requester.offer({
70
+ worker_id: workerAgentId,
71
+ title: 'Prepare a short brief',
72
+ description: 'Research the topic and return a deliverable link.',
73
+ price: { amount_minor: 500, currency: 'USD' }
74
+ }, { idempotencyKey: 'brief-request-001' });
75
+
76
+ await worker.accept(job.id);
77
+ await worker.submit(job.id, { deliverable: 'https://example.com/brief.md' });
78
+ await requester.approve(job.id);
79
+ const receipts = await requester.receipts(job.id);
80
+ ```
81
+
82
+ `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.
83
+
84
+ 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.
85
+
86
+ 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.
87
+
88
+ 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.
89
+
90
+ ## HTTP API
91
+
92
+ Every request requires successful `authenticate(request)`. POSTs use `Content-Type: application/json`.
93
+
94
+ | Method | Route | Body | Who can call |
95
+ | --- | --- | --- | --- |
96
+ | POST | `/api/a423/offers` | `{worker_id,title,description,price}` and `Idempotency-Key` header | Authenticated requester |
97
+ | GET | `/api/a423/jobs/:id` | None | Requester or assigned worker |
98
+ | POST | `/api/a423/jobs/:id/accept` | `{}` | Assigned worker |
99
+ | POST | `/api/a423/jobs/:id/submit` | `{deliverable}` | Assigned worker |
100
+ | POST | `/api/a423/jobs/:id/approve` | `{}` | Requester |
101
+ | GET | `/api/a423/jobs/:id/receipts` | None | Requester or assigned worker |
102
+
103
+ 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.
104
+
105
+ External agents can use plain HTTP without installing the SDK. For example:
106
+
107
+ ```sh
108
+ curl https://your-app.example/api/a423/offers \
109
+ -H "Authorization: Bearer $A423_REQUESTER_TOKEN" \
110
+ -H 'Content-Type: application/json' \
111
+ -H 'Idempotency-Key: brief-request-001' \
112
+ -d '{"worker_id":"worker-agent-id","title":"Brief","description":"Prepare a short brief","price":{"amount_minor":500,"currency":"USD"}}'
113
+ ```
114
+
115
+ 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.
116
+
117
+ ## Workflow and retry behaviour
118
+
119
+ A job follows `offered → accepted → submitted → completed`. With a settlement adapter it follows `submitted → settlement_pending → completed` during approval.
120
+
121
+ 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.
122
+
123
+ 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.
124
+
125
+ 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.
126
+
127
+ ## Storage adapter
128
+
129
+ `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.
130
+
131
+ Implement the `StateStore` interface against your database:
132
+
133
+ ```ts
134
+ interface StateStore {
135
+ get(id: string): Promise<Job | null>;
136
+ create(job: Job): Promise<boolean>;
137
+ compareAndSet(id: string, expectedVersion: number, job: Job): Promise<boolean>;
138
+ }
139
+ ```
140
+
141
+ `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.
142
+
143
+ 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.
144
+
145
+ ## Identity and authentication
146
+
147
+ `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.
148
+
149
+ `@allorc/identity` 0.3.0 can form a public agent ID:
150
+
151
+ ```ts
152
+ import { VERSION, createIdentity } from '@allorc/identity';
153
+
154
+ const identity = createIdentity({
155
+ version: VERSION,
156
+ organization_public_key: 'externally-supplied-public-value',
157
+ agent_reference: 'worker-42',
158
+ agent_name: 'Alice',
159
+ agent_email: 'alice@example.com'
160
+ });
161
+ // Register identity.agent_id with your credential/authority system separately.
162
+ ```
163
+
164
+ 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.
165
+
166
+ 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.
167
+
168
+ ## Optional settlement adapter
169
+
170
+ Without an adapter, approval marks the job completed with:
171
+
172
+ ```json
173
+ {"status":"not_configured","reference":null}
174
+ ```
175
+
176
+ **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`:
177
+
178
+ ```ts
179
+ const handler = createA423Handler({
180
+ store,
181
+ authenticate,
182
+ settlement: {
183
+ async settle({ job, idempotencyKey }) {
184
+ // Invoke your own escrow/payment service with this stable key.
185
+ // Persistently deduplicate that key, including concurrent invocations.
186
+ const result = await yourSettlementService.release({
187
+ jobId: job.id,
188
+ requesterId: job.requester_id,
189
+ workerId: job.worker_id,
190
+ price: job.price,
191
+ idempotencyKey
192
+ });
193
+ return { reference: result.receiptId };
194
+ }
195
+ }
196
+ });
197
+ ```
198
+
199
+ `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.
200
+
201
+ 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.
202
+
203
+ 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.
204
+
205
+ ## Errors and limits
206
+
207
+ 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.
208
+
209
+ | HTTP status | Typical code |
210
+ | --- | --- |
211
+ | 400 | `INVALID_INPUT`, `INVALID_JSON` |
212
+ | 401 | `UNAUTHENTICATED` |
213
+ | 403 | `FORBIDDEN` |
214
+ | 404 | `NOT_FOUND` |
215
+ | 405 | `METHOD_NOT_ALLOWED` |
216
+ | 409 | `INVALID_STATE`, `CONFLICT`, `IDEMPOTENCY_CONFLICT` |
217
+ | 413 | `BODY_TOO_LARGE` |
218
+ | 415 | `UNSUPPORTED_MEDIA_TYPE` |
219
+ | 500 | `INTERNAL_ERROR` |
220
+ | 502 | `SETTLEMENT_UNAVAILABLE` |
221
+
222
+ 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.
223
+
224
+ ## Package exports
225
+
226
+ | Export | Purpose |
227
+ | --- | --- |
228
+ | `createA423Handler(options)` | Standard Web Request/Response handler usable in Next.js routes |
229
+ | `createA423Client(options)` | Typed HTTP client |
230
+ | `createMemoryStore()` | Explicit local development store |
231
+ | `A423Error` | Structured client/protocol error |
232
+ | `PROTOCOL_VERSION` | `a423.experimental.v1` |
233
+
234
+ 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.
235
+
236
+ ## Development
237
+
238
+ ```sh
239
+ npm test
240
+ npm run pack:check
241
+ ```
242
+
243
+ 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.
244
+
245
+ 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,5 @@
1
+ import { a423 } from '@/lib/a423';
2
+
3
+ export const runtime = 'nodejs';
4
+ export const GET = a423;
5
+ export const POST = a423;
@@ -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,53 @@
1
1
  {
2
2
  "name": "@allorc/a423-sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.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
+ },
16
+ "files": [
17
+ "src/index.cjs",
18
+ "src/index.mjs",
19
+ "src/index.d.ts",
20
+ "examples/client.mjs",
21
+ "examples/nextjs/lib/a423.ts",
22
+ "examples/nextjs/app/api/a423/*/route.ts",
23
+ "README.md",
24
+ "LICENSE",
25
+ "SECURITY.md"
26
+ ],
27
+ "engines": {
28
+ "node": ">=22"
29
+ },
30
+ "scripts": {
31
+ "test": "node --test",
32
+ "pack:check": "npm pack --dry-run --ignore-scripts",
33
+ "prepublishOnly": "npm test"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "keywords": [
39
+ "nextjs",
40
+ "agent",
41
+ "a423",
42
+ "sdk",
43
+ "experimental"
44
+ ],
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/Allorc-official/A423-SDK.git"
48
+ },
49
+ "homepage": "https://github.com/Allorc-official/A423-SDK#readme",
50
+ "bugs": {
51
+ "url": "https://github.com/Allorc-official/A423-SDK/issues"
52
+ }
53
+ }
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
@@ -0,0 +1,2 @@
1
+ import sdk from './index.cjs';
2
+ export const { PROTOCOL_VERSION, A423Error, createMemoryStore, createA423Handler, createA423Client } = sdk;