@absol-labs/agent 0.4.0 → 0.5.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.
Files changed (49) hide show
  1. package/README.md +164 -0
  2. package/dist/capability/invocation-capability.d.ts +184 -0
  3. package/dist/capability/invocation-capability.d.ts.map +1 -0
  4. package/dist/capability/invocation-capability.js +183 -0
  5. package/dist/capability/invocation-capability.js.map +1 -0
  6. package/dist/frameworks/agentkit.js +2 -2
  7. package/dist/frameworks/agentkit.js.map +1 -1
  8. package/dist/frameworks/eliza.js +2 -2
  9. package/dist/frameworks/eliza.js.map +1 -1
  10. package/dist/frameworks/langchain.js +2 -2
  11. package/dist/frameworks/langchain.js.map +1 -1
  12. package/dist/gateway/caller-auth-gateway.d.ts +106 -0
  13. package/dist/gateway/caller-auth-gateway.d.ts.map +1 -0
  14. package/dist/gateway/caller-auth-gateway.js +189 -0
  15. package/dist/gateway/caller-auth-gateway.js.map +1 -0
  16. package/dist/gateway/http-server.d.ts +49 -0
  17. package/dist/gateway/http-server.d.ts.map +1 -0
  18. package/dist/gateway/http-server.js +227 -0
  19. package/dist/gateway/http-server.js.map +1 -0
  20. package/dist/gateway/server-entry.d.ts +2 -0
  21. package/dist/gateway/server-entry.d.ts.map +1 -0
  22. package/dist/gateway/server-entry.js +30 -0
  23. package/dist/gateway/server-entry.js.map +1 -0
  24. package/dist/index.d.ts +5 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +5 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/mcp/server.js +2 -2
  29. package/dist/mcp/server.js.map +1 -1
  30. package/dist/sdk/invoke.d.ts +122 -0
  31. package/dist/sdk/invoke.d.ts.map +1 -0
  32. package/dist/sdk/invoke.js +158 -0
  33. package/dist/sdk/invoke.js.map +1 -0
  34. package/dist/wallet/lifecycle.d.ts +101 -0
  35. package/dist/wallet/lifecycle.d.ts.map +1 -0
  36. package/dist/wallet/lifecycle.js +57 -0
  37. package/dist/wallet/lifecycle.js.map +1 -0
  38. package/package.json +5 -2
  39. package/src/capability/invocation-capability.ts +255 -0
  40. package/src/frameworks/agentkit.ts +2 -2
  41. package/src/frameworks/eliza.ts +2 -2
  42. package/src/frameworks/langchain.ts +2 -2
  43. package/src/gateway/caller-auth-gateway.ts +328 -0
  44. package/src/gateway/http-server.ts +325 -0
  45. package/src/gateway/server-entry.ts +38 -0
  46. package/src/index.ts +81 -0
  47. package/src/mcp/server.ts +2 -2
  48. package/src/sdk/invoke.ts +313 -0
  49. package/src/wallet/lifecycle.ts +158 -0
@@ -230,7 +230,7 @@ export function createLangChainVerifiedStreamTools(
230
230
  new DynamicStructuredToolCtor({
231
231
  name: "check_stream_status",
232
232
  description:
233
- "Read the current stream status and whether the agent should stop work.",
233
+ "Read the current stream status and whether the agent should stop work. In V2, failed or unproven intervals do not advance cumulative entitlement; the stream remains active until buyer close or expiry.",
234
234
  schema: streamStatusToolSchema as any,
235
235
  func: async ({ streamId }: z.infer<typeof streamStatusToolSchema>) => {
236
236
  const result = await options.agentClient.getStreamStatus(
@@ -244,7 +244,7 @@ export function createLangChainVerifiedStreamTools(
244
244
  new DynamicStructuredToolCtor({
245
245
  name: "reclaim_unspent",
246
246
  description:
247
- "Close and/or reclaim an existing stream under the same signed mandate controls.",
247
+ "Close and/or reclaim an existing stream under the same signed mandate controls. V2 reclaim follows checkpoint finalization or escape-window rules.",
248
248
  schema: reclaimToolSchema as any,
249
249
  func: async (input: z.infer<typeof reclaimToolSchema>) => {
250
250
  const signedMandate = parseSignedMandateInput(input.signedMandate);
@@ -0,0 +1,328 @@
1
+ import { MetrikClient } from "@absol-labs/sdk";
2
+ import { isAddress, isHex, type Address, type Hex } from "viem";
3
+
4
+ import {
5
+ CAPABILITY_HEADER_NAME,
6
+ decodeCapabilityHeader,
7
+ hashInvocationPath,
8
+ InvalidCapabilityHeaderError,
9
+ recoverInvocationCapabilitySigner,
10
+ type InvocationCapabilityDomainInput,
11
+ type SignedInvocationCapability,
12
+ } from "../capability/invocation-capability.js";
13
+
14
+ /**
15
+ * Seller-side caller-auth gateway (metrik-agent#63, impl of #62 part 1/2).
16
+ *
17
+ * Authorizes an incoming service request ONLY when it carries a valid
18
+ * `InvocationCapability` (see `../capability/invocation-capability.ts`) whose
19
+ * signer is the buyer of an active, funded, non-expired `StreamEscrowV2`
20
+ * stream matching this gateway's configured `serviceRef` - and the capability
21
+ * itself is unexpired, unreplayed, and bound to the exact method+path being
22
+ * called. Revocation is automatic: once a stream is closed/expired/reclaimed,
23
+ * the on-chain read step below fails closed and access stops with no extra
24
+ * bookkeeping.
25
+ */
26
+
27
+ /** Machine-readable, closed set of rejection reasons - every failure mode is distinct. */
28
+ export type CallerAuthDenialReason =
29
+ | "missing-capability-header"
30
+ | "malformed-capability-header"
31
+ | "capability-expired"
32
+ | "invalid-signature"
33
+ | "method-mismatch"
34
+ | "path-mismatch"
35
+ | "service-ref-mismatch"
36
+ | "nonce-replayed"
37
+ | "stream-not-found"
38
+ | "buyer-mismatch"
39
+ | "stream-not-active"
40
+ | "stream-expired"
41
+ | "stream-not-funded";
42
+
43
+ export interface CallerAuthDecision {
44
+ readonly authorized: boolean;
45
+ readonly reason?: CallerAuthDenialReason;
46
+ /** Set only when `authorized === false`. */
47
+ readonly httpStatus?: 402 | 403;
48
+ /** Set only when `authorized === true`. */
49
+ readonly stream?: GatewayStreamView;
50
+ /** The decoded (but not necessarily valid) capability, when one was present. */
51
+ readonly capability?: SignedInvocationCapability;
52
+ }
53
+
54
+ /** The minimal on-chain stream fields the gateway needs to authorize a call. */
55
+ export interface GatewayStreamView {
56
+ readonly buyer: Address;
57
+ readonly serviceRef: Hex;
58
+ readonly deposit: bigint;
59
+ readonly claimedCumulative: bigint;
60
+ readonly expiresAt: number;
61
+ readonly status: "active" | "closed";
62
+ }
63
+
64
+ /** Pluggable on-chain stream reader, so the gateway is testable without a live RPC. */
65
+ export interface StreamReader {
66
+ getStream(streamId: Hex): Promise<GatewayStreamView>;
67
+ }
68
+
69
+ export class UnknownStreamGatewayError extends Error {
70
+ constructor(message: string, options?: { readonly cause?: unknown }) {
71
+ super(message, options);
72
+ this.name = "UnknownStreamGatewayError";
73
+ }
74
+ }
75
+
76
+ /** Default `StreamReader`: real, read-only `MetrikClient.getStreamV2` (no wallet, no writes). */
77
+ export function createSdkStreamReader(options: {
78
+ readonly escrowAddress: Address;
79
+ readonly rpcUrl: string;
80
+ }): StreamReader {
81
+ const metrik = MetrikClient.baseSepolia({
82
+ escrow: options.escrowAddress,
83
+ rpcUrl: options.rpcUrl,
84
+ });
85
+ return {
86
+ async getStream(streamId) {
87
+ try {
88
+ const stream = await metrik.getStreamV2(streamId);
89
+ return {
90
+ buyer: stream.buyer,
91
+ serviceRef: stream.serviceRef,
92
+ deposit: stream.deposit,
93
+ claimedCumulative: stream.claimedCumulative,
94
+ expiresAt: stream.expiresAt,
95
+ status: stream.status,
96
+ };
97
+ } catch (cause) {
98
+ throw new UnknownStreamGatewayError(
99
+ `failed to read stream ${streamId}: ${cause instanceof Error ? cause.message : String(cause)}`,
100
+ { cause },
101
+ );
102
+ }
103
+ },
104
+ };
105
+ }
106
+
107
+ /** Single-use nonce replay protection. */
108
+ export interface NonceReplayCache {
109
+ /** Returns `true` (and records the nonce) IFF it has not been consumed before. Fails closed on a repeat. */
110
+ consume(nonce: Hex, expiresAtSeconds: number): boolean;
111
+ }
112
+
113
+ /**
114
+ * In-memory replay cache. Sufficient for a single-instance reference gateway;
115
+ * a multi-instance deployment should back this with a shared store (Redis,
116
+ * etc.) via the same `NonceReplayCache` interface.
117
+ */
118
+ export class InMemoryNonceReplayCache implements NonceReplayCache {
119
+ private readonly seen = new Map<string, number>();
120
+
121
+ consume(nonce: Hex, expiresAtSeconds: number): boolean {
122
+ this.sweep();
123
+ const key = nonce.toLowerCase();
124
+ if (this.seen.has(key)) {
125
+ return false;
126
+ }
127
+ this.seen.set(key, expiresAtSeconds);
128
+ return true;
129
+ }
130
+
131
+ private sweep(): void {
132
+ const nowSeconds = Math.floor(Date.now() / 1000);
133
+ for (const [key, expiresAtSeconds] of this.seen) {
134
+ if (expiresAtSeconds < nowSeconds) {
135
+ this.seen.delete(key);
136
+ }
137
+ }
138
+ }
139
+ }
140
+
141
+ export interface CallerAuthGatewayConfig {
142
+ /** The `StreamEscrowV2` this gateway's streams settle on. */
143
+ readonly escrowAddress: Address;
144
+ /** RPC used for the default `StreamReader` (ignored if `streamReader` is supplied). */
145
+ readonly rpcUrl?: string;
146
+ /** The service this gateway fronts. Every request's stream AND capability must match it. */
147
+ readonly serviceRef: Hex;
148
+ /** Chain id for EIP-712 domain separation. Default `84532` (Base Sepolia). */
149
+ readonly chainId?: number;
150
+ /** Capability header name. Default {@link CAPABILITY_HEADER_NAME}. */
151
+ readonly headerName?: string;
152
+ /** Override the on-chain reader (tests inject a fake; default is the real SDK reader). */
153
+ readonly streamReader?: StreamReader;
154
+ /** Override the replay cache (tests inject a fake; default is in-memory). */
155
+ readonly nonceCache?: NonceReplayCache;
156
+ /** Injectable clock (unix seconds), for tests. */
157
+ readonly now?: () => number;
158
+ }
159
+
160
+ export interface CallerAuthRequestInput {
161
+ /** Lower-cased-lookup-safe header map (e.g. Node's `IncomingMessage.headers`). */
162
+ readonly headers: Readonly<Record<string, string | undefined>>;
163
+ readonly method: string;
164
+ /** The request path (query/fragment are ignored - only the path is bound). */
165
+ readonly path: string;
166
+ }
167
+
168
+ /**
169
+ * Authorizes requests against stream-bound `InvocationCapability` headers.
170
+ * Framework-agnostic: `authorize()` takes a plain `{headers, method, path}`
171
+ * triple so it can front any HTTP server (Node `http`, Express, Fastify, ...).
172
+ * See `./http-server.ts` for a ready-to-run reverse-proxy reference server.
173
+ */
174
+ export class CallerAuthGateway {
175
+ private readonly streamReader: StreamReader;
176
+ private readonly nonceCache: NonceReplayCache;
177
+ private readonly headerName: string;
178
+ private readonly domain: InvocationCapabilityDomainInput;
179
+ private readonly serviceRef: Hex;
180
+ private readonly now: () => number;
181
+
182
+ constructor(config: CallerAuthGatewayConfig) {
183
+ if (!isAddress(config.escrowAddress)) {
184
+ throw new Error("escrowAddress must be an EVM address");
185
+ }
186
+ if (
187
+ !isHex(config.serviceRef, { strict: true }) ||
188
+ config.serviceRef.length !== 66
189
+ ) {
190
+ throw new Error("serviceRef must be a 32-byte hex value");
191
+ }
192
+ if (config.streamReader === undefined && config.rpcUrl === undefined) {
193
+ throw new Error(
194
+ "rpcUrl is required unless an explicit streamReader is supplied",
195
+ );
196
+ }
197
+
198
+ this.serviceRef = config.serviceRef;
199
+ this.headerName = (
200
+ config.headerName ?? CAPABILITY_HEADER_NAME
201
+ ).toLowerCase();
202
+ this.streamReader =
203
+ config.streamReader ??
204
+ createSdkStreamReader({
205
+ escrowAddress: config.escrowAddress,
206
+ rpcUrl: config.rpcUrl as string,
207
+ });
208
+ this.nonceCache = config.nonceCache ?? new InMemoryNonceReplayCache();
209
+ this.domain = {
210
+ chainId: config.chainId ?? 84532,
211
+ verifyingContract: config.escrowAddress,
212
+ };
213
+ this.now = config.now ?? (() => Math.floor(Date.now() / 1000));
214
+ }
215
+
216
+ async authorize(
217
+ request: CallerAuthRequestInput,
218
+ ): Promise<CallerAuthDecision> {
219
+ const headerValue = lookupHeader(request.headers, this.headerName);
220
+ if (headerValue === undefined || headerValue.length === 0) {
221
+ return deny("missing-capability-header", 403);
222
+ }
223
+
224
+ let capability: SignedInvocationCapability;
225
+ try {
226
+ capability = decodeCapabilityHeader(headerValue);
227
+ } catch (error) {
228
+ if (error instanceof InvalidCapabilityHeaderError) {
229
+ return deny("malformed-capability-header", 403);
230
+ }
231
+ throw error;
232
+ }
233
+
234
+ const nowSeconds = this.now();
235
+
236
+ if (nowSeconds >= capability.expiry) {
237
+ return deny("capability-expired", 403, capability);
238
+ }
239
+
240
+ let signer: Address;
241
+ try {
242
+ signer = await recoverInvocationCapabilitySigner(
243
+ capability,
244
+ this.domain,
245
+ capability.signature as Hex,
246
+ );
247
+ } catch {
248
+ return deny("invalid-signature", 403, capability);
249
+ }
250
+ if (signer.toLowerCase() !== capability.buyer.toLowerCase()) {
251
+ return deny("invalid-signature", 403, capability);
252
+ }
253
+
254
+ if (capability.method.toUpperCase() !== request.method.toUpperCase()) {
255
+ return deny("method-mismatch", 403, capability);
256
+ }
257
+
258
+ const expectedPathHash = hashInvocationPath(request.path);
259
+ if (expectedPathHash.toLowerCase() !== capability.pathHash.toLowerCase()) {
260
+ return deny("path-mismatch", 403, capability);
261
+ }
262
+
263
+ if (capability.serviceRef.toLowerCase() !== this.serviceRef.toLowerCase()) {
264
+ return deny("service-ref-mismatch", 403, capability);
265
+ }
266
+
267
+ // Consume the nonce BEFORE the on-chain read: the replay check is the
268
+ // cheapest gate and must fail closed even if the RPC is slow/unavailable.
269
+ if (!this.nonceCache.consume(capability.nonce as Hex, capability.expiry)) {
270
+ return deny("nonce-replayed", 403, capability);
271
+ }
272
+
273
+ let stream: GatewayStreamView;
274
+ try {
275
+ stream = await this.streamReader.getStream(capability.streamId as Hex);
276
+ } catch {
277
+ return deny("stream-not-found", 403, capability);
278
+ }
279
+
280
+ if (stream.buyer.toLowerCase() !== capability.buyer.toLowerCase()) {
281
+ return deny("buyer-mismatch", 403, capability);
282
+ }
283
+ if (stream.serviceRef.toLowerCase() !== this.serviceRef.toLowerCase()) {
284
+ return deny("service-ref-mismatch", 403, capability);
285
+ }
286
+ if (stream.status !== "active") {
287
+ return deny("stream-not-active", 403, capability);
288
+ }
289
+ if (nowSeconds >= stream.expiresAt) {
290
+ return deny("stream-expired", 403, capability);
291
+ }
292
+ if (stream.deposit <= stream.claimedCumulative) {
293
+ return deny("stream-not-funded", 402, capability);
294
+ }
295
+
296
+ return { authorized: true, stream, capability };
297
+ }
298
+ }
299
+
300
+ function deny(
301
+ reason: CallerAuthDenialReason,
302
+ httpStatus: 402 | 403,
303
+ capability?: SignedInvocationCapability,
304
+ ): CallerAuthDecision {
305
+ return {
306
+ authorized: false,
307
+ reason,
308
+ httpStatus,
309
+ ...(capability === undefined ? {} : { capability }),
310
+ };
311
+ }
312
+
313
+ function lookupHeader(
314
+ headers: Readonly<Record<string, string | undefined>>,
315
+ name: string,
316
+ ): string | undefined {
317
+ // Header maps may or may not already be lower-cased by the caller's framework;
318
+ // normalize defensively so a differently-cased key still resolves.
319
+ if (headers[name] !== undefined) {
320
+ return headers[name];
321
+ }
322
+ for (const [key, value] of Object.entries(headers)) {
323
+ if (key.toLowerCase() === name) {
324
+ return value;
325
+ }
326
+ }
327
+ return undefined;
328
+ }
@@ -0,0 +1,325 @@
1
+ import {
2
+ createServer,
3
+ type IncomingMessage,
4
+ type Server,
5
+ type ServerResponse,
6
+ } from "node:http";
7
+ import type { AddressInfo } from "node:net";
8
+
9
+ import { isAddress, isHex, type Address, type Hex } from "viem";
10
+
11
+ import {
12
+ CallerAuthGateway,
13
+ type CallerAuthDenialReason,
14
+ } from "./caller-auth-gateway.js";
15
+
16
+ /**
17
+ * Reference standalone reverse-proxy gateway server (metrik-agent#63).
18
+ *
19
+ * A seller runs this in front of their real HTTP service. Every request must
20
+ * carry a valid `InvocationCapability` header (see
21
+ * `../capability/invocation-capability.ts`); on pass, the request is proxied
22
+ * to `upstreamUrl` verbatim and its response is returned unmodified. On fail,
23
+ * a `402`/`403` with a machine-readable JSON reason is returned and the
24
+ * upstream is never touched.
25
+ */
26
+
27
+ /** Maximum buffered request body size (bytes) - bounds memory per request. */
28
+ const MAX_BODY_BYTES = 16 * 1024 * 1024;
29
+
30
+ /** Hop-by-hop headers that must never be forwarded verbatim to the upstream. */
31
+ const HOP_BY_HOP_HEADERS = new Set([
32
+ "connection",
33
+ "keep-alive",
34
+ "proxy-authenticate",
35
+ "proxy-authorization",
36
+ "te",
37
+ "trailer",
38
+ "transfer-encoding",
39
+ "upgrade",
40
+ "host",
41
+ "content-length",
42
+ ]);
43
+
44
+ export interface CallerAuthGatewayServerConfig {
45
+ readonly gateway: CallerAuthGateway;
46
+ /** Base URL of the real service this gateway fronts (e.g. `https://svc.example.com`). */
47
+ readonly upstreamUrl: string;
48
+ /** Extra request-timeout for the upstream fetch, in ms. Default 30000. */
49
+ readonly upstreamTimeoutMs?: number;
50
+ }
51
+
52
+ export interface CallerAuthGatewayServer {
53
+ readonly httpServer: Server;
54
+ listen(port: number, host?: string): Promise<{ port: number; host: string }>;
55
+ close(): Promise<void>;
56
+ }
57
+
58
+ export function createCallerAuthGatewayServer(
59
+ config: CallerAuthGatewayServerConfig,
60
+ ): CallerAuthGatewayServer {
61
+ const upstreamBase = new URL(config.upstreamUrl);
62
+ const upstreamTimeoutMs = config.upstreamTimeoutMs ?? 30_000;
63
+
64
+ const httpServer = createServer((req, res) => {
65
+ handleRequest(
66
+ req,
67
+ res,
68
+ config.gateway,
69
+ upstreamBase,
70
+ upstreamTimeoutMs,
71
+ ).catch((error) => {
72
+ logError("unhandled request error", error);
73
+ if (!res.headersSent) {
74
+ writeJsonError(res, 500, "internal-error");
75
+ } else {
76
+ res.end();
77
+ }
78
+ });
79
+ });
80
+
81
+ return {
82
+ httpServer,
83
+ listen: (port, host = "0.0.0.0") =>
84
+ new Promise((resolve, reject) => {
85
+ const onError = (error: Error) => reject(error);
86
+ httpServer.once("error", onError);
87
+ httpServer.listen(port, host, () => {
88
+ httpServer.removeListener("error", onError);
89
+ const address = httpServer.address() as AddressInfo;
90
+ resolve({ port: address.port, host });
91
+ });
92
+ }),
93
+ close: () =>
94
+ new Promise<void>((resolve, reject) => {
95
+ httpServer.close((error) => (error ? reject(error) : resolve()));
96
+ }),
97
+ };
98
+ }
99
+
100
+ async function handleRequest(
101
+ req: IncomingMessage,
102
+ res: ServerResponse,
103
+ gateway: CallerAuthGateway,
104
+ upstreamBase: URL,
105
+ upstreamTimeoutMs: number,
106
+ ): Promise<void> {
107
+ const requestUrl = new URL(req.url ?? "/", "http://localhost");
108
+ const method = req.method ?? "GET";
109
+ const headers = normalizeIncomingHeaders(req.headers);
110
+
111
+ const decision = await gateway.authorize({
112
+ headers,
113
+ method,
114
+ path: requestUrl.pathname,
115
+ });
116
+
117
+ if (!decision.authorized) {
118
+ writeJsonError(
119
+ res,
120
+ decision.httpStatus ?? 403,
121
+ decision.reason ?? "unauthorized",
122
+ );
123
+ return;
124
+ }
125
+
126
+ let body: Buffer | undefined;
127
+ if (method !== "GET" && method !== "HEAD") {
128
+ body = await readBody(req);
129
+ }
130
+
131
+ const upstreamHeaders = new Headers();
132
+ for (const [key, value] of Object.entries(headers)) {
133
+ if (value === undefined || HOP_BY_HOP_HEADERS.has(key)) {
134
+ continue;
135
+ }
136
+ upstreamHeaders.set(key, value);
137
+ }
138
+
139
+ const upstreamUrl = new URL(
140
+ requestUrl.pathname + requestUrl.search,
141
+ upstreamBase,
142
+ );
143
+
144
+ const controller = new AbortController();
145
+ const timeout = setTimeout(() => controller.abort(), upstreamTimeoutMs);
146
+ try {
147
+ const upstreamResponse = await fetch(upstreamUrl, {
148
+ method,
149
+ headers: upstreamHeaders,
150
+ ...(body === undefined ? {} : { body }),
151
+ signal: controller.signal,
152
+ });
153
+
154
+ res.writeHead(
155
+ upstreamResponse.status,
156
+ headersToObject(upstreamResponse.headers),
157
+ );
158
+ const buffer = Buffer.from(await upstreamResponse.arrayBuffer());
159
+ res.end(buffer);
160
+ } catch (error) {
161
+ logError("upstream request failed", error);
162
+ writeJsonError(res, 502, "upstream-unavailable");
163
+ } finally {
164
+ clearTimeout(timeout);
165
+ }
166
+ }
167
+
168
+ function normalizeIncomingHeaders(
169
+ headers: IncomingMessage["headers"],
170
+ ): Record<string, string | undefined> {
171
+ const normalized: Record<string, string | undefined> = {};
172
+ for (const [key, value] of Object.entries(headers)) {
173
+ if (value === undefined) continue;
174
+ normalized[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
175
+ }
176
+ return normalized;
177
+ }
178
+
179
+ function headersToObject(headers: Headers): Record<string, string> {
180
+ const result: Record<string, string> = {};
181
+ headers.forEach((value, key) => {
182
+ if (HOP_BY_HOP_HEADERS.has(key.toLowerCase())) return;
183
+ result[key] = value;
184
+ });
185
+ return result;
186
+ }
187
+
188
+ class BodyTooLargeError extends Error {}
189
+
190
+ async function readBody(req: IncomingMessage): Promise<Buffer> {
191
+ const chunks: Buffer[] = [];
192
+ let size = 0;
193
+ for await (const chunk of req) {
194
+ const buffer = chunk as Buffer;
195
+ size += buffer.length;
196
+ if (size > MAX_BODY_BYTES) {
197
+ throw new BodyTooLargeError("request body too large");
198
+ }
199
+ chunks.push(buffer);
200
+ }
201
+ return Buffer.concat(chunks);
202
+ }
203
+
204
+ function writeJsonError(
205
+ res: ServerResponse,
206
+ status: number,
207
+ reason:
208
+ | CallerAuthDenialReason
209
+ | "unauthorized"
210
+ | "internal-error"
211
+ | "upstream-unavailable",
212
+ ): void {
213
+ if (res.headersSent) {
214
+ res.end();
215
+ return;
216
+ }
217
+ res
218
+ .writeHead(status, { "Content-Type": "application/json" })
219
+ .end(JSON.stringify({ authorized: false, reason }));
220
+ }
221
+
222
+ function logError(context: string, error: unknown): void {
223
+ const message = error instanceof Error ? error.message : String(error);
224
+ console.error(`[metrik-caller-auth-gateway] ${context}: ${message}`);
225
+ }
226
+
227
+ // ── Environment configuration ────────────────────────────────────────────────
228
+
229
+ export interface CallerAuthGatewayEnvConfig {
230
+ readonly host: string;
231
+ readonly port: number;
232
+ readonly escrowAddress: Address;
233
+ readonly rpcUrl: string;
234
+ readonly serviceRef: Hex;
235
+ readonly chainId: number;
236
+ readonly upstreamUrl: string;
237
+ readonly headerName?: string;
238
+ }
239
+
240
+ /**
241
+ * Parses the gateway's env contract:
242
+ * - `METRIK_GATEWAY_ESCROW_ADDRESS` (required) - the `StreamEscrowV2` this
243
+ * gateway checks streams against.
244
+ * - `METRIK_GATEWAY_RPC_URL` (required) - RPC used for the read-only stream check.
245
+ * - `METRIK_GATEWAY_SERVICE_REF` (required) - bytes32 `serviceRef` this gateway fronts.
246
+ * - `METRIK_GATEWAY_UPSTREAM_URL` (required) - the real service to proxy to on pass.
247
+ * - `METRIK_GATEWAY_CHAIN_ID` (optional) - default `84532` (Base Sepolia).
248
+ * - `METRIK_GATEWAY_HEADER_NAME` (optional) - default `x-metrik-capability`.
249
+ * - `PORT` / `METRIK_GATEWAY_PORT` (optional) - default `8787`.
250
+ * - `METRIK_GATEWAY_HOST` (optional) - default `0.0.0.0`.
251
+ *
252
+ * Fail-closed: throws if any required var is missing or malformed.
253
+ */
254
+ export function parseCallerAuthGatewayEnvConfig(
255
+ env: NodeJS.ProcessEnv = process.env,
256
+ ): CallerAuthGatewayEnvConfig {
257
+ const escrowAddress = requireEnv(env, "METRIK_GATEWAY_ESCROW_ADDRESS");
258
+ if (!isAddress(escrowAddress)) {
259
+ throw new Error("METRIK_GATEWAY_ESCROW_ADDRESS must be an EVM address");
260
+ }
261
+ const rpcUrl = requireEnv(env, "METRIK_GATEWAY_RPC_URL");
262
+ const serviceRef = requireEnv(env, "METRIK_GATEWAY_SERVICE_REF");
263
+ if (!isHex(serviceRef, { strict: true }) || serviceRef.length !== 66) {
264
+ throw new Error("METRIK_GATEWAY_SERVICE_REF must be a 32-byte hex value");
265
+ }
266
+ const upstreamUrl = requireEnv(env, "METRIK_GATEWAY_UPSTREAM_URL");
267
+ // eslint-disable-next-line no-new -- validates the URL is well-formed.
268
+ new URL(upstreamUrl);
269
+
270
+ const chainIdRaw = env.METRIK_GATEWAY_CHAIN_ID ?? "84532";
271
+ const chainId = Number(chainIdRaw);
272
+ if (!Number.isInteger(chainId) || chainId <= 0) {
273
+ throw new Error(`invalid METRIK_GATEWAY_CHAIN_ID: ${chainIdRaw}`);
274
+ }
275
+
276
+ const portRaw = env.METRIK_GATEWAY_PORT ?? env.PORT ?? "8787";
277
+ const port = Number(portRaw);
278
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
279
+ throw new Error(`invalid port: ${portRaw}`);
280
+ }
281
+
282
+ const host = env.METRIK_GATEWAY_HOST ?? "0.0.0.0";
283
+ const headerName = env.METRIK_GATEWAY_HEADER_NAME;
284
+
285
+ return {
286
+ host,
287
+ port,
288
+ escrowAddress,
289
+ rpcUrl,
290
+ serviceRef,
291
+ chainId,
292
+ upstreamUrl,
293
+ ...(headerName === undefined ? {} : { headerName }),
294
+ };
295
+ }
296
+
297
+ function requireEnv(env: NodeJS.ProcessEnv, name: string): string {
298
+ const value = env[name];
299
+ if (value === undefined || value.trim().length === 0) {
300
+ throw new Error(`${name} is required (fail-closed)`);
301
+ }
302
+ return value;
303
+ }
304
+
305
+ /** Builds and starts the reference gateway server entirely from the environment. */
306
+ export async function startCallerAuthGatewayServerFromEnv(
307
+ env: NodeJS.ProcessEnv = process.env,
308
+ ): Promise<CallerAuthGatewayServer & { readonly boundPort: number }> {
309
+ const config = parseCallerAuthGatewayEnvConfig(env);
310
+ const gateway = new CallerAuthGateway({
311
+ escrowAddress: config.escrowAddress,
312
+ rpcUrl: config.rpcUrl,
313
+ serviceRef: config.serviceRef,
314
+ chainId: config.chainId,
315
+ ...(config.headerName === undefined
316
+ ? {}
317
+ : { headerName: config.headerName }),
318
+ });
319
+ const server = createCallerAuthGatewayServer({
320
+ gateway,
321
+ upstreamUrl: config.upstreamUrl,
322
+ });
323
+ const { port } = await server.listen(config.port, config.host);
324
+ return Object.assign(server, { boundPort: port });
325
+ }
@@ -0,0 +1,38 @@
1
+ import { startCallerAuthGatewayServerFromEnv } from "./http-server.js";
2
+
3
+ /**
4
+ * Runnable entrypoint for the reference caller-auth gateway (metrik-agent#63).
5
+ * Reads all configuration from the environment (see
6
+ * {@link parseCallerAuthGatewayEnvConfig}) and refuses to start if required
7
+ * config is missing (fail-closed).
8
+ *
9
+ * Start with: `pnpm gateway` (or `node dist/gateway/server-entry.js`).
10
+ */
11
+ async function main(): Promise<void> {
12
+ const server = await startCallerAuthGatewayServerFromEnv();
13
+ const host = process.env.METRIK_GATEWAY_HOST ?? "0.0.0.0";
14
+ console.error(
15
+ `[metrik-caller-auth-gateway] listening on http://${host}:${server.boundPort}`,
16
+ );
17
+
18
+ const shutdown = (signal: string) => {
19
+ console.error(
20
+ `[metrik-caller-auth-gateway] ${signal} received — shutting down`,
21
+ );
22
+ server
23
+ .close()
24
+ .then(() => process.exit(0))
25
+ .catch(() => process.exit(1));
26
+ };
27
+ process.on("SIGINT", () => shutdown("SIGINT"));
28
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
29
+ }
30
+
31
+ main().catch((error) => {
32
+ console.error(
33
+ error instanceof Error
34
+ ? error.message
35
+ : "failed to start caller-auth gateway server",
36
+ );
37
+ process.exit(1);
38
+ });