@forgezero/runtime 0.1.6 → 0.1.7

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.
Files changed (2) hide show
  1. package/README.md +595 -105
  2. package/package.json +2 -2
package/README.md CHANGED
@@ -1,155 +1,645 @@
1
+ <!--
2
+ GENERATED FILE — do not edit.
3
+
4
+ Change scripts/generate-guides.ts or its typed sources, run `bun run guides`,
5
+ and commit the generator and rendered files together.
6
+ -->
7
+
1
8
  # @forgezero/runtime
2
9
 
3
- **The machinery behind a request handler.** Background jobs that never overlap
4
- themselves, keyed queues, a transactional outbox, a hash-chained audit trail,
5
- templated mail, encrypted backups, schema validation, and money that is never a
6
- floating-point number.
10
+ The machinery behind a request handler jobs, queues, an outbox, a hash-chained audit trail, mail, backups, schema validation and exact money. 44 public modules, each imported on its own.
11
+
12
+ ## Global package root and supported runtimes
13
+
14
+ Anyone running a service, for the work that happens outside a request. Separate from `access` because a Cloudflare Worker wants the request pipeline and cannot run a backup job. Supported runtimes: bun, node. The global base/root import is @forgezero/runtime. Every public import or command is listed below; the documentation inventory is checked in both directions against package.json exports.
7
15
 
8
- Thirty-four public modules. Each is its own entry point, so you install one package and
9
- your bundler includes only what you imported.
16
+ ```text
17
+ import * as root from '@forgezero/runtime';
18
+ ```
19
+
20
+ ## Commands
10
21
 
11
- ```bash
22
+ bun add @forgezero/runtime — Install runtime contracts; import the required subpath so unused capabilities stay out of the bundle.
23
+
24
+ ```text
12
25
  bun add @forgezero/runtime
13
26
  ```
14
27
 
15
- ```ts
28
+ ## @forgezero/runtime/query
29
+
30
+ Provider-neutral typed function contracts: decode untrusted input, run with caller-supplied services or storage, and strictly validate the result without coupling business logic to ForgeZero or ArangoDB.
31
+
32
+ ```text
33
+ import * as api from '@forgezero/runtime/query';
34
+ ```
35
+
36
+ ## @forgezero/runtime/jobs
37
+
38
+ Background work that never overlaps itself, advances a cursor only on success, and can be paused and inspected.
39
+
40
+ ```text
41
+ import * as api from '@forgezero/runtime/jobs';
42
+ ```
43
+
44
+ ## @forgezero/runtime/queue
45
+
46
+ Memory-only keyed work queue — awaited results, parallel across keys and strictly sequential within one; clustered callers atomically claim ownership in their own business store before submitting.
47
+
48
+ ```text
49
+ import * as api from '@forgezero/runtime/queue';
50
+ ```
51
+
52
+ ## @forgezero/runtime/outbox
53
+
54
+ Write the event with the record, deliver it after, in order per key with backoff and a dead-letter queue.
55
+
56
+ ```text
57
+ import * as api from '@forgezero/runtime/outbox';
58
+ ```
59
+
60
+ ## @forgezero/runtime/audit
61
+
62
+ Append-only records chained by hash, with a verifier that names the first altered entry.
63
+
64
+ ```text
65
+ import * as api from '@forgezero/runtime/audit';
66
+ ```
67
+
68
+ ## @forgezero/runtime/backup
69
+
70
+ Encrypted, chunked, verified snapshots to object storage — and the restore that reads them back.
71
+
72
+ ```text
73
+ import * as api from '@forgezero/runtime/backup';
74
+ ```
75
+
76
+ ## @forgezero/runtime/notify
77
+
78
+ Render a named template to text and HTML, escaped per part, refusing to send with a blank where a value should be.
79
+
80
+ ```text
81
+ import * as api from '@forgezero/runtime/notify';
82
+ ```
83
+
84
+ ## @forgezero/runtime/notify/templates
85
+
86
+ The six transactional messages ForgeZero sends.
87
+
88
+ ```text
89
+ import * as api from '@forgezero/runtime/notify/templates';
90
+ ```
91
+
92
+ ## @forgezero/runtime/calendar
93
+
94
+ Billing periods computed from an anchor, working days, holidays and due dates.
95
+
96
+ ```text
97
+ import * as api from '@forgezero/runtime/calendar';
98
+ ```
99
+
100
+ ## @forgezero/runtime/compliance
101
+
102
+ Screening as a decision record — tiers, rules and lists, failing closed when a list is unreachable.
103
+
104
+ ```text
105
+ import * as api from '@forgezero/runtime/compliance';
106
+ ```
107
+
108
+ ## @forgezero/runtime/totp
109
+
110
+ RFC 6238 TOTP on the existing HMAC — base32, an asymmetric window, and replay left to the caller.
111
+
112
+ ```text
113
+ import * as api from '@forgezero/runtime/totp';
114
+ ```
115
+
116
+ ## @forgezero/runtime/passkey
117
+
118
+ Passkeys for sites that are not us. A vault-held credential is as unphishable as one in a security chip provided the RP ID check never slips — `evil-example.com` ends with `example.com` and is a different site.
119
+
120
+ ```text
121
+ import * as api from '@forgezero/runtime/passkey';
122
+ ```
123
+
124
+ ## @forgezero/runtime/phrase
125
+
126
+ BIP-39 recovery phrases, and the salted verifier that proves one without being able to reconstruct it.
127
+
128
+ ```text
129
+ import * as api from '@forgezero/runtime/phrase';
130
+ ```
131
+
132
+ ## @forgezero/runtime/snp
133
+
134
+ Parse an AMD SEV-SNP attestation report at the firmware ABI offsets, and compare a TCB component by component so a microcode bump cannot mask a firmware downgrade.
135
+
136
+ ```text
137
+ import * as api from '@forgezero/runtime/snp';
138
+ ```
139
+
140
+ ## @forgezero/runtime/importers
141
+
142
+ Read secrets out of a .env, a CSV, or a Bitwarden or 1Password export — skipping what cannot be understood rather than guessing, and never putting a value in an error.
143
+
144
+ ```text
145
+ import * as api from '@forgezero/runtime/importers';
146
+ ```
147
+
148
+ ## @forgezero/runtime/openssh
149
+
150
+ OpenSSH wire encoding, so a derived ed25519 key becomes a line that pastes into authorized_keys.
151
+
152
+ ```text
153
+ import * as api from '@forgezero/runtime/openssh';
154
+ ```
155
+
156
+ ## @forgezero/runtime/ssh-cert
157
+
158
+ OpenSSH certificates, so access expires instead of having to be hunted down. Takes a signing FUNCTION rather than a secret key, which is what lets the CA live in a vault that never hands it out.
159
+
160
+ ```text
161
+ import * as api from '@forgezero/runtime/ssh-cert';
162
+ ```
163
+
164
+ ## @forgezero/runtime/slip10
165
+
166
+ SLIP-0010 derivation for ed25519, hardened-only — BIP-32 does not work on this curve and produces halves that do not correspond.
167
+
168
+ ```text
169
+ import * as api from '@forgezero/runtime/slip10';
170
+ ```
171
+
172
+ ## @forgezero/runtime/identity
173
+
174
+ Hybrid Ed25519 + ML-DSA-65 request signing. One canonical string, so the compute agent that signs inside a guest and the API that verifies cannot drift — which two implementations of it certainly would.
175
+
176
+ ```text
177
+ import * as api from '@forgezero/runtime/identity';
178
+ ```
179
+
180
+ ## @forgezero/runtime/schema
181
+
182
+ Validate against JSON Schema, restrict what a caller may declare, and describe a schema as a form.
183
+
184
+ ```text
185
+ import * as api from '@forgezero/runtime/schema';
186
+ ```
187
+
188
+ ## @forgezero/runtime/schema/typebox
189
+
190
+ The TypeBox validator behind that interface.
191
+
192
+ ```text
193
+ import * as api from '@forgezero/runtime/schema/typebox';
194
+ ```
195
+
196
+ ## @forgezero/runtime/finance/discounts
197
+
198
+ Promotions as arithmetic over integer minor units. They never stack — one winner — and a percentage rounds down, because rounding a discount up gives away a unit of currency per invoice forever.
199
+
200
+ ```text
201
+ import * as api from '@forgezero/runtime/finance/discounts';
202
+ ```
203
+
204
+ ## @forgezero/runtime/finance/money
205
+
206
+ Exact amounts in minor units with the asset attached, so two currencies cannot be added.
207
+
208
+ ```text
209
+ import * as api from '@forgezero/runtime/finance/money';
210
+ ```
211
+
212
+ ## @forgezero/runtime/finance/venues
213
+
214
+ Trading venues, market types and symbols as data — spot, margin and futures behind one order model.
215
+
216
+ ```text
217
+ import * as api from '@forgezero/runtime/finance/venues';
218
+ ```
219
+
220
+ ## @forgezero/runtime/finance/ledger
221
+
222
+ Double-entry postings and derived balances. A hold is a posting, not a lock — the queue does the ordering.
223
+
224
+ ```text
225
+ import * as api from '@forgezero/runtime/finance/ledger';
226
+ ```
227
+
228
+ ## @forgezero/runtime/finance/commission
229
+
230
+ Profit net of flows, a high-water mark, the tier split and the referral share of our income.
231
+
232
+ ```text
233
+ import * as api from '@forgezero/runtime/finance/commission';
234
+ ```
235
+
236
+ ## @forgezero/runtime/finance/rates
237
+
238
+ What an asset is worth in USD, and how old that answer is. A peg never ages; a quote always does.
239
+
240
+ ```text
241
+ import * as api from '@forgezero/runtime/finance/rates';
242
+ ```
243
+
244
+ ## @forgezero/runtime/finance/transfers
245
+
246
+ Deposits and withdrawals as ordered pipelines, with screening reserved at position zero.
247
+
248
+ ```text
249
+ import * as api from '@forgezero/runtime/finance/transfers';
250
+ ```
251
+
252
+ ## @forgezero/runtime/finance/chain
253
+
254
+ Which chains exist, their confirmation depth by value band, assets and explorers — as data an admin edits.
255
+
256
+ ```text
257
+ import * as api from '@forgezero/runtime/finance/chain';
258
+ ```
259
+
260
+ ## @forgezero/runtime/finance/custody
261
+
262
+ Derive CREATE2 deposit addresses and EIP-712 custody digests — arithmetic over a seed and a salt, needing no credential and no node.
263
+
264
+ ```text
265
+ import * as api from '@forgezero/runtime/finance/custody';
266
+ ```
267
+
268
+ ## @forgezero/runtime/finance/derive
269
+
270
+ Declare a field ForgeZero generates rather than the tenant supplying: BIP-44 path arithmetic, and the choice of who is capable of generating the key.
271
+
272
+ ```text
273
+ import * as api from '@forgezero/runtime/finance/derive';
274
+ ```
275
+
276
+ ## @forgezero/runtime/finance/tax
277
+
278
+ Which jurisdiction may tax a sale, who accounts for it, and the three ways to be zero that report differently. Rates are data.
279
+
280
+ ```text
281
+ import * as api from '@forgezero/runtime/finance/tax';
282
+ ```
283
+
284
+ ## @forgezero/runtime/finance/storage
285
+
286
+ Write an amount so it is both exact and sortable: an authoritative string, and a number that is only an index.
287
+
288
+ ```text
289
+ import * as api from '@forgezero/runtime/finance/storage';
290
+ ```
291
+
292
+ ## @forgezero/runtime/realtime
293
+
294
+ Provider-neutral realtime audience, shard, event and delivery contracts.
295
+
296
+ ```text
297
+ import * as api from '@forgezero/runtime/realtime';
298
+ ```
299
+
300
+ ## @forgezero/runtime/passkey-hybrid
301
+
302
+ Versioned WebAuthn PRF plus ML-DSA companion proof construction and verification.
303
+
304
+ ```text
305
+ import * as api from '@forgezero/runtime/passkey-hybrid';
306
+ ```
307
+
308
+ ## @forgezero/runtime/otpauth
309
+
310
+ Parse and render otpauth URIs without binding enrolment to a UI framework.
311
+
312
+ ```text
313
+ import * as api from '@forgezero/runtime/otpauth';
314
+ ```
315
+
316
+ ## @forgezero/runtime/pipeline
317
+
318
+ Typed ordered application-pipeline execution with explicit evidence.
319
+
320
+ ```text
321
+ import * as api from '@forgezero/runtime/pipeline';
322
+ ```
323
+
324
+ ## @forgezero/runtime/finance/chain-addresses
325
+
326
+ Chain-address derivation records and validation independent of a node provider.
327
+
328
+ ```text
329
+ import * as api from '@forgezero/runtime/finance/chain-addresses';
330
+ ```
331
+
332
+ ## @forgezero/runtime/finance/chain-deposits
333
+
334
+ Provider-neutral deposit observation, confirmation and credit transitions.
335
+
336
+ ```text
337
+ import * as api from '@forgezero/runtime/finance/chain-deposits';
338
+ ```
339
+
340
+ ## @forgezero/runtime/finance/chain-withdrawals
341
+
342
+ Provider-neutral withdrawal approval, broadcast and finality transitions.
343
+
344
+ ```text
345
+ import * as api from '@forgezero/runtime/finance/chain-withdrawals';
346
+ ```
347
+
348
+ ## @forgezero/runtime/finance/chain-reconcile
349
+
350
+ Deterministic reconciliation between chain observations and durable transfer state.
351
+
352
+ ```text
353
+ import * as api from '@forgezero/runtime/finance/chain-reconcile';
354
+ ```
355
+
356
+ ## @forgezero/runtime/finance/market
357
+
358
+ Market order, fill and quote contracts independent of any exchange adapter.
359
+
360
+ ```text
361
+ import * as api from '@forgezero/runtime/finance/market';
362
+ ```
363
+
364
+ ## @forgezero/runtime/custody-share
365
+
366
+ Threshold-share parsing, validation and reconstruction.
367
+
368
+ ```text
369
+ import * as api from '@forgezero/runtime/custody-share';
370
+ ```
371
+
372
+ ## @forgezero/runtime/custody-crypto
373
+
374
+ Hybrid ML-KEM-768 plus X25519 custody-share sealing and opening.
375
+
376
+ ```text
377
+ import * as api from '@forgezero/runtime/custody-crypto';
378
+ ```
379
+
380
+ ## Define and implement a typed query
381
+
382
+ Decode untrusted input and validate output around injected storage/services rather than embedding a database query in the route adapter.
383
+
384
+ ```text
385
+ import { defineQuery, implementQuery } from '@forgezero/runtime/query';
386
+ import { T, type Static, typeboxQueryCodec } from '@forgezero/runtime/schema/typebox';
387
+
388
+ const OrderKey = T.Object({ orderKey: T.String({ minLength: 1 }) });
389
+ const Order = T.Object({ orderKey: T.String(), total: T.String() });
390
+ type OrderRow = Static<typeof Order>;
391
+ type Stores = { orders: { find(key: string): Promise<OrderRow | null> } };
392
+ declare const stores: Stores;
393
+
394
+ const findOrder = defineQuery({
395
+ name: 'orders.find',
396
+ input: typeboxQueryCodec(OrderKey),
397
+ output: typeboxQueryCodec(T.Union([Order, T.Null()]))
398
+ });
399
+ export const runFindOrder = implementQuery(findOrder,
400
+ (context: Stores, input) => context.orders.find(input.orderKey)
401
+ );
402
+
403
+ const order = await runFindOrder.execute(stores, { orderKey: 'ord_123' });
404
+ ```
405
+
406
+ ## 1. Install, then import a subpath
407
+
408
+ There is no root export, and that is deliberate. `import from "@forgezero/runtime"` is meant to fail rather than resolve to whichever module happened to be listed first — a bare import that silently works is how a project ends up depending on the whole package to use one function. Every module is its own entry point, so a bundler includes what you imported and nothing else.
409
+
410
+ ```text
411
+ bun add @forgezero/runtime
412
+
16
413
  import { parseAmount } from '@forgezero/runtime/finance/money';
17
414
  import { createScheduler } from '@forgezero/runtime/jobs';
18
415
 
19
- // There is no root export. `import from '@forgezero/runtime'` is meant to fail
20
- // rather than resolve to whichever module happened to be listed first.
416
+ // This throws. It is supposed to.
417
+ // import { anything } from '@forgezero/runtime';
21
418
  ```
22
419
 
23
420
  ## What is in it
24
421
 
25
- | | |
26
- |---|---|
27
- | **Spine** | `query` `jobs` `queue` `outbox` |
28
- | **Record** | `audit` `backup` `compliance` `calendar` |
29
- | **Identity** | `identity` `totp` `notify` `notify/templates` `schema` `schema/typebox` |
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` |
422
+ Four groups. The service spine is what a process needs to keep working after the response is sent; the record keeps evidence; the finance modules are the ones that must never be approximated. Nothing here knows what your product does — a tenant binds its own business rules to these, it does not fork them.
423
+
424
+ ```text
425
+ SPINE jobs scheduled work submitted through the common queue
426
+ queue awaited result; parallel keys, sequential within one
427
+ outbox business-owned durability, drained through handlers
428
+
429
+ RECORD audit append-only, hash-chained, names the first altered row
430
+ backup encrypted, chunked, verified snapshots + the restore
431
+ compliance screening as a decision record, failing closed
432
+ calendar billing periods, working days, holidays, due dates
433
+
434
+ IDENTITY identity hybrid Ed25519 + ML-DSA-65 request signing
435
+ totp RFC 6238, asymmetric window, replay rejected
436
+ notify named templates to text and HTML, escaped per part
437
+ schema JSON Schema validation, restricted and describable
438
+
439
+ FINANCE money minor units with the asset attached
440
+ storage the same amount, exact AND sortable in a database
441
+ ledger double-entry postings and derived balances
442
+ commission profit net of flows, high-water mark, referral split
443
+ rates what an asset is worth, and how old that answer is
444
+ transfers deposits and withdrawals as ordered pipelines
445
+ tax who may tax a sale, and who accounts for it
446
+ chain chains, assets, confirmation depth by value band
447
+ custody CREATE2 deposit addresses and EIP-712 digests
448
+ derive declare a key ForgeZero generates, not the tenant
449
+ venues trading venues, market types and symbols as data
450
+ binance Binance behind the venue adapter
451
+ ```
31
452
 
32
- None of it knows what your product does. You bind your own rules to these; you
33
- do not fork them.
453
+ ## Optional peers install only what your subpath needs
34
454
 
35
- ## Typed function queries
455
+ Nothing third-party is bundled. Vendoring a crypto library means a fix for it never reaches you until this package is republished, so the imports stay real and the dependency stays yours. Most subpaths need nothing; four of them do, and the failure is a plain module-not-found rather than a silently wrong result.
36
456
 
37
- `query` binds typed input/output codecs to an ordinary function and any context
38
- you choose. It has no database or ForgeZero realm dependency. Inputs may decode
39
- wire values; outputs are checked strictly so a wrong handler result cannot be
40
- coerced into looking correct.
457
+ ```text
458
+ # most subpaths: nothing to add
41
459
 
42
- ```ts
43
- import { defineQuery, implementQuery } from '@forgezero/runtime/query';
44
- import { typeboxQueryCodec, T } from '@forgezero/runtime/schema/typebox';
460
+ # finance/custody — CREATE2 and EIP-712 digests
461
+ bun add @noble/hashes
45
462
 
46
- const contract = defineQuery({
47
- name: 'invoice.by-reference',
48
- input: typeboxQueryCodec(T.Object({ reference: T.String() })),
49
- output: typeboxQueryCodec(T.Object({ total: T.String() }))
50
- });
463
+ # identity — hybrid post-quantum signing
464
+ bun add @noble/curves @noble/post-quantum
51
465
 
52
- const findInvoice = implementQuery(contract, async (services: MyServices, input, { signal }) =>
53
- services.invoices.find(input.reference, { signal })
54
- );
466
+ # schema/typebox — only if you validate with TypeBox
467
+ bun add @sinclair/typebox
55
468
  ```
56
469
 
57
- ## One reusable async queue
470
+ ## Money is never a number
471
+
472
+ An amount is minor units as a bigint with its asset attached, so two currencies cannot be added by accident and a rounding mode is always stated. A database needs a second thing the bigint cannot give it — an ORDER BY that works — so `finance/storage` writes both: the authoritative string, and a lossy double used for sorting and range filters only. One ETH is 10^18 minor units and a signed 64-bit column overflows at about nine ETH, which is why the exact value is never the sortable one.
473
+
474
+ ```text
475
+ import { parseAmount, mulRate, formatAmount } from '@forgezero/runtime/finance/money';
476
+ import { toStored, rangeBounds } from '@forgezero/runtime/finance/storage';
477
+
478
+ const fee = mulRate(parseAmount('1250.00', 'USD'), '0.015', 'down');
479
+ formatAmount(fee); // '18.75' — never 18.749999999
480
+
481
+ const row = toStored(fee);
482
+ // { units: '1875', value: '18.75', asset: 'USD', sort: 18.75 }
483
+ // units → what you pay out. sort → what you ORDER BY.
58
484
 
59
- `queue` is deliberately memory-only: it accepts a function, returns that
60
- function's value through a Promise, runs one key sequentially, and runs
61
- different keys in parallel. Persistence and cluster ownership remain in the
62
- business layer that knows what a pending request means; ForgeZero uses an
63
- ArangoDB unique claim, while another caller may use any database or no database.
485
+ rangeBounds(parseAmount('10', 'USD'), null); // { gte: 10 }
486
+ ```
64
487
 
65
- ```ts
66
- import { createQueue } from '@forgezero/runtime/queue';
488
+ ## The contracts ship with it
67
489
 
68
- // Default admission is 60% of reported logical CPUs (with one reserved).
69
- // Use `width` for an exact ceiling, or an explicit dynamic resource policy:
70
- const queue = createQueue({ resources: { percent: 60, reserve: 1, max: 32 } });
71
- const task = queue.run('tenant-a:wallet-7', transfer, amount, destination);
72
- const receipt = await task.result;
490
+ The Solidity behind `finance/custody` is in the package rather than in a repository you have to go find. `finance/custody` computes the counterfactual address; `DepositFactory` is what makes that address real on first sweep, and `ColdVault` is the M-of-N EIP-712 approval the digests are built for. Address arithmetic without the contract that honours it is half an answer.
73
491
 
74
- queue.pauseKey('tenant-a:wallet-7');
75
- queue.resumeKey('tenant-a:wallet-7');
76
- queue.stopKey('tenant-a:wallet-7'); // running finishes; pending is rejected
77
- queue.startKey('tenant-a:wallet-7');
78
- queue.cancel(task.id); // pending task only
79
- await queue.stop(30_000); // close intake and drain all work
492
+ ```text
493
+ node_modules/@forgezero/runtime/contracts/src/
494
+ DepositProxy.sol lazily deployed at first sweep no key, no gas per address
495
+ DepositFactory.sol CREATE2, salt opaque to the contract
496
+ ColdVault.sol M-of-N, signatures ordered by ascending signer
497
+ SafeTransferLib.sol tokens that do not return a bool
80
498
  ```
81
499
 
82
- Different async keys overlap immediately. CPU-heavy JavaScript does not become
83
- multi-core merely by entering a queue: put that handler in Bun/standard Workers
84
- and await the Worker result from the queue.
500
+ ## Jobs background work that never overlaps
85
501
 
86
- Jobs accept intervals down to seconds or a local wall-clock schedule with an
87
- IANA timezone and weekday filter. `overlap: 'wait'` (the default) schedules the
88
- next run after completion; `overlap: 'skip'` keeps clock cadence and drops a tick
89
- when the same key is still busy. Same-key overlap is never allowed.
502
+ The lock and the cursor store are interfaces, so this runs against a database, Redis, or nothing at all in a test.
90
503
 
91
- ```ts
92
- defineJob({
93
- key: 'tenant:acme:invoice',
94
- label: 'Monthly invoice preparation',
95
- schedule: { timezone: 'Asia/Kolkata', time: '00:00:15', weekdays: [1] },
96
- overlap: 'skip',
97
- run: async ({ signal }) => generateInvoices({ signal })
504
+ ```text
505
+ import { createScheduler, defineJob, cursorJob } from '@forgezero/runtime/jobs';
506
+ ```
507
+
508
+ ## Never setInterval
509
+
510
+ An interval fires whether or not the previous run finished, so work that takes longer than its period ends up running twice over the same data. The next run is scheduled after the current one completes, which makes the overlap impossible rather than unlikely.
511
+
512
+ ```text
513
+ const scheduler = createScheduler({
514
+ jobs: [
515
+ defineJob({ key: 'reap-sessions', label: 'Reap sessions', every: '5m', run: reap }),
516
+ defineJob({ key: 'probe-providers', label: 'Probe providers', every: '30s', run: probe })
517
+ ],
518
+ lock: storeLock(lockStore)
98
519
  });
520
+
521
+ scheduler.start();
99
522
  ```
100
523
 
101
- ## Three things worth knowing before you use it
524
+ ## A lease, not a mutex
102
525
 
103
- **Money is never a number.** An amount is minor units as a `bigint` with its
104
- asset attached, so two currencies cannot be added by accident and a rounding
105
- mode is always stated. `finance/storage` writes the same amount twice — the
106
- authoritative string, and a lossy double used for `ORDER BY` only. One ETH is
107
- 10^18 minor units and a signed 64-bit column overflows around nine ETH, which is
108
- why the exact value is never the sortable one.
526
+ A process that dies holding a mutex blocks its job for ever, and somebody clears it by hand at three in the morning. A lease expires on its own. The fence number rises each time the lock is granted, so a run that stalled past its lease and woke up finds its fence stale and stops before writing.
109
527
 
110
- ```ts
111
- const fee = mulRate(parseAmount('1250.00', 'USD'), '0.015', 'down');
112
- formatAmount(fee); // '18.75' never 18.749999999
113
- toStored(fee); // { units: '1875', value: '18.75', asset: 'USD', sort: 18.75 }
528
+ ```text
529
+ const lease = await lock.acquire('scan', 60_000); // undefined if held
530
+ await lock.renew('scan', lease.fence, 60_000); // false once superseded
531
+ ```
532
+
533
+ ## The cursor advances only on a complete batch
534
+
535
+ Fetch a batch, process every item, then write the cursor — never per item and never before. If item three of ten throws, the whole batch is retried from the same position. That means process must be idempotent, and idempotent retries are strictly better than the alternative, which is records nobody ever looks at again.
536
+
537
+ ```text
538
+ cursorJob({
539
+ key: 'scan-deposits',
540
+ label: 'Scan deposits',
541
+ every: '15s',
542
+ store: cursors,
543
+ from: '0',
544
+ fetch: (cursor) => fetchBlocks(cursor),
545
+ process: (transfer) => credit(transfer) // keyed, so a retry is free
546
+ });
114
547
  ```
115
548
 
116
- **A scheduler, never `setInterval`.** An interval fires whether or not the last
117
- run finished, so work slower than its period runs twice over the same data. The
118
- next run is scheduled after the current one completes. The lock is a lease with
119
- a fence, not a mutex — a process that dies does not block its job for ever.
549
+ ## Stop waits
120
550
 
121
- **The cursor advances only on a complete batch.** Fetch, process every item,
122
- then write the cursor — never per item, never before. Item three of ten throwing
123
- retries the whole batch from the same position, which means `process` must be
124
- idempotent, and idempotent retries beat records nobody ever looks at again.
551
+ Returning from stop() before in-flight work settles is how a deploy leaves a record half-written and the next boot finds state nothing explains. Timers are cleared, the abort signal fires so long runs can cut themselves short, and then it awaits what is still running.
125
552
 
126
- ## Optional peers
553
+ ```text
554
+ await scheduler.stop(); // clears timers, aborts, awaits in-flight
555
+ scheduler.pause(); // stop scheduling, let in-flight finish
556
+ await scheduler.runNow('scan-deposits');
557
+ ```
558
+
559
+ ## Status somebody can read during an incident
127
560
 
128
- Nothing third-party is bundled vendoring a crypto library means a fix for it
129
- never reaches you until this package republishes. Most subpaths need nothing:
561
+ Last run, duration, result, error and consecutive failures per job. Without it a job that has been failing for a week looks exactly like a job that has been succeeding.
130
562
 
131
- ```bash
132
- bun add @noble/hashes # finance/custody
133
- bun add @noble/curves @noble/post-quantum # identity
134
- bun add @sinclair/typebox # schema/typebox
563
+ ```text
564
+ scheduler.status();
565
+ // [{ key: 'scan-deposits', state: 'idle', runs: 412,
566
+ // lastDurationMs: 840, lastResult: { processed: 17, batches: 2 },
567
+ // consecutiveFailures: 0, skippedLocked: 0 }]
135
568
  ```
136
569
 
137
- ## The Solidity ships with it
570
+ ## Schema validation you can also render
138
571
 
139
- `finance/custody` computes counterfactual CREATE2 deposit addresses; the
140
- contracts that make those addresses real are in the tarball, not in a repository
141
- you have to go find.
572
+ TypeBox is a peer dependency and optional. A project on Zod pulls none of it, because the interface is what the other packages depend on.
142
573
 
574
+ ```text
575
+ import { validate, describeForm } from '@forgezero/runtime/schema';
576
+
577
+ // only if you validate with TypeBox
578
+ bun add @sinclair/typebox
143
579
  ```
144
- node_modules/@forgezero/runtime/contracts/src/
145
- DepositProxy.sol deployed lazily at first sweep — no key, no gas per address
146
- DepositFactory.sol CREATE2, salt opaque to the contract
147
- ColdVault.sol M-of-N EIP-712, signatures ordered by ascending signer
580
+
581
+ ## 2. Validate
582
+
583
+ Query strings are entirely strings, so values are converted before checking — otherwise ?port=587 fails a schema expecting a number on a perfectly well-formed request.
584
+
585
+ ```text
586
+ import { typebox, T } from '@forgezero/runtime/schema/typebox';
587
+
588
+ const Config = T.Object(
589
+ { host: T.String(), port: T.Integer() },
590
+ { additionalProperties: false }
591
+ );
592
+
593
+ typebox.validate(Config, { host: 'mail', port: '587' });
594
+ // { ok: true, value: { host: 'mail', port: 587 } }
595
+ ```
596
+
597
+ ## 3. Render a form from it
598
+
599
+ describeForm turns a schema into flat, framework-agnostic fields. This is what lets an admin screen render provider credentials and security factors with no per-feature UI code.
600
+
601
+ ```text
602
+ typebox.describeForm(Config);
603
+ // [{ path: 'host', label: 'Host', kind: 'string', required: true }, ...]
604
+ ```
605
+
606
+ ## The restrictions are a security boundary
607
+
608
+ A schema authored in your own source is trusted. One submitted by a tenant is not, and these limits are the only thing between the two. $ref would let a schema point validation at a document you do not control; unbounded depth is a denial-of-service; and an object accepting unknown properties is one that lets an unvalidated field ride into an envelope.
609
+
610
+ ```text
611
+ typebox.restrict(schema)
612
+
613
+ $ref · $id · $defs refused
614
+ depth > 4 refused
615
+ more than 100 fields refused
616
+ larger than 64 KiB refused
617
+ additionalProperties must be false
148
618
  ```
149
619
 
150
- Full documentation: **https://www.forgezero.net/docs/runtime**
620
+ ## writeOnly is the vault boundary
151
621
 
152
- ## Licence
622
+ A field marked writeOnly is never returned to a browser. readableFields strips them at any depth, and writeOnlyPaths lists exactly what must be routed to secret storage instead.
623
+
624
+ ```text
625
+ const fields = typebox.describeForm(schema);
626
+
627
+ readableFields(fields); // safe to serialise
628
+ writeOnlyPaths(fields); // ['apiKey', 'nested.secret']
629
+ ```
630
+
631
+ ## Adding another validator
632
+
633
+ Implement four functions. toJsonSchema is what keeps the ecosystem from fragmenting: stored config, the admin UI and the wire all speak JSON Schema, so a Zod project and a TypeBox project produce identical documents and either can read the other.
634
+
635
+ ```text
636
+ interface SchemaValidator<S> {
637
+ name: string;
638
+ validate(schema: S, value: unknown): ValidationResult;
639
+ describeForm(schema: S): FormField[];
640
+ toJsonSchema(schema: S): Record<string, unknown>;
641
+ restrict(schema: S, limits?: Restrictions): S;
642
+ }
643
+ ```
153
644
 
154
- MIT. Part of [ForgeZero](https://www.forgezero.net) — secrets, attested compute and
155
- deploys — and usable entirely on its own, with no ForgeZero account.
645
+ Full rendered documentation: https://www.forgezero.net/docs/runtime
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgezero/runtime",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -191,7 +191,7 @@
191
191
  "prepublishOnly": "bun run check && bun run build"
192
192
  },
193
193
  "dependencies": {
194
- "@forgezero/access": "^0.1.0"
194
+ "@forgezero/access": "^0.1.3"
195
195
  },
196
196
  "peerDependencies": {
197
197
  "@noble/ciphers": "^2.2.0",