@absol-labs/agent 0.7.2 → 0.8.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/README.md +19 -4
- package/dist/gateway/caller-auth-gateway.d.ts +103 -2
- package/dist/gateway/caller-auth-gateway.d.ts.map +1 -1
- package/dist/gateway/caller-auth-gateway.js +176 -19
- package/dist/gateway/caller-auth-gateway.js.map +1 -1
- package/dist/gateway/http-server.d.ts +12 -0
- package/dist/gateway/http-server.d.ts.map +1 -1
- package/dist/gateway/http-server.js +45 -1
- package/dist/gateway/http-server.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/wallet/autonomous-wallet-store.d.ts +123 -0
- package/dist/wallet/autonomous-wallet-store.d.ts.map +1 -0
- package/dist/wallet/autonomous-wallet-store.js +318 -0
- package/dist/wallet/autonomous-wallet-store.js.map +1 -0
- package/dist/wallet/autonomous-wallet.d.ts +14 -39
- package/dist/wallet/autonomous-wallet.d.ts.map +1 -1
- package/dist/wallet/autonomous-wallet.js +12 -145
- package/dist/wallet/autonomous-wallet.js.map +1 -1
- package/dist/wallet/encrypted-file-credential-store.d.ts +41 -0
- package/dist/wallet/encrypted-file-credential-store.d.ts.map +1 -0
- package/dist/wallet/encrypted-file-credential-store.js +221 -0
- package/dist/wallet/encrypted-file-credential-store.js.map +1 -0
- package/dist/wallet/provider.d.ts.map +1 -1
- package/dist/wallet/provider.js +22 -2
- package/dist/wallet/provider.js.map +1 -1
- package/dist/wallet/secret-service-probe.d.ts +56 -0
- package/dist/wallet/secret-service-probe.d.ts.map +1 -0
- package/dist/wallet/secret-service-probe.js +407 -0
- package/dist/wallet/secret-service-probe.js.map +1 -0
- package/package.json +1 -1
- package/src/gateway/caller-auth-gateway.ts +281 -15
- package/src/gateway/http-server.ts +64 -0
- package/src/index.ts +19 -0
- package/src/wallet/autonomous-wallet-store.ts +487 -0
- package/src/wallet/autonomous-wallet.ts +57 -224
- package/src/wallet/encrypted-file-credential-store.ts +341 -0
- package/src/wallet/provider.ts +23 -1
- package/src/wallet/secret-service-probe.ts +487 -0
|
@@ -22,6 +22,36 @@ import {
|
|
|
22
22
|
* called. Revocation is automatic: once a stream is closed/expired/reclaimed,
|
|
23
23
|
* the on-chain read step below fails closed and access stops with no extra
|
|
24
24
|
* bookkeeping.
|
|
25
|
+
*
|
|
26
|
+
* ## Stream state must never be trusted from a single unverified view (#87)
|
|
27
|
+
*
|
|
28
|
+
* A capability presented seconds after the buyer's `close()` was mined was
|
|
29
|
+
* served, because authorization read the stream from ONE unpinned `latest`
|
|
30
|
+
* view and believed it unconditionally: the exposure window was exactly
|
|
31
|
+
* "however stale that view happened to be" - unbounded and unobservable.
|
|
32
|
+
* Provider replica lag, an unsafe-head reorg, provider response caching, or a
|
|
33
|
+
* cache added later for performance all land in the same failure mode, and it
|
|
34
|
+
* fails OPEN on the one transition that must be immediate.
|
|
35
|
+
*
|
|
36
|
+
* Three fail-closed properties remove that trust:
|
|
37
|
+
*
|
|
38
|
+
* 1. **Corroboration** - every configured view is read (bounded: one read per
|
|
39
|
+
* view, in parallel) and the request is authorized ONLY if EVERY view
|
|
40
|
+
* authorizes. Any view reporting a non-authorizable stream - or failing to
|
|
41
|
+
* read at all - denies. Independent endpoints go stale independently, so
|
|
42
|
+
* configuring one corroborating RPC collapses the window to the freshest of
|
|
43
|
+
* them.
|
|
44
|
+
* 2. **Freshness bound** - a view that reports the block it observed
|
|
45
|
+
* ({@link GatewayStreamView.observedAt}) is rejected outright when that
|
|
46
|
+
* block is older than {@link CallerAuthGatewayConfig.maxStateStalenessSeconds},
|
|
47
|
+
* turning silent staleness into an explicit `stream-state-stale` denial.
|
|
48
|
+
* 3. **Monotonic revocation** - `StreamEscrowV2` only ever assigns
|
|
49
|
+
* `status = Closed` (never resets it), only ever decreases `deposit` (there
|
|
50
|
+
* is no top-up), and fixes `expiresAt` at open. "Not active", "expired" and
|
|
51
|
+
* "fully claimed" are therefore irreversible, so an observed terminal state
|
|
52
|
+
* is remembered and a later view reporting ACTIVE - which is provably stale
|
|
53
|
+
* - can never re-authorize. This is the only correctness-preserving caching
|
|
54
|
+
* direction here: negative, never positive.
|
|
25
55
|
*/
|
|
26
56
|
|
|
27
57
|
/** Machine-readable, closed set of rejection reasons - every failure mode is distinct. */
|
|
@@ -38,7 +68,49 @@ export type CallerAuthDenialReason =
|
|
|
38
68
|
| "buyer-mismatch"
|
|
39
69
|
| "stream-not-active"
|
|
40
70
|
| "stream-expired"
|
|
41
|
-
| "stream-not-funded"
|
|
71
|
+
| "stream-not-funded"
|
|
72
|
+
| "stream-state-stale";
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Default freshness bound for on-chain stream state, in seconds. Base blocks
|
|
76
|
+
* are ~2s, so a healthy endpoint is a few seconds stale at most; this is a
|
|
77
|
+
* generous ceiling whose job is to reject GROSS staleness rather than to
|
|
78
|
+
* micro-manage a healthy provider.
|
|
79
|
+
*/
|
|
80
|
+
export const DEFAULT_MAX_STATE_STALENESS_SECONDS = 30;
|
|
81
|
+
|
|
82
|
+
/** Maximum corroborating views the bounded hot path accepts (excludes the primary). */
|
|
83
|
+
export const MAX_CORROBORATING_STREAM_READERS = 3;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Denial precedence when views disagree: the most authoritative statement about
|
|
87
|
+
* the stream wins, so a single fresh "closed" is never masked by another view's
|
|
88
|
+
* read error or staleness.
|
|
89
|
+
*/
|
|
90
|
+
const DENIAL_PRECEDENCE: readonly CallerAuthDenialReason[] = [
|
|
91
|
+
"stream-not-active",
|
|
92
|
+
"stream-expired",
|
|
93
|
+
"buyer-mismatch",
|
|
94
|
+
"service-ref-mismatch",
|
|
95
|
+
"stream-not-funded",
|
|
96
|
+
"stream-state-stale",
|
|
97
|
+
"stream-not-found",
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Denials that reflect an IRREVERSIBLE on-chain state (see the class docs):
|
|
102
|
+
* once observed, they hold forever, so they are remembered and a stale ACTIVE
|
|
103
|
+
* read can never re-authorize.
|
|
104
|
+
*
|
|
105
|
+
* Deliberately limited to states derived PURELY from on-chain values. Expiry is
|
|
106
|
+
* equally irreversible but is derived from the local clock, and a skewed clock
|
|
107
|
+
* must not be able to permanently revoke a live stream - expiry re-denies on
|
|
108
|
+
* every request anyway, at no extra cost.
|
|
109
|
+
*/
|
|
110
|
+
const TERMINAL_DENIALS: ReadonlySet<CallerAuthDenialReason> = new Set([
|
|
111
|
+
"stream-not-active",
|
|
112
|
+
"stream-not-funded",
|
|
113
|
+
]);
|
|
42
114
|
|
|
43
115
|
export interface CallerAuthDecision {
|
|
44
116
|
readonly authorized: boolean;
|
|
@@ -59,6 +131,18 @@ export interface GatewayStreamView {
|
|
|
59
131
|
readonly claimedCumulative: bigint;
|
|
60
132
|
readonly expiresAt: number;
|
|
61
133
|
readonly status: "active" | "closed";
|
|
134
|
+
/**
|
|
135
|
+
* The chain head this view was observed against. Readers SHOULD report it:
|
|
136
|
+
* when present, the gateway rejects the view outright if the block is older
|
|
137
|
+
* than its freshness bound (`stream-state-stale`) instead of trusting
|
|
138
|
+
* unbounded staleness. Optional only so `StreamReader` implementations
|
|
139
|
+
* predating this field keep working; the default SDK reader always reports it.
|
|
140
|
+
*/
|
|
141
|
+
readonly observedAt?: {
|
|
142
|
+
readonly blockNumber: bigint;
|
|
143
|
+
/** Unix seconds of the observed block. */
|
|
144
|
+
readonly blockTimestamp: number;
|
|
145
|
+
};
|
|
62
146
|
}
|
|
63
147
|
|
|
64
148
|
/** Pluggable on-chain stream reader, so the gateway is testable without a live RPC. */
|
|
@@ -66,6 +150,48 @@ export interface StreamReader {
|
|
|
66
150
|
getStream(streamId: Hex): Promise<GatewayStreamView>;
|
|
67
151
|
}
|
|
68
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Remembers streams observed in an irreversible terminal state, so a later
|
|
155
|
+
* stale view cannot re-authorize them. Negative-only by construction: nothing
|
|
156
|
+
* that would GRANT access is ever cached.
|
|
157
|
+
*/
|
|
158
|
+
export interface StreamRevocationSet {
|
|
159
|
+
/** The remembered terminal denial for this stream, if any. */
|
|
160
|
+
get(streamId: Hex): CallerAuthDenialReason | undefined;
|
|
161
|
+
/** Records an irreversible terminal denial for this stream. */
|
|
162
|
+
record(streamId: Hex, reason: CallerAuthDenialReason): void;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* In-memory {@link StreamRevocationSet}. Bounded (oldest entries are evicted
|
|
167
|
+
* past {@link maxEntries}) so a hostile caller cannot grow it without limit;
|
|
168
|
+
* eviction is safe because the on-chain read still denies an evicted stream -
|
|
169
|
+
* it only loses the short-circuit.
|
|
170
|
+
*/
|
|
171
|
+
export class InMemoryStreamRevocationSet implements StreamRevocationSet {
|
|
172
|
+
private readonly revoked = new Map<string, CallerAuthDenialReason>();
|
|
173
|
+
|
|
174
|
+
constructor(private readonly maxEntries = 10_000) {}
|
|
175
|
+
|
|
176
|
+
get(streamId: Hex): CallerAuthDenialReason | undefined {
|
|
177
|
+
return this.revoked.get(streamId.toLowerCase());
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
record(streamId: Hex, reason: CallerAuthDenialReason): void {
|
|
181
|
+
const key = streamId.toLowerCase();
|
|
182
|
+
if (this.revoked.has(key)) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (this.revoked.size >= this.maxEntries) {
|
|
186
|
+
const oldest = this.revoked.keys().next();
|
|
187
|
+
if (!oldest.done) {
|
|
188
|
+
this.revoked.delete(oldest.value);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
this.revoked.set(key, reason);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
69
195
|
export class UnknownStreamGatewayError extends Error {
|
|
70
196
|
constructor(message: string, options?: { readonly cause?: unknown }) {
|
|
71
197
|
super(message, options);
|
|
@@ -85,7 +211,14 @@ export function createSdkStreamReader(options: {
|
|
|
85
211
|
return {
|
|
86
212
|
async getStream(streamId) {
|
|
87
213
|
try {
|
|
88
|
-
|
|
214
|
+
// Read the stream and the chain head this endpoint is serving in one
|
|
215
|
+
// round trip (concurrent, same transport) so the view can be
|
|
216
|
+
// freshness-checked. A failed head read is treated as an unusable view
|
|
217
|
+
// (fail closed) rather than silently dropping the freshness signal.
|
|
218
|
+
const [stream, block] = await Promise.all([
|
|
219
|
+
metrik.getStreamV2(streamId),
|
|
220
|
+
metrik.publicClient.getBlock({ blockTag: "latest" }),
|
|
221
|
+
]);
|
|
89
222
|
return {
|
|
90
223
|
buyer: stream.buyer,
|
|
91
224
|
serviceRef: stream.serviceRef,
|
|
@@ -93,6 +226,10 @@ export function createSdkStreamReader(options: {
|
|
|
93
226
|
claimedCumulative: stream.claimedCumulative,
|
|
94
227
|
expiresAt: stream.expiresAt,
|
|
95
228
|
status: stream.status,
|
|
229
|
+
observedAt: {
|
|
230
|
+
blockNumber: block.number,
|
|
231
|
+
blockTimestamp: Number(block.timestamp),
|
|
232
|
+
},
|
|
96
233
|
};
|
|
97
234
|
} catch (cause) {
|
|
98
235
|
throw new UnknownStreamGatewayError(
|
|
@@ -153,6 +290,28 @@ export interface CallerAuthGatewayConfig {
|
|
|
153
290
|
readonly headerName?: string;
|
|
154
291
|
/** Override the on-chain reader (tests inject a fake; default is the real SDK reader). */
|
|
155
292
|
readonly streamReader?: StreamReader;
|
|
293
|
+
/**
|
|
294
|
+
* Independent corroborating views of the SAME escrow. Every view is read per
|
|
295
|
+
* request (in parallel) and the request is authorized only if EVERY view
|
|
296
|
+
* authorizes - so a stale primary cannot serve a closed stream. Bounded to
|
|
297
|
+
* {@link MAX_CORROBORATING_STREAM_READERS}.
|
|
298
|
+
*/
|
|
299
|
+
readonly corroboratingStreamReaders?: readonly StreamReader[];
|
|
300
|
+
/**
|
|
301
|
+
* Corroborating RPC endpoints for the default SDK reader path. One reader is
|
|
302
|
+
* built per URL; ignored when `corroboratingStreamReaders` is supplied.
|
|
303
|
+
* Use endpoints on DIFFERENT providers - two URLs on one provider share its
|
|
304
|
+
* staleness.
|
|
305
|
+
*/
|
|
306
|
+
readonly corroboratingRpcUrls?: readonly string[];
|
|
307
|
+
/**
|
|
308
|
+
* Reject stream state observed more than this many seconds behind the wall
|
|
309
|
+
* clock. Default {@link DEFAULT_MAX_STATE_STALENESS_SECONDS}. Enforced only
|
|
310
|
+
* for views that report {@link GatewayStreamView.observedAt}.
|
|
311
|
+
*/
|
|
312
|
+
readonly maxStateStalenessSeconds?: number;
|
|
313
|
+
/** Override the terminal-state memory (default in-memory, bounded). */
|
|
314
|
+
readonly revocationSet?: StreamRevocationSet;
|
|
156
315
|
/** Override the replay cache (tests inject a fake; default is in-memory). */
|
|
157
316
|
readonly nonceCache?: NonceReplayCache;
|
|
158
317
|
/** Injectable clock (unix seconds), for tests. */
|
|
@@ -174,7 +333,9 @@ export interface CallerAuthRequestInput {
|
|
|
174
333
|
* See `./http-server.ts` for a ready-to-run reverse-proxy reference server.
|
|
175
334
|
*/
|
|
176
335
|
export class CallerAuthGateway {
|
|
177
|
-
private readonly
|
|
336
|
+
private readonly streamReaders: readonly StreamReader[];
|
|
337
|
+
private readonly maxStateStalenessSeconds: number;
|
|
338
|
+
private readonly revocationSet: StreamRevocationSet;
|
|
178
339
|
private readonly nonceCache: NonceReplayCache;
|
|
179
340
|
private readonly headerName: string;
|
|
180
341
|
private readonly domain: InvocationCapabilityDomainInput;
|
|
@@ -201,12 +362,35 @@ export class CallerAuthGateway {
|
|
|
201
362
|
this.headerName = (
|
|
202
363
|
config.headerName ?? CAPABILITY_HEADER_NAME
|
|
203
364
|
).toLowerCase();
|
|
204
|
-
|
|
365
|
+
const primaryReader =
|
|
205
366
|
config.streamReader ??
|
|
206
367
|
createSdkStreamReader({
|
|
207
368
|
escrowAddress: config.escrowAddress,
|
|
208
369
|
rpcUrl: config.rpcUrl as string,
|
|
209
370
|
});
|
|
371
|
+
const corroborating =
|
|
372
|
+
config.corroboratingStreamReaders ??
|
|
373
|
+
(config.corroboratingRpcUrls ?? []).map((rpcUrl) =>
|
|
374
|
+
createSdkStreamReader({
|
|
375
|
+
escrowAddress: config.escrowAddress,
|
|
376
|
+
rpcUrl,
|
|
377
|
+
}),
|
|
378
|
+
);
|
|
379
|
+
if (corroborating.length > MAX_CORROBORATING_STREAM_READERS) {
|
|
380
|
+
throw new Error(
|
|
381
|
+
`at most ${MAX_CORROBORATING_STREAM_READERS} corroborating stream readers are supported (bounded hot path)`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
this.streamReaders = [primaryReader, ...corroborating];
|
|
385
|
+
|
|
386
|
+
const maxStaleness =
|
|
387
|
+
config.maxStateStalenessSeconds ?? DEFAULT_MAX_STATE_STALENESS_SECONDS;
|
|
388
|
+
if (!Number.isFinite(maxStaleness) || maxStaleness <= 0) {
|
|
389
|
+
throw new Error("maxStateStalenessSeconds must be a positive number");
|
|
390
|
+
}
|
|
391
|
+
this.maxStateStalenessSeconds = maxStaleness;
|
|
392
|
+
this.revocationSet =
|
|
393
|
+
config.revocationSet ?? new InMemoryStreamRevocationSet();
|
|
210
394
|
this.nonceCache = config.nonceCache ?? new InMemoryNonceReplayCache();
|
|
211
395
|
this.domain = {
|
|
212
396
|
chainId: config.chainId ?? 84532,
|
|
@@ -272,33 +456,115 @@ export class CallerAuthGateway {
|
|
|
272
456
|
return deny("nonce-replayed", 403, capability);
|
|
273
457
|
}
|
|
274
458
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
459
|
+
const streamId = capability.streamId as Hex;
|
|
460
|
+
|
|
461
|
+
// An irreversible terminal state already observed for this stream holds
|
|
462
|
+
// forever: no read can un-close it, so short-circuit (and never re-read a
|
|
463
|
+
// dead stream).
|
|
464
|
+
const revoked = this.revocationSet.get(streamId);
|
|
465
|
+
if (revoked !== undefined) {
|
|
466
|
+
return deny(revoked, httpStatusFor(revoked), capability);
|
|
280
467
|
}
|
|
281
468
|
|
|
469
|
+
// Read EVERY configured view (bounded: one read per view, all in parallel).
|
|
470
|
+
const results = await Promise.allSettled(
|
|
471
|
+
this.streamReaders.map((reader) => reader.getStream(streamId)),
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
// Strictest view wins. A view that fails to read, is measurably stale, or
|
|
475
|
+
// reports a non-authorizable stream denies the request - authorization
|
|
476
|
+
// requires unanimous consent from every view.
|
|
477
|
+
const denials: CallerAuthDenialReason[] = [];
|
|
478
|
+
const views: GatewayStreamView[] = [];
|
|
479
|
+
for (const result of results) {
|
|
480
|
+
if (result.status === "rejected") {
|
|
481
|
+
denials.push("stream-not-found");
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
const reason = this.evaluateView(result.value, capability, nowSeconds);
|
|
485
|
+
if (reason === undefined) {
|
|
486
|
+
views.push(result.value);
|
|
487
|
+
} else {
|
|
488
|
+
denials.push(reason);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (denials.length > 0) {
|
|
493
|
+
const reason = strictestDenial(denials);
|
|
494
|
+
if (TERMINAL_DENIALS.has(reason)) {
|
|
495
|
+
this.revocationSet.record(streamId, reason);
|
|
496
|
+
}
|
|
497
|
+
return deny(reason, httpStatusFor(reason), capability);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Report the freshest corroborated view to the caller.
|
|
501
|
+
return { authorized: true, stream: freshestView(views), capability };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Returns the denial reason this single view produces, or `undefined` if it authorizes. */
|
|
505
|
+
private evaluateView(
|
|
506
|
+
stream: GatewayStreamView,
|
|
507
|
+
capability: SignedInvocationCapability,
|
|
508
|
+
nowSeconds: number,
|
|
509
|
+
): CallerAuthDenialReason | undefined {
|
|
510
|
+
// Freshness first: a measurably stale view is not evidence of anything, so
|
|
511
|
+
// it must not be able to vouch for an ACTIVE stream.
|
|
512
|
+
if (
|
|
513
|
+
stream.observedAt !== undefined &&
|
|
514
|
+
nowSeconds - stream.observedAt.blockTimestamp >
|
|
515
|
+
this.maxStateStalenessSeconds
|
|
516
|
+
) {
|
|
517
|
+
return "stream-state-stale";
|
|
518
|
+
}
|
|
282
519
|
if (stream.buyer.toLowerCase() !== capability.buyer.toLowerCase()) {
|
|
283
|
-
return
|
|
520
|
+
return "buyer-mismatch";
|
|
284
521
|
}
|
|
285
522
|
if (
|
|
286
523
|
stream.serviceRef.toLowerCase() !== capability.serviceRef.toLowerCase()
|
|
287
524
|
) {
|
|
288
|
-
return
|
|
525
|
+
return "service-ref-mismatch";
|
|
289
526
|
}
|
|
290
527
|
if (stream.status !== "active") {
|
|
291
|
-
return
|
|
528
|
+
return "stream-not-active";
|
|
292
529
|
}
|
|
293
530
|
if (nowSeconds >= stream.expiresAt) {
|
|
294
|
-
return
|
|
531
|
+
return "stream-expired";
|
|
295
532
|
}
|
|
296
533
|
if (stream.deposit <= stream.claimedCumulative) {
|
|
297
|
-
return
|
|
534
|
+
return "stream-not-funded";
|
|
535
|
+
}
|
|
536
|
+
return undefined;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function httpStatusFor(reason: CallerAuthDenialReason): 402 | 403 {
|
|
541
|
+
return reason === "stream-not-funded" ? 402 : 403;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function strictestDenial(
|
|
545
|
+
denials: readonly CallerAuthDenialReason[],
|
|
546
|
+
): CallerAuthDenialReason {
|
|
547
|
+
for (const candidate of DENIAL_PRECEDENCE) {
|
|
548
|
+
if (denials.includes(candidate)) {
|
|
549
|
+
return candidate;
|
|
298
550
|
}
|
|
551
|
+
}
|
|
552
|
+
return denials[0] as CallerAuthDenialReason;
|
|
553
|
+
}
|
|
299
554
|
|
|
300
|
-
|
|
555
|
+
function freshestView(views: readonly GatewayStreamView[]): GatewayStreamView {
|
|
556
|
+
let freshest = views[0] as GatewayStreamView;
|
|
557
|
+
for (const view of views.slice(1)) {
|
|
558
|
+
const current = freshest.observedAt?.blockNumber;
|
|
559
|
+
const candidate = view.observedAt?.blockNumber;
|
|
560
|
+
if (
|
|
561
|
+
candidate !== undefined &&
|
|
562
|
+
(current === undefined || candidate > current)
|
|
563
|
+
) {
|
|
564
|
+
freshest = view;
|
|
565
|
+
}
|
|
301
566
|
}
|
|
567
|
+
return freshest;
|
|
302
568
|
}
|
|
303
569
|
|
|
304
570
|
function deny(
|
|
@@ -10,6 +10,8 @@ import { isAddress, isHex, type Address, type Hex } from "viem";
|
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
12
|
CallerAuthGateway,
|
|
13
|
+
DEFAULT_MAX_STATE_STALENESS_SECONDS,
|
|
14
|
+
MAX_CORROBORATING_STREAM_READERS,
|
|
13
15
|
type CallerAuthDenialReason,
|
|
14
16
|
} from "./caller-auth-gateway.js";
|
|
15
17
|
|
|
@@ -236,6 +238,10 @@ export interface CallerAuthGatewayEnvConfig {
|
|
|
236
238
|
readonly chainId: number;
|
|
237
239
|
readonly upstreamUrl: string;
|
|
238
240
|
readonly headerName?: string;
|
|
241
|
+
/** Independent RPCs used to corroborate stream state (see `CallerAuthGateway`). */
|
|
242
|
+
readonly corroboratingRpcUrls: readonly string[];
|
|
243
|
+
/** Freshness bound for on-chain stream state, in seconds. */
|
|
244
|
+
readonly maxStateStalenessSeconds: number;
|
|
239
245
|
}
|
|
240
246
|
|
|
241
247
|
/**
|
|
@@ -246,6 +252,14 @@ export interface CallerAuthGatewayEnvConfig {
|
|
|
246
252
|
* - `METRIK_GATEWAY_SERVICE_REF` (required) - primary bytes32 service ref.
|
|
247
253
|
* - `METRIK_GATEWAY_SERVICE_REFS` (optional) - comma-separated migration aliases.
|
|
248
254
|
* - `METRIK_GATEWAY_UPSTREAM_URL` (required) - the real service to proxy to on pass.
|
|
255
|
+
* - `METRIK_GATEWAY_CORROBORATING_RPC_URLS` (optional, STRONGLY recommended) -
|
|
256
|
+
* comma-separated INDEPENDENT RPC endpoints for the same escrow. Stream state
|
|
257
|
+
* is authorized only if every configured view agrees, so a stale primary view
|
|
258
|
+
* cannot serve a stream that has just been closed. At most
|
|
259
|
+
* {@link MAX_CORROBORATING_STREAM_READERS}.
|
|
260
|
+
* - `METRIK_GATEWAY_MAX_STATE_STALENESS_SECONDS` (optional) - reject stream
|
|
261
|
+
* state observed more than N seconds behind the wall clock. Default
|
|
262
|
+
* {@link DEFAULT_MAX_STATE_STALENESS_SECONDS}.
|
|
249
263
|
* - `METRIK_GATEWAY_CHAIN_ID` (optional) - default `84532` (Base Sepolia).
|
|
250
264
|
* - `METRIK_GATEWAY_HEADER_NAME` (optional) - default `x-metrik-capability`.
|
|
251
265
|
* - `PORT` / `METRIK_GATEWAY_PORT` (optional) - default `8787`.
|
|
@@ -295,6 +309,43 @@ export function parseCallerAuthGatewayEnvConfig(
|
|
|
295
309
|
const host = env.METRIK_GATEWAY_HOST ?? "0.0.0.0";
|
|
296
310
|
const headerName = env.METRIK_GATEWAY_HEADER_NAME;
|
|
297
311
|
|
|
312
|
+
const corroboratingRpcUrls = [
|
|
313
|
+
...new Set(
|
|
314
|
+
(env.METRIK_GATEWAY_CORROBORATING_RPC_URLS ?? "")
|
|
315
|
+
.split(",")
|
|
316
|
+
.map((value) => value.trim())
|
|
317
|
+
.filter((value) => value.length > 0),
|
|
318
|
+
),
|
|
319
|
+
];
|
|
320
|
+
for (const candidate of corroboratingRpcUrls) {
|
|
321
|
+
try {
|
|
322
|
+
// eslint-disable-next-line no-new -- validates the URL is well-formed.
|
|
323
|
+
new URL(candidate);
|
|
324
|
+
} catch {
|
|
325
|
+
throw new Error(
|
|
326
|
+
`METRIK_GATEWAY_CORROBORATING_RPC_URLS must contain only valid URLs (got ${candidate})`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (corroboratingRpcUrls.length > MAX_CORROBORATING_STREAM_READERS) {
|
|
331
|
+
throw new Error(
|
|
332
|
+
`METRIK_GATEWAY_CORROBORATING_RPC_URLS accepts at most ${MAX_CORROBORATING_STREAM_READERS} URLs (bounded hot path)`,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const stalenessRaw =
|
|
337
|
+
env.METRIK_GATEWAY_MAX_STATE_STALENESS_SECONDS ??
|
|
338
|
+
String(DEFAULT_MAX_STATE_STALENESS_SECONDS);
|
|
339
|
+
const maxStateStalenessSeconds = Number(stalenessRaw);
|
|
340
|
+
if (
|
|
341
|
+
!Number.isInteger(maxStateStalenessSeconds) ||
|
|
342
|
+
maxStateStalenessSeconds <= 0
|
|
343
|
+
) {
|
|
344
|
+
throw new Error(
|
|
345
|
+
`invalid METRIK_GATEWAY_MAX_STATE_STALENESS_SECONDS: ${stalenessRaw}`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
298
349
|
return {
|
|
299
350
|
host,
|
|
300
351
|
port,
|
|
@@ -306,6 +357,8 @@ export function parseCallerAuthGatewayEnvConfig(
|
|
|
306
357
|
] as Hex[],
|
|
307
358
|
chainId,
|
|
308
359
|
upstreamUrl,
|
|
360
|
+
corroboratingRpcUrls,
|
|
361
|
+
maxStateStalenessSeconds,
|
|
309
362
|
...(headerName === undefined ? {} : { headerName }),
|
|
310
363
|
};
|
|
311
364
|
}
|
|
@@ -329,10 +382,21 @@ export async function startCallerAuthGatewayServerFromEnv(
|
|
|
329
382
|
serviceRef: config.serviceRef,
|
|
330
383
|
serviceRefs: config.serviceRefs,
|
|
331
384
|
chainId: config.chainId,
|
|
385
|
+
corroboratingRpcUrls: config.corroboratingRpcUrls,
|
|
386
|
+
maxStateStalenessSeconds: config.maxStateStalenessSeconds,
|
|
332
387
|
...(config.headerName === undefined
|
|
333
388
|
? {}
|
|
334
389
|
: { headerName: config.headerName }),
|
|
335
390
|
});
|
|
391
|
+
if (config.corroboratingRpcUrls.length === 0) {
|
|
392
|
+
console.error(
|
|
393
|
+
"[metrik-caller-auth-gateway] WARNING: no METRIK_GATEWAY_CORROBORATING_RPC_URLS " +
|
|
394
|
+
"configured — stream state is authorized from a single RPC view. A stale view " +
|
|
395
|
+
"can authorize a stream that has just been closed (bounded to " +
|
|
396
|
+
`${config.maxStateStalenessSeconds}s by the freshness check). Configure at least ` +
|
|
397
|
+
"one INDEPENDENT provider to close that window.",
|
|
398
|
+
);
|
|
399
|
+
}
|
|
336
400
|
const server = createCallerAuthGatewayServer({
|
|
337
401
|
gateway,
|
|
338
402
|
upstreamUrl: config.upstreamUrl,
|
package/src/index.ts
CHANGED
|
@@ -154,26 +154,41 @@ export {
|
|
|
154
154
|
export {
|
|
155
155
|
AUTONOMOUS_WALLET_CAIP2,
|
|
156
156
|
AUTONOMOUS_WALLET_CHAIN_ID,
|
|
157
|
+
AUTONOMOUS_WALLET_KEYCHAIN_SERVICE,
|
|
157
158
|
AUTONOMOUS_WALLET_VERSION,
|
|
158
159
|
METRIK_AUTONOMOUS_WALLET_BROKER_URL,
|
|
160
|
+
METRIK_WALLET_ENCRYPTION_KEY_ENV,
|
|
161
|
+
METRIK_WALLET_STORE_DIR_ENV,
|
|
159
162
|
AutonomousWalletError,
|
|
163
|
+
EncryptedFileCredentialStore,
|
|
160
164
|
HttpAutonomousWalletBroker,
|
|
161
165
|
InMemoryAutonomousWalletStore,
|
|
162
166
|
OsCredentialStore,
|
|
167
|
+
assertDurableCredentialStore,
|
|
163
168
|
autonomousProofBytes,
|
|
164
169
|
createAutonomousWalletAccount,
|
|
165
170
|
createAutonomousWalletProvider,
|
|
166
171
|
createDefaultAutonomousWalletStore,
|
|
172
|
+
probeSecretServiceAvailable,
|
|
167
173
|
provisionAutonomousWallet,
|
|
168
174
|
provisionMetrikAutonomousWallet,
|
|
175
|
+
type AssertDurableCredentialStoreOptions,
|
|
169
176
|
type AutonomousWalletBroker as AutonomousWalletClientBroker,
|
|
170
177
|
type AutonomousWalletCredentialStore,
|
|
171
178
|
type AutonomousWalletProof,
|
|
172
179
|
type AutonomousWalletRecord,
|
|
173
180
|
type CreateAutonomousWalletProviderOptions,
|
|
181
|
+
type CredentialStoreDurability,
|
|
182
|
+
type CredentialStoreDurabilityReport,
|
|
183
|
+
type EncryptedFileCredentialStoreOptions,
|
|
184
|
+
type OsCredentialStoreOptions,
|
|
174
185
|
type PreparedPrivyRequest,
|
|
186
|
+
type ProbeSecretServiceOptions,
|
|
175
187
|
type ProvisionAutonomousWalletOptions,
|
|
176
188
|
type ProvisionMetrikAutonomousWalletOptions,
|
|
189
|
+
type SecretEntryFactory,
|
|
190
|
+
type SecretEntryLike,
|
|
191
|
+
type SecretServiceProbeResult,
|
|
177
192
|
} from "./wallet/autonomous-wallet.js";
|
|
178
193
|
|
|
179
194
|
export {
|
|
@@ -271,7 +286,10 @@ export {
|
|
|
271
286
|
|
|
272
287
|
export {
|
|
273
288
|
CallerAuthGateway,
|
|
289
|
+
DEFAULT_MAX_STATE_STALENESS_SECONDS,
|
|
274
290
|
InMemoryNonceReplayCache,
|
|
291
|
+
InMemoryStreamRevocationSet,
|
|
292
|
+
MAX_CORROBORATING_STREAM_READERS,
|
|
275
293
|
UnknownStreamGatewayError,
|
|
276
294
|
createSdkStreamReader,
|
|
277
295
|
type CallerAuthDecision,
|
|
@@ -281,6 +299,7 @@ export {
|
|
|
281
299
|
type GatewayStreamView,
|
|
282
300
|
type NonceReplayCache,
|
|
283
301
|
type StreamReader,
|
|
302
|
+
type StreamRevocationSet,
|
|
284
303
|
} from "./gateway/caller-auth-gateway.js";
|
|
285
304
|
|
|
286
305
|
export {
|