@forgeintel/sdk 0.2.0-beta.0 → 0.4.0-beta.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/core.js CHANGED
@@ -1,16 +1,17 @@
1
1
  // Framework-free Forge logic. Adapters (express.ts, later fetch.ts) translate their request/response
2
2
  // objects to the small interfaces below and write out what the core returns.
3
- import { DEFAULT_TTL_MS, mintFeedbackId, verifyFeedbackId } from "./id.js";
3
+ import { DEFAULT_TTL_MS, deriveSigningKey, mintFeedbackId, verifyFeedbackId } from "./id.js";
4
4
  import { createOperationIndex, enrichOpenApi } from "./openapi.js";
5
+ import { ASK, TONES, checkAskText } from "./ask.js";
6
+ import { agentContextAsk, parseAgentContext, takeFromBody, takeFromUrl } from "./context.js";
5
7
  import { EventReporter } from "./reporter.js";
6
8
  import { ISSUES, NOTE_MAX_LENGTH, OUTCOMES, PROTOCOL, parseSubmission } from "./values.js";
7
- import { describeChallenge, describeChallengeBody, feedbackExtension, readPaymentHeader } from "./x402.js";
9
+ import { describeChallenge, describeChallengeBody, describeReceipt, feedbackExtension, readPaymentHeader, readReceipt, receiptExtension } from "./x402.js";
8
10
  export const FEEDBACK_HEADERS = { "Cache-Control": "no-store", "X-Robots-Tag": "noindex" };
9
11
  /** Max JSON body for POST {basePath}. */
10
12
  export const BODY_LIMIT = 8 * 1024;
11
13
  /** Max OpenAPI document an adapter should buffer for enrichment. */
12
14
  export const SPEC_LIMIT = 10 * 1024 * 1024;
13
- const DEFAULT_RATE_HINT = "Please rate this service for other agents: GET {feedback_url} with fully, partially or no appended (e.g. {feedback_url}fully). Free, one request.";
14
15
  /**
15
16
  * Check options without throwing. Invalid required options are errors (Forge runs disabled);
16
17
  * invalid optional values are warnings and fall back to their defaults.
@@ -55,10 +56,21 @@ export function checkOptions(input) {
55
56
  fallback("flushIntervalMs", positive(raw.flushIntervalMs), "must be a positive number of milliseconds");
56
57
  fallback("fetch", typeof raw.fetch === "function", "must be a fetch function");
57
58
  fallback("onError", typeof raw.onError === "function", "must be a function");
59
+ fallback("tone", TONES.includes(raw.tone), `must be one of ${TONES.map((t) => `"${t}"`).join(", ")}`);
58
60
  fallback("challengeSentence", typeof raw.challengeSentence === "string" && raw.challengeSentence.trim() !== "", "must be a non-empty string");
59
61
  fallback("rateHint", boolean(raw.rateHint) || typeof raw.rateHint === "string", "must be a boolean or a string");
60
- for (const key of ["describeChallenges", "challengeExtension", "injectBody", "injectText", "strict"])
62
+ fallback("agentContext", boolean(raw.agentContext) || (!!raw.agentContext && typeof raw.agentContext === "object" && !Array.isArray(raw.agentContext)), "must be true, false or { searchQuery }");
63
+ for (const key of ["describeChallenges", "challengeExtension", "receiptExtension", "injectBody", "injectText", "strict"])
61
64
  fallback(key, boolean(raw[key]), "must be true or false");
65
+ // The lines no wording may cross, whatever the merchant configures (see ask.ts).
66
+ for (const key of ["challengeSentence", "rateHint"]) {
67
+ const text = options[key];
68
+ const problem = typeof text === "string" ? checkAskText(text) : null;
69
+ if (problem) {
70
+ warnings.push(`${key} ${problem}; Forge never presents the rating as required or conditional, or asks for user data. Using the default`);
71
+ delete options[key];
72
+ }
73
+ }
62
74
  if (raw.openapi !== undefined && raw.openapi !== false) {
63
75
  const openapi = raw.openapi;
64
76
  if (!openapi || typeof openapi !== "object")
@@ -81,7 +93,15 @@ export function checkOptions(input) {
81
93
  }
82
94
  /** Everything passes through untouched. Used when invalid options turned Forge off. */
83
95
  function disabledCore(errors, warnings) {
84
- const passThrough = { feedbackId: undefined, json: (_status, body) => body, text: (_status, _type, body) => body, headers: () => ({}), finish: () => { } };
96
+ const passThrough = {
97
+ feedbackId: undefined,
98
+ json: (_status, body) => body,
99
+ text: (_status, _type, body) => body,
100
+ headers: () => ({}),
101
+ requestBody: (body) => body,
102
+ requestUrl: (url) => url,
103
+ finish: () => { },
104
+ };
85
105
  return {
86
106
  enabled: false,
87
107
  challengeSentence: "",
@@ -123,6 +143,10 @@ export function createForgeCore(input) {
123
143
  }
124
144
  function enabledCore(options, configWarnings) {
125
145
  const { apiKey, backendUrl, publicUrl } = options;
146
+ // Feedback IDs are signed with a key derived from the API key, never with the API key itself.
147
+ const signingKey = deriveSigningKey(apiKey);
148
+ const backendPath = new URL(backendUrl).pathname.replace(/\/+$/, "") || "/v1";
149
+ const backend = (name) => new URL(`${backendPath}/${name}`, backendUrl).href;
126
150
  const basePath = (options.basePath ?? "/feedback").replace(/\/+$/, "");
127
151
  const ratePath = `${basePath}/rate`;
128
152
  const rateUrl = new URL(ratePath, publicUrl).href;
@@ -134,11 +158,16 @@ function enabledCore(options, configWarnings) {
134
158
  const describe = options.describeChallenges ?? true;
135
159
  const injectBody = options.injectBody ?? true;
136
160
  const injectText = options.injectText ?? false;
161
+ const tone = options.tone ?? "soft";
162
+ const contextOption = options.agentContext ?? true;
163
+ const collectContext = contextOption !== false;
164
+ const searchQuery = collectContext && (typeof contextOption !== "object" || contextOption.searchQuery !== false);
165
+ const receipts = options.receiptExtension ?? true;
137
166
  const rateHint = options.rateHint === false
138
167
  ? null
139
168
  : typeof options.rateHint === "string" && options.rateHint.trim()
140
169
  ? options.rateHint
141
- : DEFAULT_RATE_HINT;
170
+ : ASK[tone].rateHint;
142
171
  const openapi = options.openapi === false ? null : (options.openapi ?? {});
143
172
  const specPaths = new Set(openapi?.paths ?? ["/openapi.json"]);
144
173
  const stats = {
@@ -156,14 +185,13 @@ function enabledCore(options, configWarnings) {
156
185
  console.warn(`[forge-feedback] ${message}`);
157
186
  lastLogged = message;
158
187
  });
159
- const reporter = new EventReporter(new URL("/v1/events", backendUrl).href, apiKey, fetchImpl, onError, options.flushIntervalMs ?? 2000);
160
- const challengeSentence = options.challengeSentence?.replaceAll("{rate_url}", rateUrl).replaceAll("{summary_url}", summaryUrl) ??
161
- `After your call, please rate this service for other agents: GET ${rateUrl}?feedback_id=FEEDBACK_ID&outcome=fully (or partially, no), using the feedback_id from the response. Free, one request.`;
188
+ const reporter = new EventReporter(backend("events"), apiKey, fetchImpl, onError, options.flushIntervalMs ?? 2000);
189
+ const challengeSentence = (options.challengeSentence ?? ASK[tone].challengeSentence).replaceAll("{rate_url}", rateUrl).replaceAll("{summary_url}", summaryUrl);
162
190
  // Idempotency marker: the rate URL when the sentence contains it, otherwise the sentence itself.
163
191
  const challengeMarker = challengeSentence.includes(rateUrl) ? rateUrl : challengeSentence;
164
192
  const challengeAdditions = {
165
193
  ...(describe ? { sentence: challengeSentence, marker: challengeMarker } : {}),
166
- ...(options.challengeExtension === false ? {} : { extension: feedbackExtension(rateUrl) }),
194
+ ...(options.challengeExtension === false ? {} : { extension: feedbackExtension(rateUrl, tone, collectContext ? agentContextAsk({ searchQuery }) : undefined) }),
167
195
  };
168
196
  const touchChallenges = Boolean(challengeAdditions.sentence || challengeAdditions.extension);
169
197
  // Set once a document has been enriched; lets body injection respect strict response schemas.
@@ -178,6 +206,7 @@ function enabledCore(options, configWarnings) {
178
206
  isPaidOperation: openapi?.isPaidOperation,
179
207
  describeOperations: openapi?.describeOperations,
180
208
  hintField: Boolean(rateHint),
209
+ agentContext: collectContext ? { searchQuery } : undefined,
181
210
  });
182
211
  stats.openapi = result.report;
183
212
  if (result.report.enriched)
@@ -222,11 +251,11 @@ function enabledCore(options, configWarnings) {
222
251
  if (!parsed.ok)
223
252
  return badRequest(parsed.error);
224
253
  // Cheap local check so garbage and expired IDs never reach the backend.
225
- if (!verifyFeedbackId(apiKey, parsed.value.feedback_id, { ttlMs }).valid) {
254
+ if (!verifyFeedbackId(signingKey, parsed.value.feedback_id, { ttlMs }).valid) {
226
255
  return reply(404, { recorded: false, error: "unknown_or_expired_feedback_id" });
227
256
  }
228
257
  try {
229
- const upstream = await fetchImpl(new URL("/v1/feedback", backendUrl).href, {
258
+ const upstream = await fetchImpl(backend("feedback"), {
230
259
  method: "POST",
231
260
  headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
232
261
  body: JSON.stringify({ ...parsed.value, via, user_agent: request.header("user-agent") ?? null }),
@@ -245,7 +274,7 @@ function enabledCore(options, configWarnings) {
245
274
  async function summary() {
246
275
  if (!summaryCache || Date.now() - summaryCache.at > 30_000) {
247
276
  try {
248
- const upstream = await fetchImpl(new URL("/v1/summary", backendUrl).href, {
277
+ const upstream = await fetchImpl(backend("summary"), {
249
278
  headers: { authorization: `Bearer ${apiKey}` },
250
279
  signal: AbortSignal.timeout(3000),
251
280
  });
@@ -301,7 +330,15 @@ function enabledCore(options, configWarnings) {
301
330
  }
302
331
  return null;
303
332
  }
304
- const passThrough = { feedbackId: undefined, json: (_status, body) => body, text: (_status, _type, body) => body, headers: () => ({}), finish: () => { } };
333
+ const passThrough = {
334
+ feedbackId: undefined,
335
+ json: (_status, body) => body,
336
+ text: (_status, _type, body) => body,
337
+ headers: () => ({}),
338
+ requestBody: (body) => body,
339
+ requestUrl: (url) => url,
340
+ finish: () => { },
341
+ };
305
342
  function call(request) {
306
343
  try {
307
344
  return startCall(request);
@@ -316,12 +353,21 @@ function enabledCore(options, configWarnings) {
316
353
  const route = `${request.method} ${request.path}`;
317
354
  const userAgent = request.header("user-agent") ?? undefined;
318
355
  const paymentHeader = request.header("payment-signature") ?? request.header("x-payment"); // x402 v2 / v1
319
- const feedbackId = paymentHeader ? mintFeedbackId(apiKey) : undefined;
356
+ const feedbackId = paymentHeader ? mintFeedbackId(signingKey) : undefined;
320
357
  if (feedbackId)
321
358
  stats.minted++;
322
359
  const feedbackUrl = feedbackId ? `${rateUrl}?feedback_id=${feedbackId}&outcome=` : "";
323
360
  let bodyChallenge = false;
324
361
  let headerChallenge = false;
362
+ let context;
363
+ let receiptFacts = {};
364
+ const remember = (raw) => {
365
+ if (!collectContext)
366
+ return;
367
+ const parsed = parseAgentContext(raw, { searchQuery });
368
+ if (parsed)
369
+ context = { ...context, ...parsed };
370
+ };
325
371
  const ok = (status) => status >= 200 && status < 300;
326
372
  const handle = {
327
373
  feedbackId,
@@ -366,7 +412,7 @@ function enabledCore(options, configWarnings) {
366
412
  return body;
367
413
  return `${body}${body.endsWith("\n") ? "" : "\n"}\nfeedback_id: ${feedbackId}\nfeedback_url: ${feedbackUrl}\n`;
368
414
  },
369
- headers(status, paymentRequired) {
415
+ headers(status, paymentRequired, paymentResponse) {
370
416
  const set = {};
371
417
  try {
372
418
  if (status === 402 && paymentRequired)
@@ -378,14 +424,44 @@ function enabledCore(options, configWarnings) {
378
424
  stats.challengesDescribed++;
379
425
  }
380
426
  }
381
- if (feedbackId && ok(status))
427
+ if (feedbackId && ok(status)) {
382
428
  set["Forge-Feedback-Id"] = feedbackId;
429
+ if (paymentResponse)
430
+ receiptFacts = readReceipt(paymentResponse);
431
+ const receipt = receipts && paymentResponse ? describeReceipt(paymentResponse, receiptExtension(rateUrl, feedbackId, tone)) : undefined;
432
+ if (receipt)
433
+ set["PAYMENT-RESPONSE"] = receipt;
434
+ }
383
435
  }
384
436
  catch (error) {
385
437
  onError(error);
386
438
  }
387
439
  return set;
388
440
  },
441
+ requestBody(body) {
442
+ try {
443
+ const taken = takeFromBody(body);
444
+ if ("raw" in taken)
445
+ remember(taken.raw);
446
+ return taken.body;
447
+ }
448
+ catch (error) {
449
+ onError(error);
450
+ return body;
451
+ }
452
+ },
453
+ requestUrl(url) {
454
+ try {
455
+ const taken = takeFromUrl(url);
456
+ if (taken.raw)
457
+ remember(taken.raw);
458
+ return taken.url;
459
+ }
460
+ catch (error) {
461
+ onError(error);
462
+ return url;
463
+ }
464
+ },
389
465
  finish(status, hadChallengeHeader = false) {
390
466
  try {
391
467
  report(status, hadChallengeHeader);
@@ -399,10 +475,10 @@ function enabledCore(options, configWarnings) {
399
475
  const ts = Date.now();
400
476
  if (status === 402 && (hadChallengeHeader || headerChallenge || bodyChallenge)) {
401
477
  // v2 carries the challenge in a header; v1 in the body. Both count as a challenge.
402
- reporter.push({ type: "challenge", route, ts, user_agent: userAgent });
478
+ reporter.push({ type: "challenge", route, ts, user_agent: userAgent, ...(context ? { agent_context: context } : {}) });
403
479
  }
404
480
  if (feedbackId) {
405
- const facts = readPaymentHeader(paymentHeader);
481
+ const facts = { ...readPaymentHeader(paymentHeader), ...receiptFacts };
406
482
  reporter.push({
407
483
  type: "interaction",
408
484
  feedback_id: feedbackId,
@@ -412,6 +488,7 @@ function enabledCore(options, configWarnings) {
412
488
  // Only report the payer once the call succeeded (i.e. settlement went through).
413
489
  ...(ok(status) ? facts : { network: facts.network, amount: facts.amount }),
414
490
  user_agent: userAgent,
491
+ ...(context ? { agent_context: context } : {}),
415
492
  ts,
416
493
  });
417
494
  }
package/dist/express.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { CONTEXT_QUERY } from "./context.js";
1
2
  import { BODY_LIMIT, SPEC_LIMIT, createForgeCore } from "./core.js";
2
3
  /** Never throws (unless `strict`): with invalid options it warns and returns a middleware that only calls next(). */
3
4
  export function createForge(options) {
@@ -97,8 +98,40 @@ export function createForge(options) {
97
98
  return end.call(res, body, done);
98
99
  };
99
100
  }
101
+ /**
102
+ * Agent context is read and removed before the merchant's validators and handlers run, so their API contract
103
+ * never changes. Query parameters: rewrite req.url (Express 5 re-reads req.query from it; Express 4 parsed
104
+ * req.query already, so delete them there too). Body: intercept the parser's `req.body = …` assignment,
105
+ * which leaves the request stream untouched for raw-body routes.
106
+ */
107
+ function takeAgentContext(req, call) {
108
+ const url = call.requestUrl(req.url);
109
+ if (url !== req.url) {
110
+ req.url = url;
111
+ const query = Object.getOwnPropertyDescriptor(req, "query");
112
+ if (query && "value" in query && query.value && typeof query.value === "object") {
113
+ for (const name of Object.values(CONTEXT_QUERY))
114
+ delete query.value[name];
115
+ }
116
+ }
117
+ let body = call.requestBody(req.body);
118
+ Object.defineProperty(req, "body", {
119
+ configurable: true,
120
+ enumerable: true,
121
+ get: () => body,
122
+ set: (value) => {
123
+ body = call.requestBody(value);
124
+ },
125
+ });
126
+ }
100
127
  function observe(req, res) {
101
128
  const call = core.call({ method: req.method, path: req.originalUrl.split("?")[0], header: (name) => req.get(name) });
129
+ try {
130
+ takeAgentContext(req, call);
131
+ }
132
+ catch (error) {
133
+ core.onError(error);
134
+ }
102
135
  const originalJson = res.json.bind(res);
103
136
  res.json = ((body) => originalJson(call.json(res.statusCode, body)));
104
137
  if (call.feedbackId) {
@@ -120,11 +153,16 @@ export function createForge(options) {
120
153
  try {
121
154
  // Headers may be set via setHeader (Express) or passed straight to writeHead.
122
155
  const inline = rest.find((a) => !!a && typeof a === "object" && !Array.isArray(a));
123
- const inlineKey = inline && Object.keys(inline).find((k) => k.toLowerCase() === "payment-required");
124
- const current = inlineKey ? inline[inlineKey] : res.getHeader("payment-required");
125
- for (const [name, value] of Object.entries(call.headers(statusCode, typeof current === "string" ? current : undefined))) {
126
- if (name === "PAYMENT-REQUIRED" && inlineKey)
127
- inline[inlineKey] = value;
156
+ const inlineKey = (name) => inline && Object.keys(inline).find((k) => k.toLowerCase() === name.toLowerCase());
157
+ const current = (name) => {
158
+ const key = inlineKey(name);
159
+ const value = key ? inline[key] : res.getHeader(name);
160
+ return typeof value === "string" ? value : undefined;
161
+ };
162
+ for (const [name, value] of Object.entries(call.headers(statusCode, current("payment-required"), current("payment-response")))) {
163
+ const key = inlineKey(name);
164
+ if (key)
165
+ inline[key] = value;
128
166
  else
129
167
  res.setHeader(name, value);
130
168
  }
@@ -0,0 +1,17 @@
1
+ import { type ForgeCore, type ForgeOptions, type ForgeResponse } from "./core.js";
2
+ export type { ForgeOptions, OpenApiOptions } from "./core.js";
3
+ type Next = (request: Request) => Response | Promise<Response>;
4
+ export interface ForgeFetch extends Pick<ForgeCore, "enabled" | "challengeSentence" | "enrichOpenApi" | "diagnostics" | "shutdown"> {
5
+ /**
6
+ * Run one request through Forge around `next` (your handler, including your x402 payment middleware).
7
+ * Answers Forge's own routes, removes agent context from the request, and decorates the 402 and the paid response.
8
+ * Fails open: on any internal error the request reaches `next` unchanged, or its response is returned unchanged.
9
+ */
10
+ handle(request: Request, next: Next): Promise<Response>;
11
+ /** Wrap a fetch handler, e.g. `export default { fetch: forge.wrap(app.fetch) }`. Extra arguments (env, ctx) pass through. */
12
+ wrap<A extends unknown[]>(handler: (request: Request, ...rest: A) => Response | Promise<Response>): (request: Request, ...rest: A) => Promise<Response>;
13
+ /** Forge's own routes only (/feedback, /feedback/rate, /feedback/summary, a static openapi.document); null for anything else. */
14
+ route(request: Request): Promise<Response | null>;
15
+ }
16
+ export declare function toResponse(response: ForgeResponse): Response;
17
+ export declare function createForge(options: ForgeOptions): ForgeFetch;
package/dist/fetch.js ADDED
@@ -0,0 +1,171 @@
1
+ // Web-standard adapter: Request in, Response out. Works wherever handlers are (request) => Response:
2
+ // Hono, Next.js route handlers, Cloudflare Workers, Bun, Deno. The Hono and Next adapters build on this.
3
+ import { BODY_LIMIT, SPEC_LIMIT, createForgeCore } from "./core.js";
4
+ /** Bodies Forge will buffer to remove agent_context or add feedback fields. Larger ones pass through untouched. */
5
+ const JSON_LIMIT = 1024 * 1024;
6
+ const isJson = (type) => /^application\/(?:[\w.+-]+\+)?json\b/i.test(type ?? "");
7
+ export function toResponse(response) {
8
+ if (response.body === undefined)
9
+ return new Response(null, { status: response.status, headers: response.headers });
10
+ return new Response(JSON.stringify(response.body), { status: response.status, headers: { ...response.headers, "Content-Type": "application/json; charset=utf-8" } });
11
+ }
12
+ async function readJson(request) {
13
+ const text = (await request.clone().text()).trim();
14
+ if (text.length > BODY_LIMIT)
15
+ throw new Error("body_too_large");
16
+ return text ? JSON.parse(text) : {};
17
+ }
18
+ /** Read a body only when it's declared small enough. Chunked bodies without a length are left alone. */
19
+ const smallEnough = (headers, limit) => {
20
+ const length = Number(headers.get("content-length"));
21
+ return Number.isFinite(length) && length > 0 && length <= limit;
22
+ };
23
+ export function createForge(options) {
24
+ const core = createForgeCore(options);
25
+ const forgeRequest = (request, url) => ({
26
+ method: request.method,
27
+ path: url.pathname,
28
+ header: (name) => request.headers.get(name) ?? undefined,
29
+ query: (name) => url.searchParams.get(name) ?? undefined,
30
+ json: () => readJson(request),
31
+ });
32
+ async function route(request) {
33
+ if (!core.enabled)
34
+ return null;
35
+ const own = await core.route(forgeRequest(request, new URL(request.url)));
36
+ return own ? toResponse(own) : null;
37
+ }
38
+ /** The merchant's own OpenAPI route: fetch it without validators, enrich JSON 200s, pass anything else through. */
39
+ async function spec(request, next) {
40
+ const headers = new Headers(request.headers);
41
+ for (const name of ["if-none-match", "if-modified-since", "accept-encoding"])
42
+ headers.delete(name);
43
+ const response = await next(new Request(request, { headers }));
44
+ try {
45
+ if (response.status !== 200 || response.headers.get("content-encoding") || !isJson(response.headers.get("content-type")))
46
+ return response;
47
+ const text = await response.clone().text();
48
+ if (text.length > SPEC_LIMIT)
49
+ return response;
50
+ const result = core.enrichOpenApi(JSON.parse(text));
51
+ if (!result.report.enriched)
52
+ return response;
53
+ const out = new Headers(response.headers);
54
+ for (const name of ["content-length", "etag", "last-modified"])
55
+ out.delete(name);
56
+ return new Response(JSON.stringify(result.document), { status: 200, statusText: response.statusText, headers: out });
57
+ }
58
+ catch (error) {
59
+ core.onError(error);
60
+ return response;
61
+ }
62
+ }
63
+ async function handle(request, next) {
64
+ if (!core.enabled)
65
+ return next(request);
66
+ // Before the handler: our own routes, the spec, and agent context. Any failure here: the original request goes on.
67
+ let call;
68
+ let forwarded = request;
69
+ try {
70
+ const url = new URL(request.url);
71
+ const own = await core.route(forgeRequest(request, url));
72
+ if (own)
73
+ return toResponse(own);
74
+ if (core.isSpecRequest(request.method, url.pathname))
75
+ return spec(request, next);
76
+ call = core.call({ method: request.method, path: url.pathname, header: (name) => request.headers.get(name) ?? undefined });
77
+ const stripped = call.requestUrl(`${url.pathname}${url.search}`);
78
+ const nextUrl = stripped === `${url.pathname}${url.search}` ? request.url : new URL(stripped, url).href;
79
+ let body;
80
+ if (request.body && isJson(request.headers.get("content-type")) && smallEnough(request.headers, JSON_LIMIT)) {
81
+ const text = await request.clone().text();
82
+ if (text.includes('"agent_context"')) {
83
+ const parsed = JSON.parse(text);
84
+ const without = call.requestBody(parsed);
85
+ if (without !== parsed)
86
+ body = JSON.stringify(without);
87
+ }
88
+ }
89
+ if (nextUrl !== request.url || body !== undefined) {
90
+ const headers = new Headers(request.headers);
91
+ if (body !== undefined)
92
+ headers.delete("content-length");
93
+ forwarded = new Request(nextUrl, { method: request.method, headers, body: body ?? request.body, redirect: request.redirect, signal: request.signal, ...(body === undefined && request.body ? { duplex: "half" } : {}) });
94
+ // Cloudflare Workers attach request metadata as `cf`; keep it.
95
+ const cf = request.cf;
96
+ if (cf !== undefined)
97
+ Object.defineProperty(forwarded, "cf", { value: cf, enumerable: true });
98
+ }
99
+ }
100
+ catch (error) {
101
+ core.onError(error);
102
+ return next(request);
103
+ }
104
+ const response = await next(forwarded);
105
+ // After the handler: decorate the 402 or the paid response. Any failure before the body is read: the original
106
+ // response goes out. Once read, the body is always sent from what was read (decorated or not).
107
+ let added;
108
+ let read;
109
+ try {
110
+ added = call.headers(response.status, response.headers.get("payment-required") ?? undefined, response.headers.get("payment-response") ?? undefined);
111
+ const status = response.status;
112
+ const type = response.headers.get("content-type") ?? "";
113
+ const decorate = status === 402 || (call.feedbackId !== undefined && status >= 200 && status < 300);
114
+ const kind = isJson(type) || /^text\/plain\b/i.test(type);
115
+ // Only buffer JSON or plain text that is declared small, or has no declared length (never streams like SSE).
116
+ if (decorate && kind && response.body && (smallEnough(response.headers, JSON_LIMIT) || !response.headers.has("content-length"))) {
117
+ read = { text: await response.text(), type };
118
+ }
119
+ }
120
+ catch (error) {
121
+ core.onError(error);
122
+ call.finish(response.status);
123
+ return response;
124
+ }
125
+ let body = read?.text;
126
+ if (read) {
127
+ try {
128
+ if (isJson(read.type)) {
129
+ const parsed = JSON.parse(read.text);
130
+ const next = call.json(response.status, parsed);
131
+ if (next !== parsed)
132
+ body = JSON.stringify(next);
133
+ }
134
+ else
135
+ body = call.text(response.status, read.type, read.text);
136
+ }
137
+ catch (error) {
138
+ core.onError(error); // not JSON after all: send it as it was
139
+ }
140
+ }
141
+ call.finish(response.status);
142
+ if (read === undefined) {
143
+ if (!Object.keys(added).length)
144
+ return response;
145
+ try {
146
+ for (const [name, value] of Object.entries(added))
147
+ response.headers.set(name, value);
148
+ return response;
149
+ }
150
+ catch {
151
+ // Immutable headers (e.g. a fetch() response): rebuild around the same body stream.
152
+ }
153
+ }
154
+ const headers = new Headers(response.headers);
155
+ for (const [name, value] of Object.entries(added))
156
+ headers.set(name, value);
157
+ if (read !== undefined)
158
+ headers.delete("content-length");
159
+ return new Response(read === undefined ? response.body : body, { status: response.status, statusText: response.statusText, headers });
160
+ }
161
+ return {
162
+ enabled: core.enabled,
163
+ challengeSentence: core.challengeSentence,
164
+ enrichOpenApi: core.enrichOpenApi,
165
+ diagnostics: core.diagnostics,
166
+ shutdown: core.shutdown,
167
+ handle,
168
+ route,
169
+ wrap: (handler) => (request, ...rest) => handle(request, (r) => handler(r, ...rest)),
170
+ };
171
+ }
package/dist/hono.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ import type { MiddlewareHandler } from "hono";
2
+ import type { ForgeOptions } from "./core.js";
3
+ import { type ForgeFetch } from "./fetch.js";
4
+ export type { ForgeOptions, OpenApiOptions } from "./core.js";
5
+ export interface Forge extends Omit<ForgeFetch, "handle" | "wrap" | "route"> {
6
+ /** Mount once, before your x402 payment middleware (`@x402/hono`) and before your OpenAPI route. */
7
+ middleware(): MiddlewareHandler;
8
+ }
9
+ /** Never throws (unless `strict`): with invalid options it warns and returns a middleware that only calls next(). */
10
+ export declare function createForge(options: ForgeOptions): Forge;
package/dist/hono.js ADDED
@@ -0,0 +1,35 @@
1
+ import { createForge as createForgeFetch } from "./fetch.js";
2
+ /** Never throws (unless `strict`): with invalid options it warns and returns a middleware that only calls next(). */
3
+ export function createForge(options) {
4
+ const forge = createForgeFetch(options);
5
+ return {
6
+ enabled: forge.enabled,
7
+ challengeSentence: forge.challengeSentence,
8
+ enrichOpenApi: forge.enrichOpenApi,
9
+ diagnostics: forge.diagnostics,
10
+ shutdown: forge.shutdown,
11
+ middleware() {
12
+ if (!forge.enabled)
13
+ return async (_c, next) => next();
14
+ return async (c, next) => {
15
+ let ranNext = false;
16
+ const response = await forge.handle(c.req.raw, async (request) => {
17
+ // Hono reads url, headers and body from c.req.raw on demand, so handlers see the request without agent context.
18
+ if (request !== c.req.raw)
19
+ c.req.raw = request;
20
+ ranNext = true;
21
+ await next();
22
+ return c.res;
23
+ });
24
+ if (!ranNext)
25
+ return response; // one of Forge's own routes
26
+ if (response !== c.res) {
27
+ // Hono's c.res setter copies the previous response's headers onto the new one, which would undo
28
+ // Forge's rewritten PAYMENT-REQUIRED / PAYMENT-RESPONSE. Clear it first.
29
+ c.res = undefined;
30
+ c.res = response;
31
+ }
32
+ };
33
+ },
34
+ };
35
+ }
package/dist/id.d.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  export declare const FEEDBACK_ID_PATTERN: RegExp;
2
2
  export declare const DEFAULT_TTL_MS: number;
3
+ /**
4
+ * The key feedback IDs are signed with, derived from the merchant's API key. Backends keep this (encrypted)
5
+ * instead of the API key itself, or derive it from the Bearer key on each request.
6
+ */
7
+ export declare function deriveSigningKey(apiKey: string): string;
3
8
  export declare function mintFeedbackId(key: string, now?: number): string;
4
9
  export type FeedbackIdCheck = {
5
10
  valid: true;
package/dist/id.js CHANGED
@@ -5,6 +5,13 @@ export const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
5
5
  const HEAD_BYTES = 10;
6
6
  const TAG_BYTES = 6;
7
7
  const CLOCK_SKEW_MS = 5 * 60 * 1000;
8
+ /**
9
+ * The key feedback IDs are signed with, derived from the merchant's API key. Backends keep this (encrypted)
10
+ * instead of the API key itself, or derive it from the Bearer key on each request.
11
+ */
12
+ export function deriveSigningKey(apiKey) {
13
+ return createHmac("sha256", apiKey).update("forge-feedback-id/v1").digest("base64url");
14
+ }
8
15
  function tag(key, head) {
9
16
  return createHmac("sha256", key).update(head).digest().subarray(0, TAG_BYTES);
10
17
  }
package/dist/index.d.ts CHANGED
@@ -4,10 +4,14 @@ export { createForgeCore, checkOptions, BODY_LIMIT, SPEC_LIMIT } from "./core.js
4
4
  export type { ForgeCall, ForgeCore, ForgeDiagnostics, ForgeFeedbackOptions, ForgeOptions, ForgeRequest, ForgeResponse, OpenApiOptions } from "./core.js";
5
5
  export { enrichOpenApi, detectVersion } from "./openapi.js";
6
6
  export type { EnrichOptions, EnrichReport, OperationReport, ResponseSupport, SpecVersion } from "./openapi.js";
7
- export { mintFeedbackId, verifyFeedbackId, FEEDBACK_ID_PATTERN, DEFAULT_TTL_MS } from "./id.js";
7
+ export { deriveSigningKey, mintFeedbackId, verifyFeedbackId, FEEDBACK_ID_PATTERN, DEFAULT_TTL_MS } from "./id.js";
8
8
  export type { FeedbackIdCheck } from "./id.js";
9
9
  export { OUTCOMES, ISSUES, NOTE_MAX_LENGTH, PROTOCOL, parseSubmission } from "./values.js";
10
10
  export type { Outcome, Issue, Submission } from "./values.js";
11
- export { describeChallenge, describeChallengeBody, feedbackExtension, FEEDBACK_EXTENSION, readPaymentHeader } from "./x402.js";
11
+ export { describeChallenge, describeChallengeBody, describeReceipt, feedbackExtension, receiptExtension, FEEDBACK_EXTENSION, readPaymentHeader, readReceipt } from "./x402.js";
12
+ export { ASK, TONES, checkAskText } from "./ask.js";
13
+ export { AGENT_TYPES, CLIENTS, CONTEXT_FIELD, CONTEXT_QUERY, agentContextSchema, parseAgentContext } from "./context.js";
14
+ export type { AgentContext } from "./context.js";
15
+ export type { AskTexts, Tone } from "./ask.js";
12
16
  export type { ChallengeAdditions } from "./x402.js";
13
17
  export type { ForgeEvent } from "./reporter.js";
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  export { createForge, createForgeFeedback } from "./express.js";
2
2
  export { createForgeCore, checkOptions, BODY_LIMIT, SPEC_LIMIT } from "./core.js";
3
3
  export { enrichOpenApi, detectVersion } from "./openapi.js";
4
- export { mintFeedbackId, verifyFeedbackId, FEEDBACK_ID_PATTERN, DEFAULT_TTL_MS } from "./id.js";
4
+ export { deriveSigningKey, mintFeedbackId, verifyFeedbackId, FEEDBACK_ID_PATTERN, DEFAULT_TTL_MS } from "./id.js";
5
5
  export { OUTCOMES, ISSUES, NOTE_MAX_LENGTH, PROTOCOL, parseSubmission } from "./values.js";
6
- export { describeChallenge, describeChallengeBody, feedbackExtension, FEEDBACK_EXTENSION, readPaymentHeader } from "./x402.js";
6
+ export { describeChallenge, describeChallengeBody, describeReceipt, feedbackExtension, receiptExtension, FEEDBACK_EXTENSION, readPaymentHeader, readReceipt } from "./x402.js";
7
+ export { ASK, TONES, checkAskText } from "./ask.js";
8
+ export { AGENT_TYPES, CLIENTS, CONTEXT_FIELD, CONTEXT_QUERY, agentContextSchema, parseAgentContext } from "./context.js";
package/dist/next.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type { ForgeOptions } from "./core.js";
2
+ import { type ForgeFetch } from "./fetch.js";
3
+ export type { ForgeOptions, OpenApiOptions } from "./core.js";
4
+ export interface Forge extends Omit<ForgeFetch, "wrap" | "route"> {
5
+ /**
6
+ * Wrap a route handler, outermost: `export const GET = forge.withForge(withX402(handler, route, server))`.
7
+ * The handler receives the request Forge forwarded (without agent context); the 402 and the paid response are decorated.
8
+ */
9
+ withForge<R extends Request, C = unknown>(handler: (request: R, context: C) => Response | Promise<Response>): (request: R, context: C) => Promise<Response>;
10
+ /**
11
+ * Handlers for Forge's own routes. In app/feedback/[[...path]]/route.ts: `export const { GET, POST } = forge.routes;`
12
+ * (and in app/openapi.json/route.ts when you pass `openapi.document`). Anything else answers 404.
13
+ */
14
+ routes: {
15
+ GET: (request: Request) => Promise<Response>;
16
+ POST: (request: Request) => Promise<Response>;
17
+ HEAD: (request: Request) => Promise<Response>;
18
+ };
19
+ /**
20
+ * Wrap an x402 proxy (`paymentProxy`) so its 402 challenges carry the rating ask. Paid responses still need
21
+ * withForge on the route: the proxy never sees the route's body.
22
+ */
23
+ proxy<R extends Request>(proxy: (request: R) => Response | Promise<Response>): (request: R) => Promise<Response>;
24
+ }
25
+ export declare function createForge(options: ForgeOptions): Forge;