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

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,18 @@ 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
+ if (context.required === false)
71
+ warnings.push("agentContext.required: false is no longer supported; enabled context is required on paid requests. Set agentContext: false to disable context");
72
+ for (const key of ["required", "searchQuery"]) {
73
+ if (context[key] !== undefined && typeof context[key] !== "boolean") {
74
+ errors.push(`agentContext.${key} must be a boolean`);
75
+ }
76
+ }
77
+ }
78
+ for (const key of ["feedback", "describeChallenges", "challengeExtension", "receiptExtension", "injectBody", "injectText", "strict"])
64
79
  fallback(key, boolean(raw[key]), "must be true or false");
65
80
  // The lines no wording may cross, whatever the merchant configures (see ask.ts).
66
81
  for (const key of ["challengeSentence", "rateHint"]) {
@@ -95,6 +110,8 @@ export function checkOptions(input) {
95
110
  function disabledCore(errors, warnings) {
96
111
  const passThrough = {
97
112
  feedbackId: undefined,
113
+ contextRequired: false,
114
+ contextError: () => null,
98
115
  json: (_status, body) => body,
99
116
  text: (_status, _type, body) => body,
100
117
  headers: () => ({}),
@@ -142,26 +159,35 @@ export function createForgeCore(input) {
142
159
  }
143
160
  }
144
161
  function enabledCore(options, configWarnings) {
145
- const { apiKey, backendUrl, publicUrl } = options;
162
+ const { apiKey, publicUrl } = options;
163
+ const backendUrl = options.backendUrl ?? DEFAULT_BACKEND_URL;
146
164
  // Feedback IDs are signed with a key derived from the API key, never with the API key itself.
147
165
  const signingKey = deriveSigningKey(apiKey);
148
166
  const backendPath = new URL(backendUrl).pathname.replace(/\/+$/, "") || "/v1";
149
167
  const backend = (name) => new URL(`${backendPath}/${name}`, backendUrl).href;
150
168
  const basePath = (options.basePath ?? "/feedback").replace(/\/+$/, "");
151
169
  const ratePath = `${basePath}/rate`;
152
- const rateUrl = new URL(ratePath, publicUrl).href;
153
- const formUrl = new URL(basePath, publicUrl).href;
154
170
  const summaryPath = `${basePath}/summary`;
155
- const summaryUrl = new URL(summaryPath, publicUrl).href;
171
+ // Rating links are absolute whenever an origin is known: publicUrl, else the origin registered in Forge
172
+ // (fetched in the background, never on the request path), else the origin of the request's own x402 resource.
173
+ const explicitOrigin = publicUrl ? new URL(publicUrl).origin : undefined;
174
+ let registeredOrigin;
175
+ const linksFor = (requestOrigin) => {
176
+ const base = explicitOrigin ?? registeredOrigin ?? requestOrigin;
177
+ const at = (path) => (base ? new URL(path, base).href : path);
178
+ return { rate: at(ratePath), form: at(basePath), summary: at(summaryPath) };
179
+ };
156
180
  const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
157
181
  const fetchImpl = options.fetch ?? fetch;
158
- const describe = options.describeChallenges ?? true;
182
+ const feedback = options.feedback !== false;
183
+ const describe = feedback && (options.describeChallenges ?? true);
159
184
  const injectBody = options.injectBody ?? true;
160
185
  const injectText = options.injectText ?? false;
161
186
  const tone = options.tone ?? "soft";
162
187
  const contextOption = options.agentContext ?? true;
163
188
  const collectContext = contextOption !== false;
164
189
  const searchQuery = collectContext && (typeof contextOption !== "object" || contextOption.searchQuery !== false);
190
+ const requiredContext = collectContext;
165
191
  const receipts = options.receiptExtension ?? true;
166
192
  const rateHint = options.rateHint === false
167
193
  ? null
@@ -186,27 +212,48 @@ function enabledCore(options, configWarnings) {
186
212
  lastLogged = message;
187
213
  });
188
214
  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);
215
+ const sentenceTemplate = options.challengeSentence ?? ASK[tone].challengeSentence;
216
+ // Idempotency marker: the rate path, which every described challenge contains whatever origin its link uses.
217
+ const describedBy = (sentence, rate) => (sentence.includes(rate) ? ratePath : sentence);
218
+ const sentenceFor = (links) => feedback ? sentenceTemplate.replaceAll("{rate_url}", links.rate).replaceAll("{summary_url}", links.summary) : "";
219
+ const additionsCache = new Map();
220
+ function challengeAdditions(requestOrigin) {
221
+ const links = linksFor(requestOrigin);
222
+ const cached = additionsCache.get(links.rate);
223
+ if (cached)
224
+ return cached;
225
+ const sentence = sentenceFor(links);
226
+ const marker = describedBy(sentence, links.rate);
227
+ const additions = {
228
+ ...(describe ? { sentence, marker, ...(marker === ratePath ? { shortSentence: ASK[tone].shortChallengeSentence.replaceAll("{rate_url}", links.rate) } : {}) } : {}),
229
+ ...(!feedback || options.challengeExtension === false ? {} : { extension: feedbackExtension(links.rate, tone) }),
230
+ };
231
+ if (collectContext)
232
+ additions.contextExtension = { info: { agent_context: agentContextAsk({ searchQuery, required: requiredContext }), optional: !requiredContext } };
233
+ if (collectContext)
234
+ additions.bazaarContext = { searchQuery, required: requiredContext };
235
+ if (additionsCache.size >= 16)
236
+ additionsCache.clear(); // one entry per origin; bounded against spoofed Host headers
237
+ additionsCache.set(links.rate, additions);
238
+ return additions;
239
+ }
240
+ const touchChallenges = Boolean((describe && feedback) || (feedback && options.challengeExtension !== false) || collectContext);
197
241
  // Set once a document has been enriched; lets body injection respect strict response schemas.
198
242
  let allowInjection = null;
199
243
  let lastWarnings = "";
200
244
  function enrich(document) {
245
+ const links = linksFor();
246
+ const sentence = sentenceFor(links);
201
247
  const result = enrichOpenApi(document, {
202
- publicUrl,
248
+ publicUrl: explicitOrigin ?? registeredOrigin,
249
+ feedback,
203
250
  basePath,
204
- sentence: challengeSentence,
205
- marker: challengeMarker,
251
+ sentence,
252
+ marker: describedBy(sentence, links.rate),
206
253
  isPaidOperation: openapi?.isPaidOperation,
207
254
  describeOperations: openapi?.describeOperations,
208
255
  hintField: Boolean(rateHint),
209
- agentContext: collectContext ? { searchQuery } : undefined,
256
+ agentContext: collectContext ? { searchQuery, required: requiredContext } : undefined,
210
257
  });
211
258
  stats.openapi = result.report;
212
259
  if (result.report.enriched)
@@ -221,30 +268,65 @@ function enabledCore(options, configWarnings) {
221
268
  // With interception or an async provider, that starts once the spec has been served.
222
269
  if (openapi && openapi.document !== undefined && typeof openapi.document !== "function")
223
270
  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
- },
271
+ const formDoc = () => {
272
+ const links = linksFor();
273
+ return {
274
+ protocol: PROTOCOL,
275
+ cost: "free",
276
+ ratings_are_public_at: links.summary,
277
+ feedback_id: `Returned by paid responses in the ${FEEDBACK_FIELD} body object (feedback_id) and the Forge-Feedback-Id header.`,
278
+ quick: { method: "GET", url: `${links.rate}?feedback_id=FEEDBACK_ID&outcome=OUTCOME`, optional_param: "issue=ISSUE" },
279
+ detailed: {
280
+ method: "POST",
281
+ url: links.form,
282
+ content_type: "application/json",
283
+ body: { feedback_id: "FEEDBACK_ID", outcome: "OUTCOME", issue: "ISSUE (optional)", note: `NOTE (optional, max ${NOTE_MAX_LENGTH} chars, no user data)` },
284
+ },
285
+ fields: {
286
+ outcome: { required: true, values: OUTCOMES },
287
+ issue: { required: false, values: ISSUES },
288
+ note: { required: false, max_length: NOTE_MAX_LENGTH, post_only: true },
289
+ },
290
+ };
241
291
  };
292
+ // The origin registered in Forge, fetched in the background on first use and refreshed every few hours.
293
+ // Requests never wait on it; until it arrives, links fall back to the request's own resource origin.
294
+ let originCheckedAt = -Infinity;
295
+ const ORIGIN_REFRESH_MS = 6 * 3600_000;
296
+ function refreshRegisteredOrigin() {
297
+ if (explicitOrigin || Date.now() - originCheckedAt < ORIGIN_REFRESH_MS)
298
+ return;
299
+ originCheckedAt = Date.now();
300
+ void (async () => {
301
+ try {
302
+ const response = await fetchImpl(backend("project"), {
303
+ headers: { Authorization: `Bearer ${apiKey}` },
304
+ signal: AbortSignal.timeout(5000),
305
+ });
306
+ if (!response.ok)
307
+ return;
308
+ const origin = (await response.json())?.publicOrigin;
309
+ if (typeof origin !== "string" || !origin.startsWith("https://"))
310
+ return;
311
+ const next = new URL(origin).origin;
312
+ if (next === registeredOrigin)
313
+ return;
314
+ registeredOrigin = next;
315
+ additionsCache.clear();
316
+ if (openapi && openapi.document !== undefined && typeof openapi.document !== "function")
317
+ enrich(openapi.document);
318
+ }
319
+ catch {
320
+ // Offline, older backend, or blocked: keep the fallbacks.
321
+ }
322
+ })();
323
+ }
242
324
  const reply = (status, body, headers = FEEDBACK_HEADERS) => body === undefined ? { status, headers } : { status, headers, body };
243
325
  const badRequest = (error) => reply(400, {
244
326
  recorded: false,
245
327
  error,
246
328
  allowed: { outcome: Object.keys(OUTCOMES), issue: Object.keys(ISSUES) },
247
- example: `${rateUrl}?feedback_id=FEEDBACK_ID&outcome=fully`,
329
+ example: `${linksFor().rate}?feedback_id=FEEDBACK_ID&outcome=fully`,
248
330
  });
249
331
  async function submit(request, input, via) {
250
332
  const parsed = parseSubmission(input, { allowNote: via === "POST" });
@@ -258,7 +340,7 @@ function enabledCore(options, configWarnings) {
258
340
  const upstream = await fetchImpl(backend("feedback"), {
259
341
  method: "POST",
260
342
  headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
261
- body: JSON.stringify({ ...parsed.value, via, user_agent: request.header("user-agent") ?? null }),
343
+ body: JSON.stringify({ ...parsed.value, via, user_agent: request.header("user-agent") ?? null, request_headers: captureClientHeaders((name) => request.header(name)) }),
262
344
  signal: AbortSignal.timeout(3000),
263
345
  });
264
346
  const body = await upstream.json().catch(() => ({ recorded: false, error: "feedback_unavailable" }));
@@ -288,6 +370,7 @@ function enabledCore(options, configWarnings) {
288
370
  return reply(summaryCache.status, summaryCache.body, { "Cache-Control": "public, max-age=30" });
289
371
  }
290
372
  async function route(request) {
373
+ refreshRegisteredOrigin();
291
374
  try {
292
375
  return await handleRoute(request);
293
376
  }
@@ -299,9 +382,9 @@ function enabledCore(options, configWarnings) {
299
382
  }
300
383
  async function handleRoute(request) {
301
384
  const { method, path } = request;
302
- if (path === basePath) {
385
+ if (feedback && path === basePath) {
303
386
  if (method === "GET" || method === "HEAD")
304
- return reply(200, form);
387
+ return reply(200, formDoc());
305
388
  if (method === "POST") {
306
389
  let body;
307
390
  try {
@@ -315,23 +398,29 @@ function enabledCore(options, configWarnings) {
315
398
  return submit(request, body, "POST");
316
399
  }
317
400
  }
318
- if (path === summaryPath && (method === "GET" || method === "HEAD"))
401
+ if (feedback && path === summaryPath && (method === "GET" || method === "HEAD"))
319
402
  return summary();
320
- if (path === ratePath) {
403
+ if (feedback && path === ratePath) {
321
404
  // HEAD and other methods must never record a rating (link checkers send HEAD).
322
405
  if (method !== "GET")
323
406
  return reply(405, undefined, { ...FEEDBACK_HEADERS, Allow: "GET" });
324
407
  const first = (v) => (Array.isArray(v) ? v[0] : v);
325
408
  return submit(request, { feedback_id: first(request.query("feedback_id")), outcome: first(request.query("outcome")), issue: first(request.query("issue")) }, "GET");
326
409
  }
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" });
410
+ if (openapi && method === "GET" && specPaths.has(path)) {
411
+ const requestHeaders = captureClientHeaders((name) => request.header(name));
412
+ reporter.push({ type: "discovery", route: `${method} ${path}`, ts: Date.now(), user_agent: requestHeaders?.["user-agent"], request_headers: requestHeaders });
413
+ if (openapi.document !== undefined) {
414
+ const source = typeof openapi.document === "function" ? await openapi.document() : openapi.document;
415
+ return reply(200, enrich(source).document, { "Cache-Control": "no-store" });
416
+ }
330
417
  }
331
418
  return null;
332
419
  }
333
420
  const passThrough = {
334
421
  feedbackId: undefined,
422
+ contextRequired: false,
423
+ contextError: () => null,
335
424
  json: (_status, body) => body,
336
425
  text: (_status, _type, body) => body,
337
426
  headers: () => ({}),
@@ -352,18 +441,26 @@ function enabledCore(options, configWarnings) {
352
441
  const started = Date.now();
353
442
  const route = `${request.method} ${request.path}`;
354
443
  const userAgent = request.header("user-agent") ?? undefined;
444
+ const requestHeaders = captureClientHeaders((name) => request.header(name));
355
445
  const paymentHeader = request.header("payment-signature") ?? request.header("x-payment"); // x402 v2 / v1
356
- const feedbackId = paymentHeader ? mintFeedbackId(signingKey) : undefined;
446
+ const feedbackId = feedback && paymentHeader ? mintFeedbackId(signingKey) : undefined;
447
+ const interactionId = paymentHeader && !feedback ? randomUUID() : undefined;
357
448
  if (feedbackId)
358
449
  stats.minted++;
359
- const feedbackUrl = feedbackId ? `${rateUrl}?feedback_id=${feedbackId}&outcome=` : "";
450
+ refreshRegisteredOrigin();
451
+ // Links in this paid response use the origin the payment was made for when none is configured or registered.
452
+ const callLinks = feedbackId ? linksFor(paymentOrigin(paymentHeader)) : undefined;
453
+ const feedbackUrl = callLinks ? `${callLinks.rate}?feedback_id=${feedbackId}&outcome=` : "";
360
454
  let bodyChallenge = false;
361
455
  let headerChallenge = false;
362
456
  let context;
457
+ let rawContext = {};
363
458
  let receiptFacts = {};
364
459
  const remember = (raw) => {
365
460
  if (!collectContext)
366
461
  return;
462
+ if (raw && typeof raw === "object" && !Array.isArray(raw))
463
+ rawContext = { ...rawContext, ...raw };
367
464
  const parsed = parseAgentContext(raw, { searchQuery });
368
465
  if (parsed)
369
466
  context = { ...context, ...parsed };
@@ -371,12 +468,24 @@ function enabledCore(options, configWarnings) {
371
468
  const ok = (status) => status >= 200 && status < 300;
372
469
  const handle = {
373
470
  feedbackId,
471
+ contextRequired: requiredContext && !!paymentHeader,
472
+ contextError() {
473
+ if (!handle.contextRequired)
474
+ return null;
475
+ const issues = contextIssues(rawContext, { searchQuery });
476
+ return issues.length ? {
477
+ status: 400, headers: {}, body: {
478
+ error: "agent_context_required", issues,
479
+ message: agentContextAsk({ searchQuery, required: true }),
480
+ },
481
+ } : null;
482
+ },
374
483
  json(status, body) {
375
484
  try {
376
485
  if (status === 402) {
377
486
  bodyChallenge = !!body && typeof body === "object" && "x402Version" in body;
378
487
  // v1 challenges (and v2 challenges echoed in the body) live in the JSON body.
379
- const described = touchChallenges ? describeChallengeBody(body, challengeAdditions) : undefined;
488
+ const described = touchChallenges ? describeChallengeBody(body, challengeAdditions(challengeOrigin(body))) : undefined;
380
489
  if (described) {
381
490
  stats.challengesDescribed++;
382
491
  return described;
@@ -388,15 +497,16 @@ function enabledCore(options, configWarnings) {
388
497
  body !== null &&
389
498
  typeof body === "object" &&
390
499
  Object.getPrototypeOf(body) === Object.prototype &&
391
- !("feedback_id" in body) &&
392
- !("feedback_url" in body) &&
393
- !(rateHint && "rate_this_call" in body) &&
500
+ !(FEEDBACK_FIELD in body) &&
394
501
  (allowInjection?.(request.method, request.path, status) ?? true)) {
502
+ // One namespaced object, so the merchant's own fields stay recognizably theirs.
395
503
  return {
396
504
  ...body,
397
- feedback_id: feedbackId,
398
- feedback_url: feedbackUrl,
399
- ...(rateHint ? { rate_this_call: rateHint.replaceAll("{feedback_url}", feedbackUrl).replaceAll("{summary_url}", summaryUrl) } : {}),
505
+ [FEEDBACK_FIELD]: {
506
+ feedback_id: feedbackId,
507
+ feedback_url: feedbackUrl,
508
+ ...(rateHint ? { rate_this_call: rateHint.replaceAll("{feedback_url}", feedbackUrl).replaceAll("{summary_url}", callLinks.summary) } : {}),
509
+ },
400
510
  };
401
511
  }
402
512
  }
@@ -418,17 +528,17 @@ function enabledCore(options, configWarnings) {
418
528
  if (status === 402 && paymentRequired)
419
529
  headerChallenge = true;
420
530
  if (status === 402 && touchChallenges && paymentRequired) {
421
- const next = describeChallenge(paymentRequired, challengeAdditions);
531
+ const next = describeChallenge(paymentRequired, challengeAdditions(challengeOrigin(paymentRequired)));
422
532
  if (next) {
423
533
  set["PAYMENT-REQUIRED"] = next;
424
534
  stats.challengesDescribed++;
425
535
  }
426
536
  }
537
+ if (paymentHeader && ok(status) && paymentResponse)
538
+ receiptFacts = readReceipt(paymentResponse);
427
539
  if (feedbackId && ok(status)) {
428
540
  set["Forge-Feedback-Id"] = feedbackId;
429
- if (paymentResponse)
430
- receiptFacts = readReceipt(paymentResponse);
431
- const receipt = receipts && paymentResponse ? describeReceipt(paymentResponse, receiptExtension(rateUrl, feedbackId, tone)) : undefined;
541
+ const receipt = receipts && paymentResponse ? describeReceipt(paymentResponse, receiptExtension(callLinks.rate, feedbackId, tone)) : undefined;
432
542
  if (receipt)
433
543
  set["PAYMENT-RESPONSE"] = receipt;
434
544
  }
@@ -475,19 +585,20 @@ function enabledCore(options, configWarnings) {
475
585
  const ts = Date.now();
476
586
  if (status === 402 && (hadChallengeHeader || headerChallenge || bodyChallenge)) {
477
587
  // 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 } : {}) });
588
+ reporter.push({ type: "challenge", route, ts, user_agent: userAgent, request_headers: requestHeaders, ...(context ? { agent_context: context } : {}) });
479
589
  }
480
- if (feedbackId) {
590
+ if (feedbackId || interactionId) {
481
591
  const facts = { ...readPaymentHeader(paymentHeader), ...receiptFacts };
482
592
  reporter.push({
483
593
  type: "interaction",
484
- feedback_id: feedbackId,
594
+ ...(feedbackId ? { feedback_id: feedbackId } : { interaction_id: interactionId }),
485
595
  route,
486
596
  status,
487
597
  latency_ms: ts - started,
488
598
  // Only report the payer once the call succeeded (i.e. settlement went through).
489
599
  ...(ok(status) ? facts : { network: facts.network, amount: facts.amount }),
490
600
  user_agent: userAgent,
601
+ request_headers: requestHeaders,
491
602
  ...(context ? { agent_context: context } : {}),
492
603
  ts,
493
604
  });
@@ -497,7 +608,9 @@ function enabledCore(options, configWarnings) {
497
608
  }
498
609
  return {
499
610
  enabled: true,
500
- challengeSentence,
611
+ get challengeSentence() {
612
+ return sentenceFor(linksFor());
613
+ },
501
614
  route,
502
615
  isSpecRequest: (method, path) => Boolean(openapi) && method === "GET" && specPaths.has(path),
503
616
  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
+ // Enabled 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,
@@ -192,14 +213,14 @@ export function createForge(options) {
192
213
  query: (name) => req.query[name],
193
214
  json: () => readJsonBody(req),
194
215
  })
195
- .then((response) => {
216
+ .then(async (response) => {
196
217
  if (response)
197
218
  return send(res, response);
198
219
  try {
199
220
  if (core.isSpecRequest(req.method, req.path))
200
221
  captureSpec(req, res);
201
- else
202
- observe(req, res);
222
+ else if (!await observe(req, res))
223
+ return;
203
224
  }
204
225
  catch (error) {
205
226
  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. */