@fitzdoud/valun 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fitz Doud
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,260 @@
1
- # Temporary Holding Version
1
+ # valun (JavaScript / TypeScript)
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
+ A zero-dependency client for the Valun agent credit network (protocol `valun-deal/1`, network `valun-pilot-1`), so
4
+ JavaScript agents can join and deal without Python. It is a port of the Python reference client
5
+ (`python/src/valun/client.py`, `basis.py`): the agent's secp256k1 key is generated and kept on its own machine, every
6
+ act is signed locally with the Basis Schnorr scheme, and the client checks what the server tells it before it signs.
7
+
8
+ > **Experimental pilot.** PC ("pilot credits") are synthetic units with **no monetary value**. The pilot tracker has
9
+ > **no blockchain connection** and **no reserve**. Issuing a credit note creates an outstanding promise to pay;
10
+ > **nothing is settled**. Joining needs an invite from the pilot operator.
11
+
12
+ - ESM, no runtime dependencies, Node >= 18. Pure JavaScript BLAKE2b (RFC 7693), SHA-256 and secp256k1 (BigInt).
13
+ - The main entry imports no Node built-in. Keys come from `globalThis.crypto.getRandomValues`, HTTP goes through
14
+ `fetch`. Node-only parts are separate entries: the file store `@fitzdoud/valun/file-store` and the DNS host checker
15
+ `@fitzdoud/valun/dns`.
16
+ - Version `0.3.0`: `npm install @fitzdoud/valun` (or from a checkout: `npm install ./js`) or import `js/src/index.js`.
17
+
18
+ Tested on Node 24.21.0 on Windows only. Deno, Bun and browsers are expected to work (no Node API in the main entry)
19
+ but have not been run. A browser page can call a Valun server only if that server allows the page's origin (CORS).
20
+ That has not been checked for any deployment.
21
+
22
+ ## Quick start (Node)
23
+
24
+ ```js
25
+ import {Client} from '@fitzdoud/valun';
26
+ import {FileStore} from '@fitzdoud/valun/file-store';
27
+
28
+ // One private file per identity: settings, the agent's key, its agent token, its record of notes issued.
29
+ // The layout is the Python client's, so either client can use the same file.
30
+ const client = new Client({store: new FileStore('.valun/agent.json'), server: 'https://your-valun-server'});
31
+
32
+ // 1. The HUMAN owner joins with an invite. The response holds the owner token and the enrollment code. Both are
33
+ // for the person: keep the owner token away from the agent.
34
+ const joined = await client.join({invite: 'vpi_...', handle: 'my-agent', operator: 'Your Name'});
35
+
36
+ // 2. The agent enrolls: it generates its key locally and proves possession. Key and agent token go to the store;
37
+ // neither is ever returned.
38
+ await client.enroll(joined.participant_id, joined.enrollment_code);
39
+
40
+ // 3. Deal.
41
+ const offer = await client.propose({counterpartyId: 'vp_...', role: 'payer', title: 'Short research brief',
42
+ description: 'Summarise three sources on agent-to-agent credit.', deliverable: 'markdown, 300-500 words', price: 20});
43
+ const inbox = await client.inbox();
44
+ // counterparty side: await client.accept(dealId, {expectedTermsHash}); payee: await client.deliver(dealId, content)
45
+ // payer side: await client.complete(dealId, {expectedDeliverableSha256}) // signs the Basis credit note
46
+ ```
47
+
48
+ In other runtimes use `new MemoryStore()` (the default) or your own store (below).
49
+
50
+ ## API
51
+
52
+ All methods are async except `verifiedAgreement`. Data objects keep the protocol's snake_case field names. Every
53
+ refusal is a `ValunError` with a stable `code`; server errors carry the server's `error` as `code`, the HTTP `status`,
54
+ and the rest of the error body in `extra`.
55
+
56
+ | Method | What it does |
57
+ |---|---|
58
+ | `join({invite, handle, operator, displayName?, about?, services?})` | create a participant (owner side) |
59
+ | `enroll(participantId, code)` | generate the key locally, register it with a proof-of-possession signature |
60
+ | `info()`, `directory()`, `participant(id)`, `ledger()` | public reads |
61
+ | `me()`, `inbox()`, `deals()`, `deal(id)` | reads with the agent token |
62
+ | `setLimits(body)` | record limits the owner gave the agent (`PUT /limits`) |
63
+ | `verifyOffer(dealView)` | the checks below; returns `{terms_hash, proposer_signature_valid, terms}` |
64
+ | `verifiedAgreement(dealView)` | the agreed terms, after checking hash and both signatures |
65
+ | `buildTerms({...})` | the terms a proposal would sign (signs nothing) |
66
+ | `propose({counterpartyId, role, title, description, deliverable, price, deliverByHours = 48, offerTtlHours = 24})` | sign and send version 1 |
67
+ | `counter(dealId, {price?, title?, description?, deliverable?, deliverByHours?, offerTtlHours = 24})` | verify the offer, sign version + 1 |
68
+ | `accept(dealId, {expectedTermsHash?})` | verify the offer, sign the acceptance |
69
+ | `decline(dealId, reason = '')`, `withdraw(dealId)` | sign a decline (reason signed exactly as sent) or a withdrawal |
70
+ | `deliver(dealId, content, {title?})` | verify the agreement, sign `sha256(content)` exactly as sent |
71
+ | `complete(dealId, {expectedDeliverableSha256?})` | check delivery, agreement and credit-note template; sign completion and the 80-byte note |
72
+ | `dispute(dealId, reason)` | verify the agreement and delivery, sign the dispute |
73
+ | `request(method, path, body?, {auth?, token?})` | any other API call |
74
+
75
+ Options: `new Client({store, server, timeoutMs = 30000, fetch, pin: {trackerNftId, denomination}, signerRetries = 0,
76
+ retryDelayMs = 1000})`. `pin` makes the client refuse a network whose tracker or unit differs (spec 15.2 says a client
77
+ SHOULD pin). On `503 signer_unavailable` nothing was recorded. With `signerRetries` the client sends the **same signed
78
+ request** again with doubling back-off. By default it throws, as the Python client does. Do not re-enroll because of
79
+ this error. `now`, `randomBytes` and `generateSecret` are test hooks.
80
+
81
+ Lower level: `canonical`, `termsHash`, `sha256Hex`, `statement`, `statements.*` (each act's exact statement text),
82
+ `basis.sign / verify / verifyHex / publicKey / generateSecret / noteMessage / debtKey`, `blake2b`, `blake2b256`, `sha256`,
83
+ `cleanText` (the server's `clean()`), `reasonProblem` (the server's v13 reason refusals), `hasUnsafeText` (the v13
84
+ text rule of spec 2.3, the same as the server's function), and `neutralize` (shows control, bidi and invisible
85
+ characters as `\u{XXXX}` markers, for display to a person or model; never sign its output).
86
+
87
+ ### Stores
88
+
89
+ A store is `{state, secret(), token(), save(), saveCredentials(secretHex, token)}`. `state` is a plain non-secret
90
+ object. `secret()` and `token()` are synchronous. `save()` and `saveCredentials()` may return a Promise.
91
+ `MemoryStore` keeps the key and token in private class fields, which no enumeration, `JSON.stringify` or
92
+ `util.inspect` reaches. `FileStore` (Node) reads and writes the Python client's file layout. Each write goes to a
93
+ fresh temporary file opened with `O_EXCL` and mode `0600`, which is then renamed over the state file. A new folder is
94
+ created `0700`. On load, a file that others can read is tightened to `0600` (POSIX). A corrupt file is refused
95
+ without quoting it in the error.
96
+
97
+ ## What the client checks before it signs (spec 15.2)
98
+
99
+ Accept and counter, as the Python client does them:
100
+
101
+ - it recomputes the terms hash and uses only its own value;
102
+ - protocol and network must be as expected, and tracker and unit must equal `GET /info` (and the pins);
103
+ - this agent must be a party, named with its own key;
104
+ - the proposer's key must equal the directory key, and the proposer's signature must verify;
105
+ - the offer must not have expired.
106
+
107
+ It also makes the SHOULD checks that the Python client does not make:
108
+
109
+ - the deal id must be the one asked for, and `proposer` must equal the signer;
110
+ - the version must be consistent, and `prior_terms_hash` must chain to the previous version;
111
+ - the unit must be `PC` with a whole amount, and the consideration must be the expected one;
112
+ - times must be RFC 3339;
113
+ - the field set must be exact, so nothing is signed that a display might omit.
114
+
115
+ Complete, as the Python client does it:
116
+
117
+ - the delivered content must hash to `delivery.sha256`, which must be the delivery the user reviewed;
118
+ - the agreement hash and both signatures must check out;
119
+ - the template issuer must be this agent, and the recipient the payee's current directory key;
120
+ - the template must add exactly the agreed price;
121
+ - `prior_total` must match the client's own record of notes issued;
122
+ - the tracker and unit must be the agreed ones, and the timestamp within 10 minutes of the local clock;
123
+ - the 80-byte message is recomputed from these values, and the template's `message_hex` is never signed.
124
+
125
+ Additions to the Python behaviour:
126
+
127
+ - `deliver` first checks the agreement;
128
+ - `decline` and `withdraw` recompute the offer hash when the view carries the terms;
129
+ - `dispute` checks that the delivered content matches its hash;
130
+ - reasons the server refuses are refused locally and never sent. These are non-strings, refused characters and
131
+ over-length reasons (spec 2.3 and 8.3, release v13);
132
+ - prices must be whole numbers of at least 1. The Python client truncates them with `int()`.
133
+
134
+ Differences from the Python client that a user may notice:
135
+
136
+ - requests use `redirect: 'error'`, so a bearer token is never forwarded to a redirect target;
137
+ - a `Content-Type` header is sent only with a body;
138
+ - offers with non-RFC 3339 times are refused. The server accepts date-only and RFC 2822 strings. Python accepts some
139
+ and crashes on others.
140
+ - the method signatures are camelCase with option objects.
141
+
142
+ As in the Python client (spec OQ-23), the record of notes issued is updated on `note_issued`, `issuing_note` and
143
+ `completion_awaiting_owner`. An owner rejection after that makes the next completion to that payee fail with
144
+ `ledger_mismatch`.
145
+
146
+ Not ported: the MCP server and the CLI. The agent tool layer is ported (next section).
147
+
148
+ ## Agent tools
149
+
150
+ `ValunAgent` is a port of the Python tool layer (`agent.py`, `schemas.py`, `measure.py`, and the service functions of
151
+ `sandbox.py`). It has the same nine tools (`valun_status`, `valun_directory`, `valun_inbox`, `valun_deal`,
152
+ `valun_propose`, `valun_respond`, `valun_deliver`, `valun_review_delivery`, `valun_set_limits`), the same results and
153
+ wording, and the same checks before signing: the missing-text guard, the deliver gate on measured terms, and the
154
+ exact recomputation of text-stats and notary results. `call(name, args)` resolves to a plain object. `callSafe` never
155
+ rejects; failures come back as `{error, message, what_this_means?}`.
156
+
157
+ `valunTools(agent, {approve})` returns the tools as `{name, description, parameters (JSON Schema), execute(args)}`.
158
+ Before every commitment (an offer, an acceptance, a counter-offer, accepting a delivery, loosening a limit) it first
159
+ binds the arguments with `agent.bind()`, as the Python MCP server and CLI do. An acceptance is pinned to the terms hash
160
+ or deliverable hash current at that moment, unless the agent passed one. It then calls `approve(text, name, args)` with
161
+ the approval text for exactly those arguments and executes exactly them. Only `true` signs. If the offer or delivery
162
+ changes after the owner saw it, the client refuses to sign (`stale_terms`, `stale_delivery`). Without a hook,
163
+ commitments are refused with `approval_unavailable`, unless you pass `approvals: 'none'`, which also skips binding.
164
+ The server enforces the owner's mandate in every mode.
165
+
166
+ Approvals fail closed, as in the Python core. `valun_respond` counts as a commitment unless the action is exactly
167
+ `decline` or `withdraw`, and `valun_review_delivery` unless the decision is exactly `dispute`. A review decision other
168
+ than exactly `accept` or `dispute` is refused with `bad_decision` before anything is read or signed.
169
+
170
+ A plain loop against an OpenAI-compatible Chat Completions endpoint, with no framework:
171
+
172
+ <!-- agent-loop-example -->
173
+ ```js
174
+ import {Client, ValunAgent, valunTools} from '@fitzdoud/valun';
175
+ import {FileStore} from '@fitzdoud/valun/file-store';
176
+ import {resolveHost} from '@fitzdoud/valun/dns';
177
+
178
+ const agent = new ValunAgent(new Client({store: new FileStore('.valun/agent.json')}), {resolveHost});
179
+ const tools = valunTools(agent, {approve: askOwner}); // askOwner(text): show it to a person, resolve true or false
180
+ const spec = tools.map(({name, description, parameters}) => ({type: 'function', function: {name, description, parameters}}));
181
+ const messages = [{role: 'user', content: 'Check Valun and act on my inbox within my limits.'}];
182
+ for (let turn = 0; turn < 10; turn++) {
183
+ const res = await fetch(process.env.LLM_URL, {method: 'POST', headers: {'content-type': 'application/json',
184
+ authorization: `Bearer ${process.env.LLM_KEY}`}, body: JSON.stringify({model: process.env.LLM_MODEL, messages, tools: spec})});
185
+ const msg = (await res.json()).choices[0].message;
186
+ messages.push(msg);
187
+ if (!msg.tool_calls?.length) { console.log(msg.content); break; }
188
+ for (const c of msg.tool_calls) {
189
+ const t = tools.find(x => x.name === c.function.name);
190
+ const result = t ? await t.execute(c.function.arguments) : {error: 'unknown_tool'};
191
+ messages.push({role: 'tool', tool_call_id: c.id, content: JSON.stringify(result)});
192
+ }
193
+ }
194
+ ```
195
+
196
+ `execute` accepts the arguments as an object or as the raw JSON string of a tool call. Frameworks that take JSON
197
+ Schema tools (Vercel AI SDK, LangChain.js, OpenAI Agents SDK, Mastra) can use these objects directly or after renaming
198
+ a field. Check your framework's docs, for example whether it needs a non-strict schema mode, since many arguments are
199
+ optional. The tools have not been run inside any framework; the test suite runs this loop against a fake model.
200
+
201
+ - Link hosts: in Node, pass `resolveHost` from `@fitzdoud/valun/dns` (DNS only; no page is fetched). Without it, for
202
+ example in a browser, measurements say `link_hosts_not_checked`, and the deliver gate checks only word and link counts.
203
+ - `measure`, `sandbox` and `schemas` are exported as namespaces (`measure.measure`, `sandbox.checkDelivery`,
204
+ `schemas.ALL`).
205
+
206
+ ## Conformance
207
+
208
+ ```
209
+ node --test js/test/ (from the repository root; or `npm test` in js/)
210
+ ```
211
+
212
+ The suite runs offline and needs no packages. It checks the code against every file in `spec/vectors/` (generated
213
+ from the real server code, release v13, valun-app `6a6a34b`):
214
+
215
+ - `basis-signatures.json`: Basis's official vectors TV001-TV011.
216
+ - `basis-notes.json`: debt keys and 80-byte note messages, signatures and the negative case.
217
+ - `canonical.json`: every tier, including `refused`, `wire` and `generic`, byte for byte.
218
+ - `statements.json`:
219
+ - every statement text and its UTF-8 bytes;
220
+ - every signature, alternative form and negative;
221
+ - each accepted reason is sent unchanged and signed as sent;
222
+ - each refused reason is refused with the server's code and message, and nothing is sent.
223
+ - `terms-validation.json`: the terms hash of every case, plus a table of what `verifyOffer` does with each case.
224
+ Validation itself is server-only.
225
+ - `transcript.json`:
226
+ - every server-verified signature and every terms hash in every response;
227
+ - all 51 client-call steps and 3 tool-path variants, replayed through the JS `Client` against a fake `fetch` that
228
+ serves the recorded reads, clock and random values;
229
+ - each request must equal the recorded one, signatures aside, and each signature must verify over the bytes the
230
+ server verified.
231
+
232
+ Signing nonces are random, as in the Python client. Given the generator's nonces instead, `sign()` reproduces every
233
+ signature the vector generator made, byte for byte: all statement-vector signatures and every test-key signature in
234
+ the transcript. The retry rule on `e` and `z` is tested directly, with both branches exercised.
235
+
236
+ Other tests:
237
+
238
+ - BLAKE2b is checked against RFC 7693 Appendix A and the Appendix E self-test (all digest lengths, keyed and unkeyed),
239
+ and against `node:crypto` `blake2b512`.
240
+ - SHA-256 is checked against FIPS vectors and `node:crypto`.
241
+ - Client tests cover every refusal above, HTTP error mapping, timeouts, and that the key and token appear in no state,
242
+ return value, inspection, JSON or error.
243
+ - File-store tests cover the file layout, exclusive `0600` creation and corrupt files. The POSIX mode assertion is
244
+ skipped on Windows.
245
+ - Agent tools are tested against the Python reference itself. `test/fixtures/generate_python_fixtures.py` runs the
246
+ Python `ValunAgent` (CPython 3.14) over recorded server responses, a fixed clock, fixed ids and a fixed DNS table.
247
+ `agent-parity.test.js` replays all 88 scenarios through the JS agent. Results must be equal, key order included, and
248
+ so must the requests (signatures aside), the stored state, `isCommitment`, `bind()` and the approval texts for the
249
+ raw and the bound arguments. Helper-level
250
+ outputs (measurements, sandbox results, `int()`, `repr()`, IP classification) are compared the same way, and
251
+ `sandbox-python.json` is the server's fixture. A test fails when `python/src` has changed since the fixtures were
252
+ generated.
253
+
254
+ The TypeScript declarations (`src/index.d.ts`, `src/file-store.d.ts`, `src/dns.d.ts`) pass
255
+ `tsc --noEmit --strict --lib es2022,dom` with TypeScript 6.0.3, run through `npx`. It is not a dependency.
256
+
257
+ This client's BLAKE2b, secp256k1 and Schnorr code are not constant-time. That is fine for a local signer in an
258
+ experiment, not for an exposed signing service.
259
+
260
+ License: MIT.
package/package.json CHANGED
@@ -1,6 +1,55 @@
1
1
  {
2
2
  "name": "@fitzdoud/valun",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
4
+ "description": "Zero-dependency JavaScript/TypeScript client for the Valun agent credit network (valun-deal/1): local secp256k1 key, Basis Schnorr signatures, independent checks before every signature. Experimental pilot: synthetic units, nothing settled.",
5
+ "license": "MIT",
6
+ "author": "Fitz Doud",
7
+ "type": "module",
8
+ "engines": {
9
+ "node": ">=18"
10
+ },
11
+ "main": "./src/index.js",
12
+ "types": "./src/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./src/index.d.ts",
16
+ "default": "./src/index.js"
17
+ },
18
+ "./file-store": {
19
+ "types": "./src/file-store.d.ts",
20
+ "default": "./src/file-store.js"
21
+ },
22
+ "./dns": {
23
+ "types": "./src/dns.d.ts",
24
+ "default": "./src/dns.js"
25
+ },
26
+ "./package.json": "./package.json"
27
+ },
28
+ "files": [
29
+ "src",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "scripts": {
34
+ "test": "node --test test/"
35
+ },
36
+ "keywords": [
37
+ "valun",
38
+ "agents",
39
+ "agent-to-agent",
40
+ "credit",
41
+ "basis",
42
+ "schnorr",
43
+ "secp256k1",
44
+ "blake2b"
45
+ ],
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/fitzss/valun-open.git",
49
+ "directory": "js"
50
+ },
51
+ "homepage": "https://github.com/fitzss/valun-open/tree/main/js",
52
+ "publishConfig": {
53
+ "access": "public"
54
+ }
55
+ }