@forgeintel/sdk 0.4.0-beta.1 → 0.5.0-alpha.oldfeedback.1.b13

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,17 +1,20 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  // Framework-free Forge logic. Adapters (express.ts, later fetch.ts) translate their request/response
2
3
  // objects to the small interfaces below and write out what the core returns.
3
4
  import { DEFAULT_TTL_MS, deriveSigningKey, mintFeedbackId, verifyFeedbackId } from "./id.js";
4
5
  import { createOperationIndex, enrichOpenApi } from "./openapi.js";
5
6
  import { ASK, TONES, checkAskText } from "./ask.js";
6
- import { agentContextAsk, parseAgentContext, takeFromBody, takeFromUrl } from "./context.js";
7
+ import { agentContextAsk, contextIssues, parseAgentContext, takeFromBody, takeFromUrl } from "./context.js";
7
8
  import { EventReporter } from "./reporter.js";
9
+ import { captureClientHeaders } from "./client-signals.js";
8
10
  import { ISSUES, NOTE_MAX_LENGTH, OUTCOMES, PROTOCOL, parseSubmission } from "./values.js";
9
- import { describeChallenge, describeChallengeBody, describeReceipt, feedbackExtension, readPaymentHeader, readReceipt, receiptExtension } from "./x402.js";
11
+ import { FEEDBACK_FIELD, challengeOrigin, describeChallenge, describeChallengeBody, describeReceipt, feedbackExtension, paymentOrigin, readPaymentHeader, readReceipt, receiptExtension } from "./x402.js";
10
12
  export const FEEDBACK_HEADERS = { "Cache-Control": "no-store", "X-Robots-Tag": "noindex" };
11
13
  /** Max JSON body for POST {basePath}. */
12
14
  export const BODY_LIMIT = 8 * 1024;
13
15
  /** Max OpenAPI document an adapter should buffer for enrichment. */
14
16
  export const SPEC_LIMIT = 10 * 1024 * 1024;
17
+ export const DEFAULT_BACKEND_URL = "https://app-api.forgeintel.co/api/sdk/v2";
15
18
  /**
16
19
  * Check options without throwing. Invalid required options are errors (Forge runs disabled);
17
20
  * invalid optional values are warnings and fall back to their defaults.
@@ -34,10 +37,12 @@ export function checkOptions(input) {
34
37
  };
35
38
  if (typeof raw.apiKey !== "string" || !raw.apiKey.trim())
36
39
  errors.push("apiKey is missing (set it to your merchant key, ffk_…)");
37
- if (!absoluteUrl(raw.backendUrl))
40
+ if (raw.backendUrl === undefined)
41
+ options.backendUrl = DEFAULT_BACKEND_URL;
42
+ else if (!absoluteUrl(raw.backendUrl))
38
43
  errors.push(`backendUrl must be an absolute http(s) URL (got ${JSON.stringify(raw.backendUrl ?? null)})`);
39
- if (!absoluteUrl(raw.publicUrl))
40
- errors.push(`publicUrl must be an absolute http(s) URL, this service's public origin (got ${JSON.stringify(raw.publicUrl ?? null)})`);
44
+ if (raw.publicUrl !== undefined && !absoluteUrl(raw.publicUrl))
45
+ errors.push(`publicUrl must be an absolute http(s) URL (got ${JSON.stringify(raw.publicUrl ?? null)})`);
41
46
  const fallback = (key, ok, expected) => {
42
47
  if (raw[key] === undefined || ok)
43
48
  return;
@@ -59,8 +64,16 @@ export function checkOptions(input) {
59
64
  fallback("tone", TONES.includes(raw.tone), `must be one of ${TONES.map((t) => `"${t}"`).join(", ")}`);
60
65
  fallback("challengeSentence", typeof raw.challengeSentence === "string" && raw.challengeSentence.trim() !== "", "must be a non-empty string");
61
66
  fallback("rateHint", boolean(raw.rateHint) || typeof raw.rateHint === "string", "must be a boolean or a string");
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"])
67
+ fallback("agentContext", boolean(raw.agentContext) || (!!raw.agentContext && typeof raw.agentContext === "object" && !Array.isArray(raw.agentContext)), "must be true, false or { searchQuery, required }");
68
+ if (raw.agentContext && typeof raw.agentContext === "object" && !Array.isArray(raw.agentContext)) {
69
+ const context = raw.agentContext;
70
+ for (const key of ["required", "searchQuery"]) {
71
+ if (context[key] !== undefined && typeof context[key] !== "boolean") {
72
+ errors.push(`agentContext.${key} must be a boolean`);
73
+ }
74
+ }
75
+ }
76
+ for (const key of ["feedback", "describeChallenges", "challengeExtension", "receiptExtension", "injectBody", "injectText", "strict"])
64
77
  fallback(key, boolean(raw[key]), "must be true or false");
65
78
  // The lines no wording may cross, whatever the merchant configures (see ask.ts).
66
79
  for (const key of ["challengeSentence", "rateHint"]) {
@@ -91,15 +104,20 @@ export function checkOptions(input) {
91
104
  }
92
105
  return { errors, warnings, options };
93
106
  }
94
- /** Everything passes through untouched. Used when invalid options turned Forge off. */
107
+ /**
108
+ * Used when invalid options turned Forge off: nothing is recorded or added, but agent context is still removed from
109
+ * requests, so agents that learned about it (an earlier deploy, a cached listing) can't trip strict validators.
110
+ */
95
111
  function disabledCore(errors, warnings) {
96
112
  const passThrough = {
97
113
  feedbackId: undefined,
114
+ contextRequired: false,
115
+ contextError: () => null,
98
116
  json: (_status, body) => body,
99
117
  text: (_status, _type, body) => body,
100
118
  headers: () => ({}),
101
- requestBody: (body) => body,
102
- requestUrl: (url) => url,
119
+ requestBody: (body) => takeFromBody(body).body,
120
+ requestUrl: (url) => takeFromUrl(url).url,
103
121
  finish: () => { },
104
122
  };
105
123
  return {
@@ -142,26 +160,36 @@ export function createForgeCore(input) {
142
160
  }
143
161
  }
144
162
  function enabledCore(options, configWarnings) {
145
- const { apiKey, backendUrl, publicUrl } = options;
163
+ const { apiKey, publicUrl } = options;
164
+ const backendUrl = options.backendUrl ?? DEFAULT_BACKEND_URL;
146
165
  // Feedback IDs are signed with a key derived from the API key, never with the API key itself.
147
166
  const signingKey = deriveSigningKey(apiKey);
148
167
  const backendPath = new URL(backendUrl).pathname.replace(/\/+$/, "") || "/v1";
149
168
  const backend = (name) => new URL(`${backendPath}/${name}`, backendUrl).href;
150
169
  const basePath = (options.basePath ?? "/feedback").replace(/\/+$/, "");
151
170
  const ratePath = `${basePath}/rate`;
152
- const rateUrl = new URL(ratePath, publicUrl).href;
153
- const formUrl = new URL(basePath, publicUrl).href;
154
171
  const summaryPath = `${basePath}/summary`;
155
- const summaryUrl = new URL(summaryPath, publicUrl).href;
172
+ // Rating links are absolute whenever an origin is known: publicUrl, else the origin registered in Forge
173
+ // (fetched in the background, never on the request path), else the origin of the request's own x402 resource.
174
+ const explicitOrigin = publicUrl ? new URL(publicUrl).origin : undefined;
175
+ let registeredOrigin;
176
+ const linksFor = (requestOrigin) => {
177
+ const base = explicitOrigin ?? registeredOrigin ?? requestOrigin;
178
+ const at = (path) => (base ? new URL(path, base).href : path);
179
+ return { rate: at(ratePath), form: at(basePath), summary: at(summaryPath) };
180
+ };
156
181
  const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
157
182
  const fetchImpl = options.fetch ?? fetch;
158
- const describe = options.describeChallenges ?? true;
183
+ const feedback = options.feedback !== false;
184
+ const describe = feedback && (options.describeChallenges ?? true);
159
185
  const injectBody = options.injectBody ?? true;
160
186
  const injectText = options.injectText ?? false;
161
187
  const tone = options.tone ?? "soft";
162
- const contextOption = options.agentContext ?? true;
188
+ const contextOption = options.agentContext;
163
189
  const collectContext = contextOption !== false;
164
190
  const searchQuery = collectContext && (typeof contextOption !== "object" || contextOption.searchQuery !== false);
191
+ // Optional unless configured: true, or an object without required: false, keeps the strict (400) behavior.
192
+ const requiredContext = contextOption === true || (typeof contextOption === "object" && contextOption.required !== false);
165
193
  const receipts = options.receiptExtension ?? true;
166
194
  const rateHint = options.rateHint === false
167
195
  ? null
@@ -186,27 +214,48 @@ function enabledCore(options, configWarnings) {
186
214
  lastLogged = message;
187
215
  });
188
216
  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);
190
- // Idempotency marker: the rate URL when the sentence contains it, otherwise the sentence itself.
191
- const challengeMarker = challengeSentence.includes(rateUrl) ? rateUrl : challengeSentence;
192
- const challengeAdditions = {
193
- ...(describe ? { sentence: challengeSentence, marker: challengeMarker } : {}),
194
- ...(options.challengeExtension === false ? {} : { extension: feedbackExtension(rateUrl, tone, collectContext ? agentContextAsk({ searchQuery }) : undefined) }),
195
- };
196
- const touchChallenges = Boolean(challengeAdditions.sentence || challengeAdditions.extension);
217
+ const sentenceTemplate = options.challengeSentence ?? ASK[tone].challengeSentence;
218
+ // Idempotency marker: the rate path, which every described challenge contains whatever origin its link uses.
219
+ const describedBy = (sentence, rate) => (sentence.includes(rate) ? ratePath : sentence);
220
+ const sentenceFor = (links) => feedback ? sentenceTemplate.replaceAll("{rate_url}", links.rate).replaceAll("{summary_url}", links.summary) : "";
221
+ const additionsCache = new Map();
222
+ function challengeAdditions(requestOrigin) {
223
+ const links = linksFor(requestOrigin);
224
+ const cached = additionsCache.get(links.rate);
225
+ if (cached)
226
+ return cached;
227
+ const sentence = sentenceFor(links);
228
+ const marker = describedBy(sentence, links.rate);
229
+ const additions = {
230
+ ...(describe ? { sentence, marker, ...(marker === ratePath ? { shortSentence: ASK[tone].shortChallengeSentence.replaceAll("{rate_url}", links.rate) } : {}) } : {}),
231
+ ...(!feedback || options.challengeExtension === false ? {} : { extension: feedbackExtension(links.rate, tone) }),
232
+ };
233
+ if (collectContext)
234
+ additions.contextExtension = { info: { agent_context: agentContextAsk({ searchQuery, required: requiredContext }), optional: !requiredContext } };
235
+ if (collectContext)
236
+ additions.bazaarContext = { searchQuery, required: requiredContext };
237
+ if (additionsCache.size >= 16)
238
+ additionsCache.clear(); // one entry per origin; bounded against spoofed Host headers
239
+ additionsCache.set(links.rate, additions);
240
+ return additions;
241
+ }
242
+ const touchChallenges = Boolean((describe && feedback) || (feedback && options.challengeExtension !== false) || collectContext);
197
243
  // Set once a document has been enriched; lets body injection respect strict response schemas.
198
244
  let allowInjection = null;
199
245
  let lastWarnings = "";
200
246
  function enrich(document) {
247
+ const links = linksFor();
248
+ const sentence = sentenceFor(links);
201
249
  const result = enrichOpenApi(document, {
202
- publicUrl,
250
+ publicUrl: explicitOrigin ?? registeredOrigin,
251
+ feedback,
203
252
  basePath,
204
- sentence: challengeSentence,
205
- marker: challengeMarker,
253
+ sentence,
254
+ marker: describedBy(sentence, links.rate),
206
255
  isPaidOperation: openapi?.isPaidOperation,
207
256
  describeOperations: openapi?.describeOperations,
208
257
  hintField: Boolean(rateHint),
209
- agentContext: collectContext ? { searchQuery } : undefined,
258
+ agentContext: collectContext ? { searchQuery, required: requiredContext } : undefined,
210
259
  });
211
260
  stats.openapi = result.report;
212
261
  if (result.report.enriched)
@@ -221,30 +270,65 @@ function enabledCore(options, configWarnings) {
221
270
  // With interception or an async provider, that starts once the spec has been served.
222
271
  if (openapi && openapi.document !== undefined && typeof openapi.document !== "function")
223
272
  enrich(openapi.document);
224
- const form = {
225
- protocol: PROTOCOL,
226
- cost: "free",
227
- ratings_are_public_at: summaryUrl,
228
- feedback_id: "Returned by paid responses as the feedback_id body field and the Forge-Feedback-Id header.",
229
- quick: { method: "GET", url: `${rateUrl}?feedback_id=FEEDBACK_ID&outcome=OUTCOME`, optional_param: "issue=ISSUE" },
230
- detailed: {
231
- method: "POST",
232
- url: formUrl,
233
- content_type: "application/json",
234
- body: { feedback_id: "FEEDBACK_ID", outcome: "OUTCOME", issue: "ISSUE (optional)", note: `NOTE (optional, max ${NOTE_MAX_LENGTH} chars, no user data)` },
235
- },
236
- fields: {
237
- outcome: { required: true, values: OUTCOMES },
238
- issue: { required: false, values: ISSUES },
239
- note: { required: false, max_length: NOTE_MAX_LENGTH, post_only: true },
240
- },
273
+ const formDoc = () => {
274
+ const links = linksFor();
275
+ return {
276
+ protocol: PROTOCOL,
277
+ cost: "free",
278
+ ratings_are_public_at: links.summary,
279
+ feedback_id: `Returned by paid responses in the ${FEEDBACK_FIELD} body object (feedback_id) and the Forge-Feedback-Id header.`,
280
+ quick: { method: "GET", url: `${links.rate}?feedback_id=FEEDBACK_ID&outcome=OUTCOME`, optional_param: "issue=ISSUE" },
281
+ detailed: {
282
+ method: "POST",
283
+ url: links.form,
284
+ content_type: "application/json",
285
+ body: { feedback_id: "FEEDBACK_ID", outcome: "OUTCOME", issue: "ISSUE (optional)", note: `NOTE (optional, max ${NOTE_MAX_LENGTH} chars, no user data)` },
286
+ },
287
+ fields: {
288
+ outcome: { required: true, values: OUTCOMES },
289
+ issue: { required: false, values: ISSUES },
290
+ note: { required: false, max_length: NOTE_MAX_LENGTH, post_only: true },
291
+ },
292
+ };
241
293
  };
294
+ // The origin registered in Forge, fetched in the background on first use and refreshed every few hours.
295
+ // Requests never wait on it; until it arrives, links fall back to the request's own resource origin.
296
+ let originCheckedAt = -Infinity;
297
+ const ORIGIN_REFRESH_MS = 6 * 3600_000;
298
+ function refreshRegisteredOrigin() {
299
+ if (explicitOrigin || Date.now() - originCheckedAt < ORIGIN_REFRESH_MS)
300
+ return;
301
+ originCheckedAt = Date.now();
302
+ void (async () => {
303
+ try {
304
+ const response = await fetchImpl(backend("project"), {
305
+ headers: { Authorization: `Bearer ${apiKey}` },
306
+ signal: AbortSignal.timeout(5000),
307
+ });
308
+ if (!response.ok)
309
+ return;
310
+ const origin = (await response.json())?.publicOrigin;
311
+ if (typeof origin !== "string" || !origin.startsWith("https://"))
312
+ return;
313
+ const next = new URL(origin).origin;
314
+ if (next === registeredOrigin)
315
+ return;
316
+ registeredOrigin = next;
317
+ additionsCache.clear();
318
+ if (openapi && openapi.document !== undefined && typeof openapi.document !== "function")
319
+ enrich(openapi.document);
320
+ }
321
+ catch {
322
+ // Offline, older backend, or blocked: keep the fallbacks.
323
+ }
324
+ })();
325
+ }
242
326
  const reply = (status, body, headers = FEEDBACK_HEADERS) => body === undefined ? { status, headers } : { status, headers, body };
243
327
  const badRequest = (error) => reply(400, {
244
328
  recorded: false,
245
329
  error,
246
330
  allowed: { outcome: Object.keys(OUTCOMES), issue: Object.keys(ISSUES) },
247
- example: `${rateUrl}?feedback_id=FEEDBACK_ID&outcome=fully`,
331
+ example: `${linksFor().rate}?feedback_id=FEEDBACK_ID&outcome=fully`,
248
332
  });
249
333
  async function submit(request, input, via) {
250
334
  const parsed = parseSubmission(input, { allowNote: via === "POST" });
@@ -258,7 +342,7 @@ function enabledCore(options, configWarnings) {
258
342
  const upstream = await fetchImpl(backend("feedback"), {
259
343
  method: "POST",
260
344
  headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
261
- body: JSON.stringify({ ...parsed.value, via, user_agent: request.header("user-agent") ?? null }),
345
+ body: JSON.stringify({ ...parsed.value, via, user_agent: request.header("user-agent") ?? null, request_headers: captureClientHeaders((name) => request.header(name)) }),
262
346
  signal: AbortSignal.timeout(3000),
263
347
  });
264
348
  const body = await upstream.json().catch(() => ({ recorded: false, error: "feedback_unavailable" }));
@@ -288,6 +372,7 @@ function enabledCore(options, configWarnings) {
288
372
  return reply(summaryCache.status, summaryCache.body, { "Cache-Control": "public, max-age=30" });
289
373
  }
290
374
  async function route(request) {
375
+ refreshRegisteredOrigin();
291
376
  try {
292
377
  return await handleRoute(request);
293
378
  }
@@ -299,9 +384,9 @@ function enabledCore(options, configWarnings) {
299
384
  }
300
385
  async function handleRoute(request) {
301
386
  const { method, path } = request;
302
- if (path === basePath) {
387
+ if (feedback && path === basePath) {
303
388
  if (method === "GET" || method === "HEAD")
304
- return reply(200, form);
389
+ return reply(200, formDoc());
305
390
  if (method === "POST") {
306
391
  let body;
307
392
  try {
@@ -315,23 +400,29 @@ function enabledCore(options, configWarnings) {
315
400
  return submit(request, body, "POST");
316
401
  }
317
402
  }
318
- if (path === summaryPath && (method === "GET" || method === "HEAD"))
403
+ if (feedback && path === summaryPath && (method === "GET" || method === "HEAD"))
319
404
  return summary();
320
- if (path === ratePath) {
405
+ if (feedback && path === ratePath) {
321
406
  // HEAD and other methods must never record a rating (link checkers send HEAD).
322
407
  if (method !== "GET")
323
408
  return reply(405, undefined, { ...FEEDBACK_HEADERS, Allow: "GET" });
324
409
  const first = (v) => (Array.isArray(v) ? v[0] : v);
325
410
  return submit(request, { feedback_id: first(request.query("feedback_id")), outcome: first(request.query("outcome")), issue: first(request.query("issue")) }, "GET");
326
411
  }
327
- if (openapi && openapi.document !== undefined && method === "GET" && specPaths.has(path)) {
328
- const source = typeof openapi.document === "function" ? await openapi.document() : openapi.document;
329
- return reply(200, enrich(source).document, { "Cache-Control": "no-store" });
412
+ if (openapi && method === "GET" && specPaths.has(path)) {
413
+ const requestHeaders = captureClientHeaders((name) => request.header(name));
414
+ reporter.push({ type: "discovery", route: `${method} ${path}`, ts: Date.now(), user_agent: requestHeaders?.["user-agent"], request_headers: requestHeaders });
415
+ if (openapi.document !== undefined) {
416
+ const source = typeof openapi.document === "function" ? await openapi.document() : openapi.document;
417
+ return reply(200, enrich(source).document, { "Cache-Control": "no-store" });
418
+ }
330
419
  }
331
420
  return null;
332
421
  }
333
422
  const passThrough = {
334
423
  feedbackId: undefined,
424
+ contextRequired: false,
425
+ contextError: () => null,
335
426
  json: (_status, body) => body,
336
427
  text: (_status, _type, body) => body,
337
428
  headers: () => ({}),
@@ -352,18 +443,26 @@ function enabledCore(options, configWarnings) {
352
443
  const started = Date.now();
353
444
  const route = `${request.method} ${request.path}`;
354
445
  const userAgent = request.header("user-agent") ?? undefined;
446
+ const requestHeaders = captureClientHeaders((name) => request.header(name));
355
447
  const paymentHeader = request.header("payment-signature") ?? request.header("x-payment"); // x402 v2 / v1
356
- const feedbackId = paymentHeader ? mintFeedbackId(signingKey) : undefined;
448
+ const feedbackId = feedback && paymentHeader ? mintFeedbackId(signingKey) : undefined;
449
+ const interactionId = paymentHeader && !feedback ? randomUUID() : undefined;
357
450
  if (feedbackId)
358
451
  stats.minted++;
359
- const feedbackUrl = feedbackId ? `${rateUrl}?feedback_id=${feedbackId}&outcome=` : "";
452
+ refreshRegisteredOrigin();
453
+ // Links in this paid response use the origin the payment was made for when none is configured or registered.
454
+ const callLinks = feedbackId ? linksFor(paymentOrigin(paymentHeader)) : undefined;
455
+ const feedbackUrl = callLinks ? `${callLinks.rate}?feedback_id=${feedbackId}&outcome=` : "";
360
456
  let bodyChallenge = false;
361
457
  let headerChallenge = false;
362
458
  let context;
459
+ let rawContext = {};
363
460
  let receiptFacts = {};
364
461
  const remember = (raw) => {
365
462
  if (!collectContext)
366
463
  return;
464
+ if (raw && typeof raw === "object" && !Array.isArray(raw))
465
+ rawContext = { ...rawContext, ...raw };
367
466
  const parsed = parseAgentContext(raw, { searchQuery });
368
467
  if (parsed)
369
468
  context = { ...context, ...parsed };
@@ -371,12 +470,24 @@ function enabledCore(options, configWarnings) {
371
470
  const ok = (status) => status >= 200 && status < 300;
372
471
  const handle = {
373
472
  feedbackId,
473
+ contextRequired: requiredContext && !!paymentHeader,
474
+ contextError() {
475
+ if (!handle.contextRequired)
476
+ return null;
477
+ const issues = contextIssues(rawContext, { searchQuery });
478
+ return issues.length ? {
479
+ status: 400, headers: {}, body: {
480
+ error: "agent_context_required", issues,
481
+ message: agentContextAsk({ searchQuery, required: true }),
482
+ },
483
+ } : null;
484
+ },
374
485
  json(status, body) {
375
486
  try {
376
487
  if (status === 402) {
377
488
  bodyChallenge = !!body && typeof body === "object" && "x402Version" in body;
378
489
  // v1 challenges (and v2 challenges echoed in the body) live in the JSON body.
379
- const described = touchChallenges ? describeChallengeBody(body, challengeAdditions) : undefined;
490
+ const described = touchChallenges ? describeChallengeBody(body, challengeAdditions(challengeOrigin(body))) : undefined;
380
491
  if (described) {
381
492
  stats.challengesDescribed++;
382
493
  return described;
@@ -388,15 +499,16 @@ function enabledCore(options, configWarnings) {
388
499
  body !== null &&
389
500
  typeof body === "object" &&
390
501
  Object.getPrototypeOf(body) === Object.prototype &&
391
- !("feedback_id" in body) &&
392
- !("feedback_url" in body) &&
393
- !(rateHint && "rate_this_call" in body) &&
502
+ !(FEEDBACK_FIELD in body) &&
394
503
  (allowInjection?.(request.method, request.path, status) ?? true)) {
504
+ // One namespaced object, so the merchant's own fields stay recognizably theirs.
395
505
  return {
396
506
  ...body,
397
- feedback_id: feedbackId,
398
- feedback_url: feedbackUrl,
399
- ...(rateHint ? { rate_this_call: rateHint.replaceAll("{feedback_url}", feedbackUrl).replaceAll("{summary_url}", summaryUrl) } : {}),
507
+ [FEEDBACK_FIELD]: {
508
+ feedback_id: feedbackId,
509
+ feedback_url: feedbackUrl,
510
+ ...(rateHint ? { rate_this_call: rateHint.replaceAll("{feedback_url}", feedbackUrl).replaceAll("{summary_url}", callLinks.summary) } : {}),
511
+ },
400
512
  };
401
513
  }
402
514
  }
@@ -418,17 +530,17 @@ function enabledCore(options, configWarnings) {
418
530
  if (status === 402 && paymentRequired)
419
531
  headerChallenge = true;
420
532
  if (status === 402 && touchChallenges && paymentRequired) {
421
- const next = describeChallenge(paymentRequired, challengeAdditions);
533
+ const next = describeChallenge(paymentRequired, challengeAdditions(challengeOrigin(paymentRequired)));
422
534
  if (next) {
423
535
  set["PAYMENT-REQUIRED"] = next;
424
536
  stats.challengesDescribed++;
425
537
  }
426
538
  }
539
+ if (paymentHeader && ok(status) && paymentResponse)
540
+ receiptFacts = readReceipt(paymentResponse);
427
541
  if (feedbackId && ok(status)) {
428
542
  set["Forge-Feedback-Id"] = feedbackId;
429
- if (paymentResponse)
430
- receiptFacts = readReceipt(paymentResponse);
431
- const receipt = receipts && paymentResponse ? describeReceipt(paymentResponse, receiptExtension(rateUrl, feedbackId, tone)) : undefined;
543
+ const receipt = receipts && paymentResponse ? describeReceipt(paymentResponse, receiptExtension(callLinks.rate, feedbackId, tone)) : undefined;
432
544
  if (receipt)
433
545
  set["PAYMENT-RESPONSE"] = receipt;
434
546
  }
@@ -475,19 +587,20 @@ function enabledCore(options, configWarnings) {
475
587
  const ts = Date.now();
476
588
  if (status === 402 && (hadChallengeHeader || headerChallenge || bodyChallenge)) {
477
589
  // v2 carries the challenge in a header; v1 in the body. Both count as a challenge.
478
- reporter.push({ type: "challenge", route, ts, user_agent: userAgent, ...(context ? { agent_context: context } : {}) });
590
+ reporter.push({ type: "challenge", route, ts, user_agent: userAgent, request_headers: requestHeaders, ...(context ? { agent_context: context } : {}) });
479
591
  }
480
- if (feedbackId) {
592
+ if (feedbackId || interactionId) {
481
593
  const facts = { ...readPaymentHeader(paymentHeader), ...receiptFacts };
482
594
  reporter.push({
483
595
  type: "interaction",
484
- feedback_id: feedbackId,
596
+ ...(feedbackId ? { feedback_id: feedbackId } : { interaction_id: interactionId }),
485
597
  route,
486
598
  status,
487
599
  latency_ms: ts - started,
488
600
  // Only report the payer once the call succeeded (i.e. settlement went through).
489
601
  ...(ok(status) ? facts : { network: facts.network, amount: facts.amount }),
490
602
  user_agent: userAgent,
603
+ request_headers: requestHeaders,
491
604
  ...(context ? { agent_context: context } : {}),
492
605
  ts,
493
606
  });
@@ -497,7 +610,9 @@ function enabledCore(options, configWarnings) {
497
610
  }
498
611
  return {
499
612
  enabled: true,
500
- challengeSentence,
613
+ get challengeSentence() {
614
+ return sentenceFor(linksFor());
615
+ },
501
616
  route,
502
617
  isSpecRequest: (method, path) => Boolean(openapi) && method === "GET" && specPaths.has(path),
503
618
  enrichOpenApi: enrich,
package/dist/express.js CHANGED
@@ -1,3 +1,5 @@
1
+ // Express adapter (4.21+ and 5): wires the framework-free core into req/res.
2
+ import express from "express";
1
3
  import { CONTEXT_QUERY } from "./context.js";
2
4
  import { BODY_LIMIT, SPEC_LIMIT, createForgeCore } from "./core.js";
3
5
  /** Never throws (unless `strict`): with invalid options it warns and returns a middleware that only calls next(). */
@@ -124,7 +126,7 @@ export function createForge(options) {
124
126
  },
125
127
  });
126
128
  }
127
- function observe(req, res) {
129
+ async function observe(req, res) {
128
130
  const call = core.call({ method: req.method, path: req.originalUrl.split("?")[0], header: (name) => req.get(name) });
129
131
  try {
130
132
  takeAgentContext(req, call);
@@ -132,6 +134,24 @@ export function createForge(options) {
132
134
  catch (error) {
133
135
  core.onError(error);
134
136
  }
137
+ if (call.contextRequired) {
138
+ // Required context: parse before payment middleware even when the merchant mounts express.json later.
139
+ // The body setter above captures context and leaves only merchant fields in req.body.
140
+ const parseError = await new Promise((resolve) => {
141
+ express.json({ limit: "1mb", type: ["application/json", "application/*+json"] })(req, res, resolve);
142
+ });
143
+ if (parseError) {
144
+ call.finish(400);
145
+ send(res, { status: 400, headers: {}, body: { error: "agent_context_invalid", message: "Send valid JSON up to 1 MiB with agent_context, or use agent_type and agent_search_query query parameters with a non-JSON body." } });
146
+ return false;
147
+ }
148
+ const invalid = call.contextError();
149
+ if (invalid) {
150
+ call.finish(invalid.status);
151
+ send(res, invalid);
152
+ return false;
153
+ }
154
+ }
135
155
  const originalJson = res.json.bind(res);
136
156
  res.json = ((body) => originalJson(call.json(res.statusCode, body)));
137
157
  if (call.feedbackId) {
@@ -173,6 +193,7 @@ export function createForge(options) {
173
193
  return originalWriteHead.call(this, statusCode, ...rest);
174
194
  };
175
195
  res.on("finish", () => call.finish(res.statusCode, Boolean(res.getHeader("payment-required"))));
196
+ return true;
176
197
  }
177
198
  return {
178
199
  enabled: core.enabled,
@@ -181,8 +202,18 @@ export function createForge(options) {
181
202
  diagnostics: core.diagnostics,
182
203
  shutdown: core.shutdown,
183
204
  middleware() {
184
- if (!core.enabled)
185
- return (_req, _res, next) => next();
205
+ if (!core.enabled) {
206
+ // Disabled by invalid options: only remove agent context, so strict validators never see it.
207
+ return (req, _res, next) => {
208
+ try {
209
+ takeAgentContext(req, core.call({ method: req.method, path: req.path, header: () => undefined }));
210
+ }
211
+ catch {
212
+ // never break the business request
213
+ }
214
+ next();
215
+ };
216
+ }
186
217
  return (req, res, next) => {
187
218
  core
188
219
  .route({
@@ -192,14 +223,14 @@ export function createForge(options) {
192
223
  query: (name) => req.query[name],
193
224
  json: () => readJsonBody(req),
194
225
  })
195
- .then((response) => {
226
+ .then(async (response) => {
196
227
  if (response)
197
228
  return send(res, response);
198
229
  try {
199
230
  if (core.isSpecRequest(req.method, req.path))
200
231
  captureSpec(req, res);
201
- else
202
- observe(req, res);
232
+ else if (!await observe(req, res))
233
+ return;
203
234
  }
204
235
  catch (error) {
205
236
  core.onError(error); // never break the business request
package/dist/fetch.d.ts CHANGED
@@ -5,9 +5,11 @@ export interface ForgeFetch extends Pick<ForgeCore, "enabled" | "challengeSenten
5
5
  /**
6
6
  * Run one request through Forge around `next` (your handler, including your x402 payment middleware).
7
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.
8
+ * Fails open by default. Required-context validation intentionally rejects invalid paid attempts before `next`.
9
9
  */
10
10
  handle(request: Request, next: Next): Promise<Response>;
11
+ /** Check required context without consuming or modifying the original request (e.g. before a payment proxy). */
12
+ validate(request: Request): Promise<Response | null>;
11
13
  /** Wrap a fetch handler, e.g. `export default { fetch: forge.wrap(app.fetch) }`. Extra arguments (env, ctx) pass through. */
12
14
  wrap<A extends unknown[]>(handler: (request: Request, ...rest: A) => Response | Promise<Response>): (request: Request, ...rest: A) => Promise<Response>;
13
15
  /** Forge's own routes only (/feedback, /feedback/rate, /feedback/summary, a static openapi.document); null for anything else. */