@belticlabs/agent-risk-sdk 0.5.0 → 0.6.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/dist/index.js CHANGED
@@ -1,62 +1,116 @@
1
1
  import {
2
- Verdict
3
- } from "./chunk-4BUUPU3O.js";
2
+ openCall
3
+ } from "./chunk-ZMPKY7AX.js";
4
4
  import {
5
- ApiClient,
6
- BelticApiError,
7
- BelticConfigError,
8
- ChainRejectedError,
9
- Session,
10
- Sessions,
11
- Transport
12
- } from "./chunk-JSE6JQJC.js";
5
+ summaryOf
6
+ } from "./chunk-M4I3FGZG.js";
13
7
  import {
8
+ Chain,
14
9
  canonicalBytes,
15
- canonicalize,
16
10
  didKeyFromEd25519,
17
11
  fromHex,
18
12
  memorySigner,
19
13
  sha256,
20
14
  toHex
21
- } from "./chunk-X3W2Z5GC.js";
15
+ } from "./chunk-77D74TWX.js";
22
16
 
23
- // src/core/identity.ts
24
- function identityFromSeed(seed, credential) {
25
- const signer = memorySigner(seed);
26
- const did = didKeyFromEd25519(signer.publicKey);
27
- return { did, signer: { ...signer, keyId: did }, ...credential ? { credential } : {} };
28
- }
17
+ // src/core/api-client.ts
18
+ var TIMEOUT_MS = 1e4;
19
+ var BelticApiError = class extends Error {
20
+ constructor(status, code, message, details, requestId) {
21
+ super(message);
22
+ this.status = status;
23
+ this.code = code;
24
+ this.details = details;
25
+ this.requestId = requestId;
26
+ this.name = "BelticApiError";
27
+ }
28
+ /** 5xx, 429 and network failures are an outage: retried by the transport, absorbed by the fail-open entries; 4xx are neither (GAP-70). */
29
+ get retryable() {
30
+ return this.status === 0 || this.status >= 500 || this.status === 429;
31
+ }
32
+ };
33
+ var ApiClient = class {
34
+ baseUrl;
35
+ headers;
36
+ constructor(baseUrl, apiKey, userAgent) {
37
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
38
+ this.headers = {
39
+ authorization: `Bearer ${apiKey}`,
40
+ "content-type": "application/json",
41
+ "user-agent": userAgent
42
+ };
43
+ }
44
+ async post(path, body) {
45
+ const controller = new AbortController();
46
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
47
+ let res;
48
+ try {
49
+ res = await globalThis.fetch(`${this.baseUrl}${path}`, {
50
+ method: "POST",
51
+ headers: this.headers,
52
+ body: JSON.stringify(body),
53
+ signal: controller.signal
54
+ });
55
+ } catch (err) {
56
+ throw new BelticApiError(
57
+ 0,
58
+ "NETWORK",
59
+ `request to ${path} failed: ${err.message}`
60
+ );
61
+ } finally {
62
+ clearTimeout(timer);
63
+ }
64
+ const text = await res.text();
65
+ let json = null;
66
+ try {
67
+ json = text ? JSON.parse(text) : null;
68
+ } catch {
69
+ json = null;
70
+ }
71
+ if (!res.ok) {
72
+ const e = json?.error;
73
+ throw new BelticApiError(
74
+ res.status,
75
+ e?.code ?? `HTTP_${res.status}`,
76
+ e?.message ?? res.statusText,
77
+ e?.details,
78
+ e?.request_id
79
+ );
80
+ }
81
+ return json;
82
+ }
83
+ };
29
84
 
30
- // src/core/payment-moment.ts
31
- function summaryOf(m) {
32
- return {
33
- protocol: m.protocol,
34
- payee: m.payee,
35
- amount: { value: m.amount.value, currency: m.amount.currency },
36
- ...m.payer ? { payer: m.payer } : {}
37
- };
38
- }
85
+ // src/core/config-error.ts
86
+ var BelticConfigError = class extends Error {
87
+ code = "CONFIG";
88
+ constructor(message) {
89
+ super(`Beltic: ${message}`);
90
+ this.name = "BelticConfigError";
91
+ }
92
+ };
39
93
 
40
94
  // src/core/decision.ts
41
95
  var Decision = class _Decision {
42
- constructor(evaluation) {
43
- this.evaluation = evaluation;
96
+ constructor(output) {
97
+ this.output = output;
44
98
  }
45
99
  static ABSENT = new _Decision(null);
46
- static of(evaluation) {
47
- return new _Decision(evaluation);
100
+ static of(output) {
101
+ return new _Decision(output);
48
102
  }
49
103
  static absent() {
50
104
  return _Decision.ABSENT;
51
105
  }
52
106
  get value() {
53
- return this.evaluation?.decision ?? null;
107
+ return this.output?.decision ?? null;
54
108
  }
55
109
  get reasonCodes() {
56
- return this.evaluation?.reasonCodes ?? [];
110
+ return this.output?.reasonCodes ?? [];
57
111
  }
58
112
  get decisionId() {
59
- return this.evaluation?.decisionId ?? null;
113
+ return this.output?.decisionId ?? null;
60
114
  }
61
115
  get allowed() {
62
116
  return this.value === "ALLOW";
@@ -68,22 +122,28 @@ var Decision = class _Decision {
68
122
  return this.value === "REVIEW";
69
123
  }
70
124
  get absent() {
71
- return this.evaluation === null;
72
- }
73
- /** Whether a gate must stop the payment (GAP-52); an absent verdict never blocks. */
74
- blocks(onReview) {
75
- return this.evaluation ? Verdict.of(this.evaluation.decision).blocks(onReview) : false;
125
+ return this.output === null;
76
126
  }
77
127
  /** One sentence for the agent or the person: what Beltic said and why. */
78
128
  explain() {
79
- if (!this.evaluation) return "Beltic could not be asked about this payment.";
129
+ if (!this.output) return "Beltic could not be asked about this payment.";
80
130
  const verb = this.denied ? "denied" : this.review ? "asked for review of" : "allowed";
81
131
  const why = this.reasonCodes.length > 0 ? ` (${this.reasonCodes.join(", ")})` : "";
82
132
  return `Beltic ${verb} this payment${why}.`;
83
133
  }
84
134
  };
85
135
 
86
- // src/core/run.ts
136
+ // src/core/identity.ts
137
+ var SEED_HEX = /^[0-9a-f]{64}$/i;
138
+ function identityFromSeed(seedHex) {
139
+ if (!SEED_HEX.test(seedHex))
140
+ throw new BelticConfigError("agentSeed must be 64 hex characters (a 32-byte Ed25519 seed)");
141
+ const signer = memorySigner(fromHex(seedHex));
142
+ const did = didKeyFromEd25519(signer.publicKey);
143
+ return { did, signer: { ...signer, keyId: did } };
144
+ }
145
+
146
+ // src/core/session.ts
87
147
  var MEMORY = 256;
88
148
  var Memory = class {
89
149
  map = /* @__PURE__ */ new Map();
@@ -96,51 +156,42 @@ var Memory = class {
96
156
  if (this.map.size > MEMORY) this.map.delete(this.map.keys().next().value);
97
157
  }
98
158
  };
99
- var Run = class _Run {
159
+ var Session = class _Session {
100
160
  constructor(deps, key, opts = {}) {
101
161
  this.deps = deps;
102
162
  this.key = key;
103
163
  this.opts = opts;
164
+ this.openedWith = opts.intent ? _Session.hash(opts.intent) : null;
104
165
  }
105
166
  opened = null;
106
167
  current = null;
107
- /** JCS hash of the mandate on the chain, and of the one the open input carried. */
168
+ /** JCS hash of the mandate on the chain, and of the one the options carried. */
108
169
  declared = null;
109
- openedWith = null;
170
+ openedWith;
110
171
  closed = false;
111
172
  timer = null;
112
173
  calls = new Memory();
113
174
  decisions = new Memory();
114
- byPayment = new Memory();
115
- /** The session this run records into — opened on first use, `null` when there is none. */
116
- session() {
117
- const next = this.deps.sessions.open(this.key, this.opener);
175
+ /** The platform's id for this session — opened on first use, `null` while there is none. */
176
+ id() {
177
+ return this.stream().then((stream) => stream?.id ?? null);
178
+ }
179
+ /**
180
+ * The stream this session records into — opened on first use, `null`
181
+ * when there is none. For the integrations; a host never holds it.
182
+ * @internal
183
+ */
184
+ stream() {
185
+ const next = this.deps.streams.open(this.key, this.opts);
118
186
  this.opened = next;
119
- return next.then((session) => {
120
- if (session !== this.current) {
121
- this.current = session;
187
+ return next.then((stream) => {
188
+ if (stream !== this.current) {
189
+ this.current = stream;
122
190
  this.declared = this.openedWith;
123
191
  }
124
- return session;
192
+ return stream;
125
193
  });
126
194
  }
127
- opener = async () => {
128
- const open = this.opts.open;
129
- const input = typeof open === "function" ? await open() : open ?? {};
130
- this.openedWith = input.intent ? _Run.hash(input.intent) : null;
131
- return input;
132
- };
133
- /** `intent.declared`, unless the mandate is the one already on the chain. */
134
- async declare(intent) {
135
- const session = await this.session();
136
- if (!session) return false;
137
- const hash = _Run.hash(intent);
138
- if (hash === this.declared) return false;
139
- const ok = await session.emit("intent.declared", intent);
140
- if (ok) this.declared = hash;
141
- this.touch();
142
- return ok;
143
- }
144
195
  /**
145
196
  * The platform's verdict on a payment about to be presented. Asked once
146
197
  * per call id: a host that re-runs its approval step reads the same
@@ -150,23 +201,21 @@ var Run = class _Run {
150
201
  async decide(payment, opts = {}) {
151
202
  const known = opts.callId ? this.decisions.get(opts.callId) : void 0;
152
203
  if (known) return known;
153
- const session = await this.session();
154
- if (!session) return Decision.absent();
155
- if (opts.intent) await this.declare(opts.intent);
204
+ const stream = await this.stream();
205
+ if (!stream) return Decision.absent();
206
+ if (opts.intent) await this.declare(stream, opts.intent);
156
207
  const summary = summaryOf(payment);
157
- const evaluation = await this.deps.evaluate(session.id, summary);
158
- if (!evaluation) return Decision.absent();
159
- const decision = Decision.of(evaluation);
208
+ const decision = await this.deps.evaluate(stream.id, summary);
209
+ if (decision.absent) return decision;
160
210
  if (opts.callId) this.decisions.set(opts.callId, decision);
161
- for (const key of _Run.paymentKeys(summary)) this.byPayment.set(key, decision);
162
211
  const call = opts.callId ? this.calls.get(opts.callId)?.call : void 0;
163
- await session.emit("gateway.decision", {
212
+ await stream.emit("gateway.decision", {
164
213
  gateway: "beltic",
165
- call: _Run.callOf(call),
166
- decision: evaluation.decision,
167
- reasonCodes: [...evaluation.reasonCodes],
214
+ call: _Session.callOf(call),
215
+ decision: decision.value,
216
+ reasonCodes: [...decision.reasonCodes],
168
217
  record: {
169
- decisionId: evaluation.decisionId,
218
+ decisionId: decision.decisionId,
170
219
  callId: opts.callId ?? null,
171
220
  payment: summary
172
221
  }
@@ -178,24 +227,12 @@ var Run = class _Run {
178
227
  decision(callId) {
179
228
  return this.decisions.get(callId) ?? Decision.absent();
180
229
  }
181
- /**
182
- * The decision given for a payment with the same comparable core (payee,
183
- * amount, payer — or payee and amount when one side names no payer), or
184
- * absent.
185
- */
186
- decisionFor(payment) {
187
- for (const key of _Run.paymentKeys(summaryOf(payment))) {
188
- const known = this.byPayment.get(key);
189
- if (known) return known;
190
- }
191
- return Decision.absent();
192
- }
193
230
  /** A tool call the host runs itself, reported as two events by its own call id. */
194
231
  tools = {
195
232
  start: async (call) => {
196
- const session = await this.session();
197
- if (!session) return false;
198
- const span = session.toolCall(call);
233
+ const stream = await this.stream();
234
+ if (!stream) return false;
235
+ const span = stream.toolCall(call);
199
236
  this.calls.set(call.callId, { call, span });
200
237
  this.touch();
201
238
  return span.opened;
@@ -215,11 +252,11 @@ var Run = class _Run {
215
252
  };
216
253
  /** A person's answer about a call, as the decision it was (GAP-75). */
217
254
  async humanDecided(callId, input) {
218
- const session = await this.session();
219
- if (!session) return false;
220
- const ok = await session.emit("gateway.decision", {
255
+ const stream = await this.stream();
256
+ if (!stream) return false;
257
+ const ok = await stream.emit("gateway.decision", {
221
258
  gateway: "human",
222
- call: _Run.callOf(this.calls.get(callId)?.call),
259
+ call: _Session.callOf(this.calls.get(callId)?.call),
223
260
  decision: input.allowed ? "ALLOW" : "DENY",
224
261
  reasonCodes: [`USER_${input.outcome.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`],
225
262
  record: {
@@ -237,8 +274,14 @@ var Run = class _Run {
237
274
  this.closed = true;
238
275
  if (this.timer) clearTimeout(this.timer);
239
276
  this.deps.onClosed(this);
240
- const session = this.opened ? await this.opened.catch(() => null) : null;
241
- await session?.close(reason);
277
+ const stream = this.opened ? await this.opened.catch(() => null) : null;
278
+ await stream?.close(reason);
279
+ }
280
+ /** `intent.declared`, unless the mandate is the one already on the chain (GAP-76). */
281
+ async declare(stream, intent) {
282
+ const hash = _Session.hash(intent);
283
+ if (hash === this.declared) return;
284
+ if (await stream.emit("intent.declared", intent)) this.declared = hash;
242
285
  }
243
286
  take(callId) {
244
287
  const known = this.calls.get(callId);
@@ -256,121 +299,420 @@ var Run = class _Run {
256
299
  static hash(intent) {
257
300
  return toHex(sha256(canonicalBytes(intent)));
258
301
  }
259
- /** With the payer first, then without it. */
260
- static paymentKeys(summary) {
261
- const { payer, ...core } = summary;
262
- return payer ? [canonicalize(summary), canonicalize(core)] : [canonicalize(core)];
263
- }
264
302
  static callOf(call) {
265
303
  return call ? { tool: call.toolName, args: call.input } : { tool: "unknown" };
266
304
  }
267
305
  };
268
306
 
307
+ // src/core/stream.ts
308
+ var Stream = class {
309
+ constructor(deps, id, source, born) {
310
+ this.deps = deps;
311
+ this.id = id;
312
+ this.source = source;
313
+ this.born = born;
314
+ this.chain = Chain.genesis(id, source);
315
+ }
316
+ chain;
317
+ building = Promise.resolve();
318
+ dropped = 0;
319
+ droppedFirstTs = null;
320
+ droppedLastTs = null;
321
+ closed = false;
322
+ get isClosed() {
323
+ return this.closed;
324
+ }
325
+ /**
326
+ * Resolves once the event is sequenced and buffered — not once it is
327
+ * acknowledged. `false` when the event was dropped for lack of room.
328
+ */
329
+ async emit(kind, payload) {
330
+ const halted = this.deps.transport.haltedError(this.id, this.source);
331
+ if (halted) throw halted;
332
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
333
+ if (!this.deps.transport.hasRoom()) {
334
+ this.dropped++;
335
+ this.droppedFirstTs ??= ts;
336
+ this.droppedLastTs = ts;
337
+ return false;
338
+ }
339
+ if (this.dropped > 0) {
340
+ this.deps.transport.enqueue(
341
+ await this.next(
342
+ "transport.gap",
343
+ { dropped: this.dropped, firstTs: this.droppedFirstTs, lastTs: this.droppedLastTs },
344
+ ts
345
+ )
346
+ );
347
+ this.dropped = 0;
348
+ this.droppedFirstTs = this.droppedLastTs = null;
349
+ }
350
+ this.deps.transport.enqueue(await this.next(kind, payload, ts));
351
+ return true;
352
+ }
353
+ /** The tool call whose `execute` the host runs itself; see `ToolCallSpan`. */
354
+ toolCall(call) {
355
+ const { callId, ...start } = call;
356
+ return openCall(this, "tool_call", callId, { transport: "local", ...start });
357
+ }
358
+ async close(reason = "completed", extra = {}) {
359
+ if (this.closed) return;
360
+ this.closed = true;
361
+ await this.emit("session.close", { reason, ...extra });
362
+ await this.flush();
363
+ this.deps.onClosed?.(this);
364
+ }
365
+ /** Read-your-writes: the platform must hold the evidence before anyone judges it (GAP-16/66). */
366
+ flush() {
367
+ return this.deps.transport.flush();
368
+ }
369
+ /** Serialized: two concurrent emits get consecutive seqs, never the same one. */
370
+ next(kind, payload, ts) {
371
+ const run = this.building.then(async () => {
372
+ const built = await this.chain.append({ ts, kind, payload }, this.deps.signer);
373
+ this.chain = built.chain;
374
+ return built.event;
375
+ });
376
+ this.building = run.catch(() => void 0);
377
+ return run;
378
+ }
379
+ };
380
+
381
+ // src/core/transport.ts
382
+ var FLUSH_MS = 1e3;
383
+ var MAX_BATCH = 50;
384
+ var MAX_BUFFERED = 5e3;
385
+ var BACKOFF_BASE_MS = 200;
386
+ var BACKOFF_MAX_MS = 3e4;
387
+ var ChainRejectedError = class extends Error {
388
+ constructor(sessionId, source, result, options) {
389
+ super(
390
+ `chain ${sessionId}:${source} halted at seq ${result.seq}: ${result.status}${result.code ? ` ${result.code}` : ""}`,
391
+ options
392
+ );
393
+ this.sessionId = sessionId;
394
+ this.source = source;
395
+ this.result = result;
396
+ this.name = "ChainRejectedError";
397
+ }
398
+ };
399
+ var TransportClosedError = class extends Error {
400
+ constructor() {
401
+ super("transport is closed");
402
+ this.name = "TransportClosedError";
403
+ }
404
+ };
405
+ var Transport = class _Transport {
406
+ constructor(api) {
407
+ this.api = api;
408
+ }
409
+ chains = /* @__PURE__ */ new Map();
410
+ buffered = 0;
411
+ timer = null;
412
+ closed = false;
413
+ /**
414
+ * What the fail-open entries absorb (GAP-70): the platform could not be
415
+ * reached or failed on its side — a network error, a 5xx, a 429.
416
+ * Everything the platform *rejected* (a 4xx: bad key, unknown session,
417
+ * invalid payload) is a fault of the client and throws.
418
+ */
419
+ static outage(err) {
420
+ return err instanceof BelticApiError && err.retryable;
421
+ }
422
+ get size() {
423
+ return this.buffered;
424
+ }
425
+ hasRoom() {
426
+ return !this.closed && this.buffered < MAX_BUFFERED;
427
+ }
428
+ haltedError(sessionId, source) {
429
+ return this.chains.get(`${sessionId}:${source}`)?.halted ?? null;
430
+ }
431
+ /** Callers check `hasRoom()` first and assign `seq` only then (GAP-38). */
432
+ enqueue(ev) {
433
+ if (this.closed) throw new TransportClosedError();
434
+ const key = `${ev.sessionId}:${ev.source}`;
435
+ let chain = this.chains.get(key);
436
+ if (!chain) {
437
+ chain = { pending: [], inFlight: null, retry: null, attempts: 0, halted: null };
438
+ this.chains.set(key, chain);
439
+ }
440
+ if (chain.halted) throw chain.halted;
441
+ if (!this.hasRoom()) throw new Error("transport buffer is full");
442
+ chain.pending.push(ev);
443
+ this.buffered++;
444
+ if (chain.pending.length >= MAX_BATCH) void this.deliver(chain, false);
445
+ else this.schedule();
446
+ }
447
+ /** One attempt per chain, now — a chain waiting out its backoff included; resolves once every attempt settled. */
448
+ async flush() {
449
+ this.unschedule();
450
+ await Promise.all([...this.chains.values()].map((chain) => this.deliver(chain, true)));
451
+ }
452
+ async close() {
453
+ await this.flush();
454
+ this.closed = true;
455
+ for (const chain of this.chains.values()) {
456
+ if (chain.retry) clearTimeout(chain.retry);
457
+ chain.retry = null;
458
+ }
459
+ }
460
+ schedule() {
461
+ if (this.timer) return;
462
+ this.timer = setTimeout(() => {
463
+ this.timer = null;
464
+ for (const chain of this.chains.values()) void this.deliver(chain, false);
465
+ }, FLUSH_MS);
466
+ this.timer.unref?.();
467
+ }
468
+ unschedule() {
469
+ if (!this.timer) return;
470
+ clearTimeout(this.timer);
471
+ this.timer = null;
472
+ }
473
+ /** A chain waiting out its backoff is left alone unless forced: only `flush` cuts a backoff short. */
474
+ deliver(chain, force) {
475
+ if (chain.inFlight) return chain.inFlight;
476
+ if (chain.halted || chain.pending.length === 0) return Promise.resolve();
477
+ if (chain.retry) {
478
+ if (!force) return Promise.resolve();
479
+ clearTimeout(chain.retry);
480
+ chain.retry = null;
481
+ }
482
+ chain.inFlight = this.attempt(chain).finally(() => {
483
+ chain.inFlight = null;
484
+ });
485
+ return chain.inFlight;
486
+ }
487
+ /** Batches until the chain drains; a retryable failure schedules the next attempt and returns. */
488
+ async attempt(chain) {
489
+ while (chain.pending.length > 0 && !chain.halted) {
490
+ const batch = chain.pending.slice(0, MAX_BATCH);
491
+ const first = batch[0];
492
+ let ack;
493
+ try {
494
+ ack = await this.api.post("/v1/evidence", batch);
495
+ } catch (err) {
496
+ console.error("[beltic]", err);
497
+ if (!_Transport.outage(err)) {
498
+ this.halt(
499
+ chain,
500
+ new ChainRejectedError(
501
+ first.sessionId,
502
+ first.source,
503
+ {
504
+ index: 0,
505
+ sessionId: first.sessionId,
506
+ source: first.source,
507
+ seq: first.seq,
508
+ status: "rejected",
509
+ code: "DELIVERY_FAILED"
510
+ },
511
+ { cause: err }
512
+ )
513
+ );
514
+ return;
515
+ }
516
+ const delay = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** chain.attempts) * (0.5 + Math.random() / 2);
517
+ chain.attempts++;
518
+ chain.retry = setTimeout(() => {
519
+ chain.retry = null;
520
+ void this.deliver(chain, false);
521
+ }, delay);
522
+ chain.retry.unref?.();
523
+ return;
524
+ }
525
+ chain.pending.splice(0, batch.length);
526
+ this.buffered -= batch.length;
527
+ chain.attempts = 0;
528
+ const bad = ack.results.find((r) => r.status === "fork" || r.status === "rejected");
529
+ if (bad) this.halt(chain, new ChainRejectedError(first.sessionId, first.source, bad));
530
+ }
531
+ }
532
+ halt(chain, error) {
533
+ chain.halted = error;
534
+ this.buffered -= chain.pending.length;
535
+ chain.pending = [];
536
+ console.error("[beltic]", error);
537
+ }
538
+ };
539
+
540
+ // src/core/streams.ts
541
+ var OPEN_RETRY_MS = 6e4;
542
+ var Streams = class {
543
+ constructor(deps) {
544
+ this.deps = deps;
545
+ }
546
+ attached = /* @__PURE__ */ new Map();
547
+ /** Buyer streams by the host's own key (GAP-71). */
548
+ opened = /* @__PURE__ */ new Map();
549
+ retryAt = 0;
550
+ /**
551
+ * Buyer half: the stream for a key of the host's own, opened on first
552
+ * use and reused after. A halted chain is reopened as a fresh session
553
+ * that continues the same key; a closed key is forgotten.
554
+ */
555
+ open(key, input = {}) {
556
+ const prior = this.opened.get(key) ?? Promise.resolve(null);
557
+ const next = prior.catch(() => null).then(
558
+ (stream) => stream && !stream.isClosed && !this.deps.transport.haltedError(stream.id, stream.source) ? stream : this.openFresh(input, () => this.forget(key, next))
559
+ );
560
+ this.opened.set(key, next);
561
+ next.catch(() => this.forget(key, next));
562
+ return next;
563
+ }
564
+ /** Seller half: emit INTERNAL_NETWORK evidence into a session the buyer bound, or open a seller-born one. */
565
+ async ensure(sessionId) {
566
+ if (sessionId) return this.attach(sessionId, "INTERNAL_NETWORK", "buyer");
567
+ const out = await this.deps.api.post("/v1/sessions", {
568
+ source: "INTERNAL_NETWORK"
569
+ });
570
+ return this.attach(out.sessionId, "INTERNAL_NETWORK", "seller");
571
+ }
572
+ forget(key, entry) {
573
+ if (this.opened.get(key) === entry) this.opened.delete(key);
574
+ }
575
+ async openFresh(input, onClosed) {
576
+ const identity = this.deps.identity;
577
+ if (!identity)
578
+ throw new BelticConfigError(
579
+ "beltic.session needs an agent seed (new Beltic({ agentSeed }) or BELTIC_AGENT_SEED)"
580
+ );
581
+ if (Date.now() < this.retryAt) return null;
582
+ try {
583
+ const stream = await this.create(identity, input, onClosed);
584
+ this.retryAt = 0;
585
+ return stream;
586
+ } catch (err) {
587
+ if (!Transport.outage(err)) throw err;
588
+ this.retryAt = Date.now() + OPEN_RETRY_MS;
589
+ console.error("[beltic]", err);
590
+ return null;
591
+ }
592
+ }
593
+ async create(identity, input, onClosed) {
594
+ const body = {
595
+ source: "AGENT_TRACE",
596
+ agent: { did: identity.did, credential: identity.did },
597
+ ...input.intent ? { intent: input.intent } : {}
598
+ };
599
+ const out = await this.deps.api.post("/v1/sessions", body);
600
+ const stream = this.attach(out.sessionId, "AGENT_TRACE", "buyer", onClosed);
601
+ await stream.emit("session.open", {
602
+ runtime: {
603
+ sdk: "@belticlabs/agent-risk-sdk",
604
+ version: this.deps.sdkVersion,
605
+ ...input.runtime
606
+ },
607
+ ...input.attestations ? { attestations: input.attestations } : {}
608
+ });
609
+ if (input.intent) await stream.emit("intent.declared", input.intent);
610
+ return stream;
611
+ }
612
+ attach(id, source, born, onClosed) {
613
+ const key = `${id}:${source}`;
614
+ const existing = this.attached.get(key);
615
+ if (existing) return existing;
616
+ const stream = new Stream(
617
+ {
618
+ transport: this.deps.transport,
619
+ signer: source === "AGENT_TRACE" ? this.deps.identity?.signer : void 0,
620
+ onClosed: () => {
621
+ this.attached.delete(key);
622
+ onClosed?.();
623
+ }
624
+ },
625
+ id,
626
+ source,
627
+ born
628
+ );
629
+ this.attached.set(key, stream);
630
+ return stream;
631
+ }
632
+ };
633
+
269
634
  // src/client.ts
270
- var SDK_VERSION = "0.5.0";
271
- var ENV_REQUIRED = ["BELTIC_API_KEY", "BELTIC_BASE_URL", "BELTIC_AGENT_SEED"];
272
- var ENV_CREDENTIAL = "BELTIC_AGENT_CREDENTIAL";
635
+ var SDK_VERSION = "0.6.0";
273
636
  var Beltic = class _Beltic {
274
- transport;
275
- sessions;
276
- identity;
277
- onReview;
278
- failOpen;
637
+ /** The stream registry, for the protocol adapters. @internal */
638
+ streams;
279
639
  api;
280
- onError;
281
- runs = /* @__PURE__ */ new Map();
640
+ transport;
641
+ sessions = /* @__PURE__ */ new Map();
282
642
  /**
283
- * The client the environment describes: `BELTIC_API_KEY`,
284
- * `BELTIC_BASE_URL`, `BELTIC_AGENT_SEED` (64 hex) and optionally
285
- * `BELTIC_AGENT_CREDENTIAL`. Any of the three missing is a configuration
286
- * error, thrown (GAP-78).
643
+ * The client the environment describes: `BELTIC_API_KEY` and
644
+ * `BELTIC_BASE_URL`, both required, and `BELTIC_AGENT_SEED` (64 hex) for
645
+ * the buyer half. A missing required variable is a configuration error,
646
+ * thrown (GAP-78).
287
647
  */
288
- static fromEnv(env = _Beltic.processEnv(), opts = {}) {
289
- const missing = ENV_REQUIRED.filter((name) => !env[name]);
648
+ static fromEnv(env = _Beltic.processEnv()) {
649
+ const missing = ["BELTIC_API_KEY", "BELTIC_BASE_URL"].filter((name) => !env[name]);
290
650
  if (missing.length > 0)
291
- throw new BelticConfigError(
292
- `${missing.join(", ")} missing \u2014 fromEnv needs ${ENV_REQUIRED.join(", ")}`
293
- );
651
+ throw new BelticConfigError(`${missing.join(", ")} missing from the environment`);
294
652
  return new _Beltic({
295
- ...opts,
296
653
  apiKey: env.BELTIC_API_KEY,
297
654
  baseUrl: env.BELTIC_BASE_URL,
298
- identity: identityFromSeed(fromHex(env.BELTIC_AGENT_SEED), env[ENV_CREDENTIAL])
655
+ agentSeed: env.BELTIC_AGENT_SEED
299
656
  });
300
657
  }
301
658
  constructor(opts) {
302
659
  if (!opts.apiKey) throw new BelticConfigError("apiKey is required");
303
660
  if (!URL.canParse(opts.baseUrl))
304
661
  throw new BelticConfigError(`baseUrl is not a URL: ${JSON.stringify(opts.baseUrl)}`);
305
- this.failOpen = opts.failOpen ?? false;
306
- this.onError = opts.onError ?? ((err) => console.error("[beltic]", err));
307
- this.api = new ApiClient({
308
- baseUrl: opts.baseUrl,
309
- apiKey: opts.apiKey,
310
- fetch: opts.fetch,
311
- userAgent: `@belticlabs/agent-risk-sdk/${SDK_VERSION}`
312
- });
313
- this.transport = new Transport(this.api, {
314
- ...opts.transport,
315
- onError: this.onError,
316
- onChainHalted: this.onError
317
- });
318
- this.sessions = new Sessions({
662
+ this.api = new ApiClient(
663
+ opts.baseUrl,
664
+ opts.apiKey,
665
+ `@belticlabs/agent-risk-sdk/${SDK_VERSION}`
666
+ );
667
+ this.transport = new Transport(this.api);
668
+ this.streams = new Streams({
319
669
  api: this.api,
320
670
  transport: this.transport,
321
- sdkVersion: SDK_VERSION,
322
- identity: opts.identity,
323
- failOpen: this.failOpen,
324
- onError: this.onError,
325
- openRetryMs: opts.openRetryMs
671
+ identity: opts.agentSeed ? identityFromSeed(opts.agentSeed) : null,
672
+ sdkVersion: SDK_VERSION
326
673
  });
327
- this.identity = opts.identity;
328
- this.onReview = opts.onReview ?? "abort";
329
- }
330
- /**
331
- * The platform's verdict on a payment — the seller's before it verifies,
332
- * the buyer's before it presents. Read-your-writes: the buffered evidence
333
- * is flushed first so the platform judges what the caller already saw
334
- * (GAP-16). A recorded moment is accepted as is: only its comparable core
335
- * (payee, amount, payer) is sent. `null` only under `failOpen`, when the
336
- * platform could not be reached.
337
- */
338
- async evaluate(sessionId, payment) {
339
- const input = { sessionId, payment: summaryOf(payment) };
340
- try {
341
- return await this.decide(input);
342
- } catch (err) {
343
- if (!this.failOpen || !Transport.outage(err)) throw err;
344
- this.onError(err);
345
- return null;
346
- }
347
- }
348
- async decide(input) {
349
- await this.transport.flush();
350
- const out = await this.api.post("/v1/evaluate", input);
351
- return { ...out, verdict: Verdict.of(out.decision) };
352
674
  }
353
675
  /**
354
- * The run for a key of the host's own one object per key until it
355
- * closes (the options count on the first call only). See `Run`.
676
+ * The session for a key of the host's own (its session, run or
677
+ * conversation id) one object per key until it closes; the options
678
+ * count on the first call only. See `Session`.
356
679
  */
357
- run(key, opts = {}) {
358
- const existing = this.runs.get(key);
680
+ session(key, opts = {}) {
681
+ const existing = this.sessions.get(key);
359
682
  if (existing) return existing;
360
- const run = new Run(
683
+ const session = new Session(
361
684
  {
362
- sessions: this.sessions,
685
+ streams: this.streams,
363
686
  evaluate: (sessionId, payment) => this.evaluate(sessionId, payment),
364
687
  onClosed: (closed) => {
365
- if (this.runs.get(key) === closed) this.runs.delete(key);
688
+ if (this.sessions.get(key) === closed) this.sessions.delete(key);
366
689
  }
367
690
  },
368
691
  key,
369
692
  opts
370
693
  );
371
- this.runs.set(key, run);
372
- return run;
694
+ this.sessions.set(key, session);
695
+ return session;
696
+ }
697
+ /**
698
+ * The platform's verdict on a payment — the seller's before it verifies,
699
+ * the buyer's before it presents (Fraud SDK RFC › Evaluation Client).
700
+ * Read-your-writes: the buffered evidence is flushed first so the
701
+ * platform judges what the caller already saw (GAP-16). Absent when the
702
+ * platform could not be reached (GAP-70).
703
+ */
704
+ async evaluate(sessionId, payment) {
705
+ const input = { sessionId, payment };
706
+ try {
707
+ await this.transport.flush();
708
+ return Decision.of(await this.api.post("/v1/evaluate", input));
709
+ } catch (err) {
710
+ if (!Transport.outage(err)) throw err;
711
+ console.error("[beltic]", err);
712
+ return Decision.absent();
713
+ }
373
714
  }
715
+ /** Send everything buffered now and wait for that attempt. */
374
716
  flush() {
375
717
  return this.transport.flush();
376
718
  }
@@ -383,15 +725,11 @@ var Beltic = class _Beltic {
383
725
  }
384
726
  };
385
727
  export {
386
- ApiClient,
387
728
  Beltic,
388
729
  BelticApiError,
389
730
  BelticConfigError,
390
731
  ChainRejectedError,
391
732
  Decision,
392
- Run,
393
733
  SDK_VERSION,
394
- Session,
395
- Transport,
396
- identityFromSeed
734
+ Session
397
735
  };