@forgezero/runtime 0.1.1 → 0.1.3

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/README.md CHANGED
@@ -5,7 +5,7 @@ themselves, keyed queues, a transactional outbox, a hash-chained audit trail,
5
5
  templated mail, encrypted backups, schema validation, and money that is never a
6
6
  floating-point number.
7
7
 
8
- Twenty-six modules. Each is its own entry point, so you install one package and
8
+ Thirty-three public modules. Each is its own entry point, so you install one package and
9
9
  your bundler includes only what you imported.
10
10
 
11
11
  ```bash
@@ -24,7 +24,7 @@ import { createScheduler } from '@forgezero/runtime/jobs';
24
24
 
25
25
  | | |
26
26
  |---|---|
27
- | **Spine** | `jobs` `queue` `serial` `outbox` |
27
+ | **Spine** | `jobs` `queue` `outbox` |
28
28
  | **Record** | `audit` `backup` `compliance` `calendar` |
29
29
  | **Identity** | `identity` `totp` `notify` `notify/templates` `schema` `schema/typebox` |
30
30
  | **Finance** | `finance/money` `finance/storage` `finance/ledger` `finance/commission` `finance/rates` `finance/transfers` `finance/tax` `finance/chain` `finance/custody` `finance/derive` `finance/venues` `finance/binance` |
@@ -32,6 +32,29 @@ import { createScheduler } from '@forgezero/runtime/jobs';
32
32
  None of it knows what your product does. You bind your own rules to these; you
33
33
  do not fork them.
34
34
 
35
+ ## One reusable async queue
36
+
37
+ `queue` is deliberately memory-only: it accepts a function, returns that
38
+ function's value through a Promise, runs one key sequentially, and runs
39
+ different keys in parallel. Persistence and cluster ownership remain in the
40
+ business layer that knows what a pending request means; ForgeZero uses an
41
+ ArangoDB unique claim, while another caller may use any database or no database.
42
+
43
+ ```ts
44
+ import { createQueue } from '@forgezero/runtime/queue';
45
+
46
+ const queue = createQueue({ width: 8 });
47
+ const task = queue.run('tenant-a:wallet-7', transfer, amount, destination);
48
+ const receipt = await task.result;
49
+
50
+ queue.pauseKey('tenant-a:wallet-7');
51
+ queue.resumeKey('tenant-a:wallet-7');
52
+ queue.stopKey('tenant-a:wallet-7'); // running finishes; pending is rejected
53
+ queue.startKey('tenant-a:wallet-7');
54
+ queue.cancel(task.id); // pending task only
55
+ await queue.stop(30_000); // close intake and drain all work
56
+ ```
57
+
35
58
  ## Three things worth knowing before you use it
36
59
 
37
60
  **Money is never a number.** An amount is minor units as a `bigint` with its
@@ -81,9 +104,9 @@ node_modules/@forgezero/runtime/contracts/src/
81
104
  ColdVault.sol M-of-N EIP-712, signatures ordered by ascending signer
82
105
  ```
83
106
 
84
- Full documentation: **https://forgezero.net/docs/runtime**
107
+ Full documentation: **https://www.forgezero.net/docs/runtime**
85
108
 
86
109
  ## Licence
87
110
 
88
- MIT. Part of [ForgeZero](https://forgezero.net) — secrets, attested compute and
111
+ MIT. Part of [ForgeZero](https://www.forgezero.net) — secrets, attested compute and
89
112
  deploys — and usable entirely on its own, with no ForgeZero account.
package/dist/audit.js CHANGED
@@ -6,32 +6,271 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
6
6
  throw Error('Dynamic require of "' + x + '" is not supported');
7
7
  });
8
8
 
9
- // src/serial.ts
10
- function createKeyedSerial(options = {}) {
11
- const maxKeys = options.maxKeys ?? 1e4;
12
- const chains = new Map;
13
- return {
14
- run(key, work) {
15
- const previous = chains.get(key) ?? Promise.resolve();
16
- const next = previous.then(work, work);
17
- const settled = next.then(() => {
9
+ // src/queue.ts
10
+ class QueueStoppedError extends Error {
11
+ constructor() {
12
+ super("queue: stopped before this task could run");
13
+ this.name = "QueueStoppedError";
14
+ }
15
+ }
16
+
17
+ class QueueKeyStoppedError extends Error {
18
+ key;
19
+ constructor(key) {
20
+ super(`queue: key ${key} is stopped`);
21
+ this.key = key;
22
+ this.name = "QueueKeyStoppedError";
23
+ }
24
+ }
25
+
26
+ class TaskCancelledError extends Error {
27
+ constructor() {
28
+ super("queue: task cancelled");
29
+ this.name = "TaskCancelledError";
30
+ }
31
+ }
32
+ var DEFAULT_RETRY = {
33
+ attempts: 1,
34
+ backoffMs: (attempt) => Math.min(30000, 2 ** attempt * 100)
35
+ };
36
+ function createQueue(options = {}) {
37
+ const width = options.width ?? 8;
38
+ const retry = { ...DEFAULT_RETRY, ...options.retry };
39
+ if (!Number.isSafeInteger(width) || width < 1) {
40
+ throw new RangeError("queue: width must be a positive integer");
41
+ }
42
+ if (!Number.isSafeInteger(retry.attempts) || retry.attempts < 1) {
43
+ throw new RangeError("queue: retry attempts must be a positive integer");
44
+ }
45
+ const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
46
+ const lanes = new Map;
47
+ const running = new Set;
48
+ const paused = new Set;
49
+ const stoppedKeys = new Set;
50
+ let sequence = 0;
51
+ let globallyPaused = false;
52
+ let accepting = true;
53
+ let aborted = false;
54
+ let completed = 0;
55
+ let failed = 0;
56
+ const idle = [];
57
+ const announceIdle = () => {
58
+ if (running.size > 0)
59
+ return;
60
+ for (const lane of lanes.values())
61
+ if (lane.length > 0)
18
62
  return;
19
- }, () => {
63
+ while (idle.length > 0)
64
+ idle.shift()();
65
+ };
66
+ function pump() {
67
+ if (globallyPaused || aborted) {
68
+ announceIdle();
69
+ return;
70
+ }
71
+ for (const [key, lane] of lanes) {
72
+ if (running.size >= width)
73
+ break;
74
+ if (running.has(key) || paused.has(key) || lane.length === 0)
75
+ continue;
76
+ execute(key);
77
+ }
78
+ announceIdle();
79
+ }
80
+ async function execute(key) {
81
+ running.add(key);
82
+ try {
83
+ const lane = lanes.get(key);
84
+ const entry = lane?.[0];
85
+ if (entry && !globallyPaused && !paused.has(key) && !aborted) {
86
+ lane.shift();
87
+ await attempt(entry);
88
+ }
89
+ } finally {
90
+ running.delete(key);
91
+ const remaining = lanes.get(key);
92
+ if (remaining?.length === 0)
93
+ lanes.delete(key);
94
+ else if (remaining) {
95
+ lanes.delete(key);
96
+ lanes.set(key, remaining);
97
+ }
98
+ pump();
99
+ }
100
+ }
101
+ async function attempt(entry) {
102
+ if (entry.cancelled) {
103
+ entry.reject(new TaskCancelledError);
104
+ return;
105
+ }
106
+ for (;; ) {
107
+ entry.attempt += 1;
108
+ try {
109
+ const value = await entry.run();
110
+ completed += 1;
111
+ entry.resolve(value);
20
112
  return;
113
+ } catch (cause) {
114
+ if (entry.attempt >= retry.attempts || entry.cancelled || aborted) {
115
+ failed += 1;
116
+ entry.reject(cause);
117
+ return;
118
+ }
119
+ const delay = retry.backoffMs(entry.attempt);
120
+ if (!Number.isFinite(delay) || delay < 0) {
121
+ failed += 1;
122
+ entry.reject(new RangeError("queue: retry backoff must be a non-negative finite number"));
123
+ return;
124
+ }
125
+ await sleep(delay);
126
+ }
127
+ }
128
+ }
129
+ return {
130
+ run(key, handler, ...args) {
131
+ const id = `q_${++sequence}`;
132
+ if (!accepting || stoppedKeys.has(key)) {
133
+ const refused = Promise.reject(accepting ? new QueueKeyStoppedError(key) : new QueueStoppedError);
134
+ refused.catch(() => {
135
+ return;
136
+ });
137
+ return { id, key, result: refused };
138
+ }
139
+ let resolve;
140
+ let reject;
141
+ const result = new Promise((ok, no) => {
142
+ resolve = ok;
143
+ reject = no;
21
144
  });
22
- chains.set(key, settled);
23
- settled.then(() => {
24
- if (chains.get(key) === settled)
25
- chains.delete(key);
26
- });
27
- if (chains.size > maxKeys) {
28
- console.warn(`[serial] ${chains.size} keys in flight, above the ${maxKeys} guideline.`);
145
+ const entry = {
146
+ id,
147
+ key,
148
+ run: async () => handler(...args),
149
+ resolve,
150
+ reject,
151
+ attempt: 0,
152
+ cancelled: false
153
+ };
154
+ const lane = lanes.get(key);
155
+ if (lane)
156
+ lane.push(entry);
157
+ else
158
+ lanes.set(key, [entry]);
159
+ pump();
160
+ return { id, key, result };
161
+ },
162
+ cancel(id) {
163
+ for (const [key, lane] of lanes) {
164
+ const index = lane.findIndex((entry2) => entry2.id === id);
165
+ if (index === -1)
166
+ continue;
167
+ const [entry] = lane.splice(index, 1);
168
+ entry.cancelled = true;
169
+ entry.reject(new TaskCancelledError);
170
+ if (lane.length === 0 && !running.has(key))
171
+ lanes.delete(key);
172
+ announceIdle();
173
+ return true;
174
+ }
175
+ return false;
176
+ },
177
+ pauseKey(key) {
178
+ paused.add(key);
179
+ },
180
+ resumeKey(key) {
181
+ paused.delete(key);
182
+ pump();
183
+ },
184
+ stopKey(key) {
185
+ stoppedKeys.add(key);
186
+ paused.delete(key);
187
+ const lane = lanes.get(key);
188
+ if (!lane)
189
+ return 0;
190
+ let removed = 0;
191
+ for (const entry of lane.splice(0)) {
192
+ removed += 1;
193
+ entry.cancelled = true;
194
+ entry.reject(new QueueKeyStoppedError(key));
195
+ }
196
+ if (!running.has(key))
197
+ lanes.delete(key);
198
+ announceIdle();
199
+ return removed;
200
+ },
201
+ startKey(key) {
202
+ const changed = stoppedKeys.delete(key);
203
+ pump();
204
+ return changed;
205
+ },
206
+ pause() {
207
+ globallyPaused = true;
208
+ },
209
+ resume() {
210
+ globallyPaused = false;
211
+ pump();
212
+ },
213
+ snapshot() {
214
+ let queued = 0;
215
+ for (const lane of lanes.values())
216
+ queued += lane.length;
217
+ return {
218
+ running: running.size,
219
+ queued,
220
+ keys: lanes.size,
221
+ paused: globallyPaused,
222
+ pausedKeys: [...paused],
223
+ stoppedKeys: [...stoppedKeys],
224
+ completed,
225
+ failed
226
+ };
227
+ },
228
+ whenIdle() {
229
+ if (running.size === 0 && [...lanes.values()].every((lane) => lane.length === 0)) {
230
+ return Promise.resolve();
29
231
  }
30
- return next;
232
+ return new Promise((resolve) => idle.push(resolve));
31
233
  },
32
- size: () => chains.size,
33
- drain: async () => {
34
- await Promise.allSettled([...chains.values()]);
234
+ async stop(deadlineMs = 30000) {
235
+ if (!Number.isSafeInteger(deadlineMs) || deadlineMs < 0) {
236
+ throw new RangeError("queue: stop deadline must be a non-negative integer");
237
+ }
238
+ accepting = false;
239
+ const before = { completed, failed };
240
+ globallyPaused = false;
241
+ paused.clear();
242
+ pump();
243
+ let timedOut = false;
244
+ let deadlineHandle;
245
+ const deadline = new Promise((resolve) => {
246
+ deadlineHandle = setTimeout(() => {
247
+ timedOut = true;
248
+ resolve();
249
+ }, deadlineMs);
250
+ });
251
+ await Promise.race([this.whenIdle(), deadline]);
252
+ if (!timedOut && deadlineHandle !== undefined)
253
+ clearTimeout(deadlineHandle);
254
+ if (timedOut)
255
+ aborted = true;
256
+ let abandoned = 0;
257
+ for (const lane of lanes.values()) {
258
+ abandoned += lane.length;
259
+ for (const entry of lane.splice(0))
260
+ entry.reject(new QueueStoppedError);
261
+ }
262
+ abandoned += running.size;
263
+ for (const [key, lane] of lanes) {
264
+ if (lane.length === 0 && !running.has(key))
265
+ lanes.delete(key);
266
+ }
267
+ announceIdle();
268
+ return {
269
+ completed: completed - before.completed,
270
+ failed: failed - before.failed,
271
+ abandoned,
272
+ timedOut
273
+ };
35
274
  }
36
275
  };
37
276
  }
@@ -201,8 +440,8 @@ async function verifyExport(slice, options = {}) {
201
440
  }
202
441
  function createAuditChain(options) {
203
442
  const digester = options.digester ?? hashDigester;
204
- const serial = createKeyedSerial();
205
- const enqueue = (realm, run) => serial.run(realm ?? "\x00platform", run);
443
+ const queue = createQueue();
444
+ const enqueue = (realm, run) => queue.run(realm ?? "\x00platform", run).result;
206
445
  return {
207
446
  append: (entry) => enqueue(entry.realm, () => appendRecord(options.store, entry, { ...options, digester })),
208
447
  verify: async (realm, fromSequence = 1, toSequence = Number.MAX_SAFE_INTEGER) => {
@@ -64,6 +64,28 @@ export interface SignedEnvelope {
64
64
  edSignature: string;
65
65
  mlDsaSignature: string;
66
66
  }
67
+ export interface ResponseRecipient {
68
+ publicKey: string;
69
+ secretKey: string;
70
+ }
71
+ export interface SealedResponse {
72
+ version: 1;
73
+ kemCiphertext: string;
74
+ nonce: string;
75
+ ciphertext: string;
76
+ }
77
+ export declare const RESPONSE_KEY_HEADER = "x-fz-response-key";
78
+ export declare function validResponsePublicKey(value: string): boolean;
79
+ /** One ephemeral hybrid ML-KEM-768 + X25519 recipient per request. */
80
+ export declare function generateResponseRecipient(): ResponseRecipient;
81
+ /** Seal one JSON response so TLS termination never sees the secret payload. */
82
+ export declare function sealResponse<T>(recipientPublicKey: string, requestBinding: string, payload: T): Promise<SealedResponse>;
83
+ /** Open a response only with the request's ephemeral private half and exact signature binding. */
84
+ export declare function openResponse<T>(recipientSecretKey: string, requestBinding: string, envelope: SealedResponse): Promise<T>;
85
+ /** One wire encoding for Agent and external API-key hybrid signatures. */
86
+ export declare function encodeSignatureHeader(envelope: SignedEnvelope): string;
87
+ /** Parse the common wire encoding and bind its public key id from the companion header. */
88
+ export declare function decodeSignatureHeader(raw: string, nodeKey: string): SignedEnvelope | null;
67
89
  /**
68
90
  * The bytes that get signed.
69
91
  *
@@ -79,12 +101,14 @@ export declare function canonicalString(args: {
79
101
  timestamp: number;
80
102
  nonce: string;
81
103
  body: string | Uint8Array;
104
+ responseKey?: string;
82
105
  }): string;
83
106
  export declare function signRequest(keys: NodeKeyPair, nodeKey: string, args: {
84
107
  method: string;
85
108
  path: string;
86
109
  query?: string;
87
110
  body: string | Uint8Array;
111
+ responseKey?: string;
88
112
  }): SignedEnvelope;
89
113
  export type VerifyFailure = 'timestamp_out_of_window' | 'ed25519_invalid' | 'ml_dsa_invalid' | 'malformed';
90
114
  /** Requests older or newer than this are refused before any signature work. */
@@ -106,6 +130,7 @@ export declare function verifyRequest(args: {
106
130
  path: string;
107
131
  query?: string;
108
132
  body: string | Uint8Array;
133
+ responseKey?: string;
109
134
  nowSeconds?: number;
110
135
  }): {
111
136
  verified: true;
package/dist/identity.js CHANGED
@@ -10,6 +10,7 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
10
10
  import { toBase64Url, fromBase64Url } from "@forgezero/access/security";
11
11
  import { ed25519 } from "@noble/curves/ed25519.js";
12
12
  import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
13
+ import { ml_kem768_x25519 } from "@noble/post-quantum/hybrid.js";
13
14
  import { sha256 } from "@noble/hashes/sha2.js";
14
15
  import { hkdf } from "@noble/hashes/hkdf.js";
15
16
  var ENCODER = new TextEncoder;
@@ -41,17 +42,104 @@ function generateNodeKeys() {
41
42
  mlDsa: { publicKey: b64(mlKeys.publicKey), secretKey: b64(mlKeys.secretKey) }
42
43
  };
43
44
  }
45
+ var RESPONSE_KEY_HEADER = "x-fz-response-key";
46
+ function validResponsePublicKey(value) {
47
+ try {
48
+ return un64(value).length === ml_kem768_x25519.lengths.publicKey;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+ function generateResponseRecipient() {
54
+ const pair = ml_kem768_x25519.keygen();
55
+ return { publicKey: b64(pair.publicKey), secretKey: b64(pair.secretKey) };
56
+ }
57
+ var responseKey = (sharedSecret) => hkdf(sha256, sharedSecret, undefined, ENCODER.encode("forgezero/response/ml-kem-768+x25519/v1"), 32);
58
+ async function sealResponse(recipientPublicKey, requestBinding, payload) {
59
+ const { cipherText, sharedSecret } = ml_kem768_x25519.encapsulate(un64(recipientPublicKey));
60
+ const rawKey = responseKey(sharedSecret);
61
+ sharedSecret.fill(0);
62
+ const key = await crypto.subtle.importKey("raw", new Uint8Array(rawKey), "AES-GCM", false, ["encrypt"]);
63
+ rawKey.fill(0);
64
+ const nonce = randomBytes(12);
65
+ const serialized = JSON.stringify(payload);
66
+ if (serialized === undefined)
67
+ throw new Error("response: payload is not JSON serializable");
68
+ const plaintext = ENCODER.encode(serialized);
69
+ const ciphertext = await crypto.subtle.encrypt({
70
+ name: "AES-GCM",
71
+ iv: new Uint8Array(nonce),
72
+ additionalData: new Uint8Array(ENCODER.encode(requestBinding)),
73
+ tagLength: 128
74
+ }, key, new Uint8Array(plaintext));
75
+ plaintext.fill(0);
76
+ return {
77
+ version: 1,
78
+ kemCiphertext: b64(cipherText),
79
+ nonce: b64(nonce),
80
+ ciphertext: b64(new Uint8Array(ciphertext))
81
+ };
82
+ }
83
+ async function openResponse(recipientSecretKey, requestBinding, envelope) {
84
+ if (envelope?.version !== 1)
85
+ throw new Error("response: unsupported sealed response");
86
+ const sharedSecret = ml_kem768_x25519.decapsulate(un64(envelope.kemCiphertext), un64(recipientSecretKey));
87
+ const rawKey = responseKey(sharedSecret);
88
+ sharedSecret.fill(0);
89
+ const key = await crypto.subtle.importKey("raw", new Uint8Array(rawKey), "AES-GCM", false, ["decrypt"]);
90
+ rawKey.fill(0);
91
+ const decrypted = new Uint8Array(await crypto.subtle.decrypt({
92
+ name: "AES-GCM",
93
+ iv: new Uint8Array(un64(envelope.nonce)),
94
+ additionalData: new Uint8Array(ENCODER.encode(requestBinding)),
95
+ tagLength: 128
96
+ }, key, new Uint8Array(un64(envelope.ciphertext))));
97
+ try {
98
+ return JSON.parse(new TextDecoder().decode(decrypted));
99
+ } finally {
100
+ decrypted.fill(0);
101
+ }
102
+ }
103
+ var SIGNATURE_FIELDS = (envelope) => ({
104
+ timestamp: envelope.timestamp,
105
+ nonce: envelope.nonce,
106
+ edSignature: envelope.edSignature,
107
+ mlDsaSignature: envelope.mlDsaSignature
108
+ });
109
+ function encodeSignatureHeader(envelope) {
110
+ return b64(ENCODER.encode(JSON.stringify(SIGNATURE_FIELDS(envelope))));
111
+ }
112
+ function decodeSignatureHeader(raw, nodeKey) {
113
+ let parsed;
114
+ try {
115
+ parsed = JSON.parse(new TextDecoder().decode(un64(raw)));
116
+ } catch {
117
+ return null;
118
+ }
119
+ if (typeof parsed.timestamp !== "number" || !Number.isSafeInteger(parsed.timestamp) || typeof parsed.nonce !== "string" || typeof parsed.edSignature !== "string" || typeof parsed.mlDsaSignature !== "string")
120
+ return null;
121
+ return {
122
+ nodeKey,
123
+ timestamp: parsed.timestamp,
124
+ nonce: parsed.nonce,
125
+ edSignature: parsed.edSignature,
126
+ mlDsaSignature: parsed.mlDsaSignature
127
+ };
128
+ }
44
129
  function canonicalString(args) {
45
130
  const body = typeof args.body === "string" ? ENCODER.encode(args.body) : args.body;
46
131
  const digest = Array.from(sha256(body), (byte) => byte.toString(16).padStart(2, "0")).join("");
47
- return [
132
+ const fields = [
48
133
  args.method.toUpperCase(),
49
134
  args.path,
50
135
  args.query ?? "",
51
136
  String(args.timestamp),
52
137
  args.nonce,
53
138
  digest
54
- ].join(`
139
+ ];
140
+ if (args.responseKey)
141
+ fields.push(args.responseKey);
142
+ return fields.join(`
55
143
  `);
56
144
  }
57
145
  function signRequest(keys, nodeKey, args) {
@@ -80,7 +168,8 @@ function verifyRequest(args) {
80
168
  query: args.query ?? "",
81
169
  timestamp: args.envelope.timestamp,
82
170
  nonce: args.envelope.nonce,
83
- body: args.body
171
+ body: args.body,
172
+ responseKey: args.responseKey
84
173
  }));
85
174
  } catch {
86
175
  return { verified: false, reason: "malformed" };
@@ -103,9 +192,16 @@ function verifyRequest(args) {
103
192
  }
104
193
  export {
105
194
  verifyRequest,
195
+ validResponsePublicKey,
106
196
  signRequest,
197
+ sealResponse,
198
+ openResponse,
199
+ generateResponseRecipient,
107
200
  generateNodeKeys,
201
+ encodeSignatureHeader,
108
202
  deriveKeysFromSeed,
203
+ decodeSignatureHeader,
109
204
  canonicalString,
205
+ RESPONSE_KEY_HEADER,
110
206
  CLOCK_SKEW_SECONDS
111
207
  };
package/dist/jobs.d.ts CHANGED
@@ -15,11 +15,12 @@
15
15
  *
16
16
  * Each has a specific answer here: reschedule after completion rather than on
17
17
  * an interval, advance only on a fully successful batch, take a fenced lock,
18
- * and await in-flight work on stop.
18
+ * and submit every run through the common awaited queue, which drains on stop.
19
19
  *
20
20
  * Zero dependencies. The lock and the cursor store are interfaces, so this runs
21
21
  * against a database, Redis, or nothing at all in a test.
22
22
  */
23
+ import { type DrainReport } from './queue';
23
24
  /** Injected so a test does not sleep and a resumed run is reproducible. */
24
25
  export interface Clock {
25
26
  now(): number;
@@ -122,7 +123,7 @@ export declare function createScheduler(options: SchedulerOptions): {
122
123
  * leaves a record half-written — the process exits mid-transaction and the
123
124
  * next boot finds state nothing can explain.
124
125
  */
125
- stop(): Promise<void>;
126
+ stop(deadlineMs?: number): Promise<DrainReport>;
126
127
  /** Timers stop firing; in-flight work is left to finish. */
127
128
  pause(): void;
128
129
  resume(): void;