@forgeintel/sdk 0.5.0-beta.1 → 0.5.0-beta.11

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
@@ -4,16 +4,17 @@ import { randomUUID } from "node:crypto";
4
4
  import { DEFAULT_TTL_MS, deriveSigningKey, mintFeedbackId, verifyFeedbackId } from "./id.js";
5
5
  import { createOperationIndex, enrichOpenApi } from "./openapi.js";
6
6
  import { ASK, TONES, checkAskText } from "./ask.js";
7
- import { agentContextAsk, parseAgentContext, takeFromBody, takeFromUrl } from "./context.js";
7
+ import { agentContextAsk, contextIssues, parseAgentContext, takeFromBody, takeFromUrl } from "./context.js";
8
8
  import { EventReporter } from "./reporter.js";
9
9
  import { captureClientHeaders } from "./client-signals.js";
10
10
  import { ISSUES, NOTE_MAX_LENGTH, OUTCOMES, PROTOCOL, parseSubmission } from "./values.js";
11
- 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";
12
12
  export const FEEDBACK_HEADERS = { "Cache-Control": "no-store", "X-Robots-Tag": "noindex" };
13
13
  /** Max JSON body for POST {basePath}. */
14
14
  export const BODY_LIMIT = 8 * 1024;
15
15
  /** Max OpenAPI document an adapter should buffer for enrichment. */
16
16
  export const SPEC_LIMIT = 10 * 1024 * 1024;
17
+ export const DEFAULT_BACKEND_URL = "https://app-api.forgeintel.co/api/sdk/v2";
17
18
  /**
18
19
  * Check options without throwing. Invalid required options are errors (Forge runs disabled);
19
20
  * invalid optional values are warnings and fall back to their defaults.
@@ -36,10 +37,12 @@ export function checkOptions(input) {
36
37
  };
37
38
  if (typeof raw.apiKey !== "string" || !raw.apiKey.trim())
38
39
  errors.push("apiKey is missing (set it to your merchant key, ffk_…)");
39
- if (!absoluteUrl(raw.backendUrl))
40
+ if (raw.backendUrl === undefined)
41
+ options.backendUrl = DEFAULT_BACKEND_URL;
42
+ else if (!absoluteUrl(raw.backendUrl))
40
43
  errors.push(`backendUrl must be an absolute http(s) URL (got ${JSON.stringify(raw.backendUrl ?? null)})`);
41
- if (!absoluteUrl(raw.publicUrl))
42
- 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)})`);
43
46
  const fallback = (key, ok, expected) => {
44
47
  if (raw[key] === undefined || ok)
45
48
  return;
@@ -61,7 +64,15 @@ export function checkOptions(input) {
61
64
  fallback("tone", TONES.includes(raw.tone), `must be one of ${TONES.map((t) => `"${t}"`).join(", ")}`);
62
65
  fallback("challengeSentence", typeof raw.challengeSentence === "string" && raw.challengeSentence.trim() !== "", "must be a non-empty string");
63
66
  fallback("rateHint", boolean(raw.rateHint) || typeof raw.rateHint === "string", "must be a boolean or a string");
64
- fallback("agentContext", boolean(raw.agentContext) || (!!raw.agentContext && typeof raw.agentContext === "object" && !Array.isArray(raw.agentContext)), "must be true, false or { searchQuery }");
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
+ }
65
76
  for (const key of ["feedback", "describeChallenges", "challengeExtension", "receiptExtension", "injectBody", "injectText", "strict"])
66
77
  fallback(key, boolean(raw[key]), "must be true or false");
67
78
  // The lines no wording may cross, whatever the merchant configures (see ask.ts).
@@ -97,6 +108,8 @@ export function checkOptions(input) {
97
108
  function disabledCore(errors, warnings) {
98
109
  const passThrough = {
99
110
  feedbackId: undefined,
111
+ contextRequired: false,
112
+ contextError: () => null,
100
113
  json: (_status, body) => body,
101
114
  text: (_status, _type, body) => body,
102
115
  headers: () => ({}),
@@ -144,17 +157,24 @@ export function createForgeCore(input) {
144
157
  }
145
158
  }
146
159
  function enabledCore(options, configWarnings) {
147
- const { apiKey, backendUrl, publicUrl } = options;
160
+ const { apiKey, publicUrl } = options;
161
+ const backendUrl = options.backendUrl ?? DEFAULT_BACKEND_URL;
148
162
  // Feedback IDs are signed with a key derived from the API key, never with the API key itself.
149
163
  const signingKey = deriveSigningKey(apiKey);
150
164
  const backendPath = new URL(backendUrl).pathname.replace(/\/+$/, "") || "/v1";
151
165
  const backend = (name) => new URL(`${backendPath}/${name}`, backendUrl).href;
152
166
  const basePath = (options.basePath ?? "/feedback").replace(/\/+$/, "");
153
167
  const ratePath = `${basePath}/rate`;
154
- const rateUrl = new URL(ratePath, publicUrl).href;
155
- const formUrl = new URL(basePath, publicUrl).href;
156
168
  const summaryPath = `${basePath}/summary`;
157
- const summaryUrl = new URL(summaryPath, publicUrl).href;
169
+ // Rating links are absolute whenever an origin is known: publicUrl, else the origin registered in Forge
170
+ // (fetched in the background, never on the request path), else the origin of the request's own x402 resource.
171
+ const explicitOrigin = publicUrl ? new URL(publicUrl).origin : undefined;
172
+ let registeredOrigin;
173
+ const linksFor = (requestOrigin) => {
174
+ const base = explicitOrigin ?? registeredOrigin ?? requestOrigin;
175
+ const at = (path) => (base ? new URL(path, base).href : path);
176
+ return { rate: at(ratePath), form: at(basePath), summary: at(summaryPath) };
177
+ };
158
178
  const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
159
179
  const fetchImpl = options.fetch ?? fetch;
160
180
  const feedback = options.feedback !== false;
@@ -162,9 +182,11 @@ function enabledCore(options, configWarnings) {
162
182
  const injectBody = options.injectBody ?? true;
163
183
  const injectText = options.injectText ?? false;
164
184
  const tone = options.tone ?? "soft";
165
- const contextOption = options.agentContext ?? true;
185
+ const contextOption = options.agentContext;
166
186
  const collectContext = contextOption !== false;
167
187
  const searchQuery = collectContext && (typeof contextOption !== "object" || contextOption.searchQuery !== false);
188
+ // Optional unless configured: true, or an object without required: false, keeps the strict (400) behavior.
189
+ const requiredContext = contextOption === true || (typeof contextOption === "object" && contextOption.required !== false);
168
190
  const receipts = options.receiptExtension ?? true;
169
191
  const rateHint = options.rateHint === false
170
192
  ? null
@@ -189,30 +211,48 @@ function enabledCore(options, configWarnings) {
189
211
  lastLogged = message;
190
212
  });
191
213
  const reporter = new EventReporter(backend("events"), apiKey, fetchImpl, onError, options.flushIntervalMs ?? 2000);
192
- const challengeSentence = feedback ? (options.challengeSentence ?? ASK[tone].challengeSentence).replaceAll("{rate_url}", rateUrl).replaceAll("{summary_url}", summaryUrl) : "";
193
- // Idempotency marker: the rate URL when the sentence contains it, otherwise the sentence itself.
194
- const challengeMarker = challengeSentence.includes(rateUrl) ? rateUrl : challengeSentence;
195
- const challengeAdditions = {
196
- ...(describe ? { sentence: challengeSentence, marker: challengeMarker } : {}),
197
- ...(!feedback || options.challengeExtension === false ? {} : { extension: feedbackExtension(rateUrl, tone, collectContext ? agentContextAsk({ searchQuery }) : undefined) }),
198
- };
199
- if (!feedback && collectContext)
200
- challengeAdditions.contextExtension = { info: { agent_context: agentContextAsk({ searchQuery }), optional: true } };
201
- const touchChallenges = Boolean(challengeAdditions.sentence || challengeAdditions.extension || challengeAdditions.contextExtension);
214
+ const sentenceTemplate = options.challengeSentence ?? ASK[tone].challengeSentence;
215
+ // Idempotency marker: the rate path, which every described challenge contains whatever origin its link uses.
216
+ const describedBy = (sentence, rate) => (sentence.includes(rate) ? ratePath : sentence);
217
+ const sentenceFor = (links) => feedback ? sentenceTemplate.replaceAll("{rate_url}", links.rate).replaceAll("{summary_url}", links.summary) : "";
218
+ const additionsCache = new Map();
219
+ function challengeAdditions(requestOrigin) {
220
+ const links = linksFor(requestOrigin);
221
+ const cached = additionsCache.get(links.rate);
222
+ if (cached)
223
+ return cached;
224
+ const sentence = sentenceFor(links);
225
+ const marker = describedBy(sentence, links.rate);
226
+ const additions = {
227
+ ...(describe ? { sentence, marker, ...(marker === ratePath ? { shortSentence: ASK[tone].shortChallengeSentence.replaceAll("{rate_url}", links.rate) } : {}) } : {}),
228
+ ...(!feedback || options.challengeExtension === false ? {} : { extension: feedbackExtension(links.rate, tone) }),
229
+ };
230
+ if (collectContext)
231
+ additions.contextExtension = { info: { agent_context: agentContextAsk({ searchQuery, required: requiredContext }), optional: !requiredContext } };
232
+ if (collectContext)
233
+ additions.bazaarContext = { searchQuery, required: requiredContext };
234
+ if (additionsCache.size >= 16)
235
+ additionsCache.clear(); // one entry per origin; bounded against spoofed Host headers
236
+ additionsCache.set(links.rate, additions);
237
+ return additions;
238
+ }
239
+ const touchChallenges = Boolean((describe && feedback) || (feedback && options.challengeExtension !== false) || collectContext);
202
240
  // Set once a document has been enriched; lets body injection respect strict response schemas.
203
241
  let allowInjection = null;
204
242
  let lastWarnings = "";
205
243
  function enrich(document) {
244
+ const links = linksFor();
245
+ const sentence = sentenceFor(links);
206
246
  const result = enrichOpenApi(document, {
207
- publicUrl,
247
+ publicUrl: explicitOrigin ?? registeredOrigin,
208
248
  feedback,
209
249
  basePath,
210
- sentence: challengeSentence,
211
- marker: challengeMarker,
250
+ sentence,
251
+ marker: describedBy(sentence, links.rate),
212
252
  isPaidOperation: openapi?.isPaidOperation,
213
253
  describeOperations: openapi?.describeOperations,
214
254
  hintField: Boolean(rateHint),
215
- agentContext: collectContext ? { searchQuery } : undefined,
255
+ agentContext: collectContext ? { searchQuery, required: requiredContext } : undefined,
216
256
  });
217
257
  stats.openapi = result.report;
218
258
  if (result.report.enriched)
@@ -227,30 +267,65 @@ function enabledCore(options, configWarnings) {
227
267
  // With interception or an async provider, that starts once the spec has been served.
228
268
  if (openapi && openapi.document !== undefined && typeof openapi.document !== "function")
229
269
  enrich(openapi.document);
230
- const form = {
231
- protocol: PROTOCOL,
232
- cost: "free",
233
- ratings_are_public_at: summaryUrl,
234
- feedback_id: "Returned by paid responses as the feedback_id body field and the Forge-Feedback-Id header.",
235
- quick: { method: "GET", url: `${rateUrl}?feedback_id=FEEDBACK_ID&outcome=OUTCOME`, optional_param: "issue=ISSUE" },
236
- detailed: {
237
- method: "POST",
238
- url: formUrl,
239
- content_type: "application/json",
240
- body: { feedback_id: "FEEDBACK_ID", outcome: "OUTCOME", issue: "ISSUE (optional)", note: `NOTE (optional, max ${NOTE_MAX_LENGTH} chars, no user data)` },
241
- },
242
- fields: {
243
- outcome: { required: true, values: OUTCOMES },
244
- issue: { required: false, values: ISSUES },
245
- note: { required: false, max_length: NOTE_MAX_LENGTH, post_only: true },
246
- },
270
+ const formDoc = () => {
271
+ const links = linksFor();
272
+ return {
273
+ protocol: PROTOCOL,
274
+ cost: "free",
275
+ ratings_are_public_at: links.summary,
276
+ feedback_id: `Returned by paid responses in the ${FEEDBACK_FIELD} body object (feedback_id) and the Forge-Feedback-Id header.`,
277
+ quick: { method: "GET", url: `${links.rate}?feedback_id=FEEDBACK_ID&outcome=OUTCOME`, optional_param: "issue=ISSUE" },
278
+ detailed: {
279
+ method: "POST",
280
+ url: links.form,
281
+ content_type: "application/json",
282
+ body: { feedback_id: "FEEDBACK_ID", outcome: "OUTCOME", issue: "ISSUE (optional)", note: `NOTE (optional, max ${NOTE_MAX_LENGTH} chars, no user data)` },
283
+ },
284
+ fields: {
285
+ outcome: { required: true, values: OUTCOMES },
286
+ issue: { required: false, values: ISSUES },
287
+ note: { required: false, max_length: NOTE_MAX_LENGTH, post_only: true },
288
+ },
289
+ };
247
290
  };
291
+ // The origin registered in Forge, fetched in the background on first use and refreshed every few hours.
292
+ // Requests never wait on it; until it arrives, links fall back to the request's own resource origin.
293
+ let originCheckedAt = -Infinity;
294
+ const ORIGIN_REFRESH_MS = 6 * 3600_000;
295
+ function refreshRegisteredOrigin() {
296
+ if (explicitOrigin || Date.now() - originCheckedAt < ORIGIN_REFRESH_MS)
297
+ return;
298
+ originCheckedAt = Date.now();
299
+ void (async () => {
300
+ try {
301
+ const response = await fetchImpl(backend("project"), {
302
+ headers: { Authorization: `Bearer ${apiKey}` },
303
+ signal: AbortSignal.timeout(5000),
304
+ });
305
+ if (!response.ok)
306
+ return;
307
+ const origin = (await response.json())?.publicOrigin;
308
+ if (typeof origin !== "string" || !origin.startsWith("https://"))
309
+ return;
310
+ const next = new URL(origin).origin;
311
+ if (next === registeredOrigin)
312
+ return;
313
+ registeredOrigin = next;
314
+ additionsCache.clear();
315
+ if (openapi && openapi.document !== undefined && typeof openapi.document !== "function")
316
+ enrich(openapi.document);
317
+ }
318
+ catch {
319
+ // Offline, older backend, or blocked: keep the fallbacks.
320
+ }
321
+ })();
322
+ }
248
323
  const reply = (status, body, headers = FEEDBACK_HEADERS) => body === undefined ? { status, headers } : { status, headers, body };
249
324
  const badRequest = (error) => reply(400, {
250
325
  recorded: false,
251
326
  error,
252
327
  allowed: { outcome: Object.keys(OUTCOMES), issue: Object.keys(ISSUES) },
253
- example: `${rateUrl}?feedback_id=FEEDBACK_ID&outcome=fully`,
328
+ example: `${linksFor().rate}?feedback_id=FEEDBACK_ID&outcome=fully`,
254
329
  });
255
330
  async function submit(request, input, via) {
256
331
  const parsed = parseSubmission(input, { allowNote: via === "POST" });
@@ -294,6 +369,7 @@ function enabledCore(options, configWarnings) {
294
369
  return reply(summaryCache.status, summaryCache.body, { "Cache-Control": "public, max-age=30" });
295
370
  }
296
371
  async function route(request) {
372
+ refreshRegisteredOrigin();
297
373
  try {
298
374
  return await handleRoute(request);
299
375
  }
@@ -307,7 +383,7 @@ function enabledCore(options, configWarnings) {
307
383
  const { method, path } = request;
308
384
  if (feedback && path === basePath) {
309
385
  if (method === "GET" || method === "HEAD")
310
- return reply(200, form);
386
+ return reply(200, formDoc());
311
387
  if (method === "POST") {
312
388
  let body;
313
389
  try {
@@ -342,6 +418,8 @@ function enabledCore(options, configWarnings) {
342
418
  }
343
419
  const passThrough = {
344
420
  feedbackId: undefined,
421
+ contextRequired: false,
422
+ contextError: () => null,
345
423
  json: (_status, body) => body,
346
424
  text: (_status, _type, body) => body,
347
425
  headers: () => ({}),
@@ -368,14 +446,20 @@ function enabledCore(options, configWarnings) {
368
446
  const interactionId = paymentHeader && !feedback ? randomUUID() : undefined;
369
447
  if (feedbackId)
370
448
  stats.minted++;
371
- const feedbackUrl = feedbackId ? `${rateUrl}?feedback_id=${feedbackId}&outcome=` : "";
449
+ refreshRegisteredOrigin();
450
+ // Links in this paid response use the origin the payment was made for when none is configured or registered.
451
+ const callLinks = feedbackId ? linksFor(paymentOrigin(paymentHeader)) : undefined;
452
+ const feedbackUrl = callLinks ? `${callLinks.rate}?feedback_id=${feedbackId}&outcome=` : "";
372
453
  let bodyChallenge = false;
373
454
  let headerChallenge = false;
374
455
  let context;
456
+ let rawContext = {};
375
457
  let receiptFacts = {};
376
458
  const remember = (raw) => {
377
459
  if (!collectContext)
378
460
  return;
461
+ if (raw && typeof raw === "object" && !Array.isArray(raw))
462
+ rawContext = { ...rawContext, ...raw };
379
463
  const parsed = parseAgentContext(raw, { searchQuery });
380
464
  if (parsed)
381
465
  context = { ...context, ...parsed };
@@ -383,12 +467,24 @@ function enabledCore(options, configWarnings) {
383
467
  const ok = (status) => status >= 200 && status < 300;
384
468
  const handle = {
385
469
  feedbackId,
470
+ contextRequired: requiredContext && !!paymentHeader,
471
+ contextError() {
472
+ if (!handle.contextRequired)
473
+ return null;
474
+ const issues = contextIssues(rawContext, { searchQuery });
475
+ return issues.length ? {
476
+ status: 400, headers: {}, body: {
477
+ error: "agent_context_required", issues,
478
+ message: agentContextAsk({ searchQuery, required: true }),
479
+ },
480
+ } : null;
481
+ },
386
482
  json(status, body) {
387
483
  try {
388
484
  if (status === 402) {
389
485
  bodyChallenge = !!body && typeof body === "object" && "x402Version" in body;
390
486
  // v1 challenges (and v2 challenges echoed in the body) live in the JSON body.
391
- const described = touchChallenges ? describeChallengeBody(body, challengeAdditions) : undefined;
487
+ const described = touchChallenges ? describeChallengeBody(body, challengeAdditions(challengeOrigin(body))) : undefined;
392
488
  if (described) {
393
489
  stats.challengesDescribed++;
394
490
  return described;
@@ -400,15 +496,16 @@ function enabledCore(options, configWarnings) {
400
496
  body !== null &&
401
497
  typeof body === "object" &&
402
498
  Object.getPrototypeOf(body) === Object.prototype &&
403
- !("feedback_id" in body) &&
404
- !("feedback_url" in body) &&
405
- !(rateHint && "rate_this_call" in body) &&
499
+ !(FEEDBACK_FIELD in body) &&
406
500
  (allowInjection?.(request.method, request.path, status) ?? true)) {
501
+ // One namespaced object, so the merchant's own fields stay recognizably theirs.
407
502
  return {
408
503
  ...body,
409
- feedback_id: feedbackId,
410
- feedback_url: feedbackUrl,
411
- ...(rateHint ? { rate_this_call: rateHint.replaceAll("{feedback_url}", feedbackUrl).replaceAll("{summary_url}", summaryUrl) } : {}),
504
+ [FEEDBACK_FIELD]: {
505
+ feedback_id: feedbackId,
506
+ feedback_url: feedbackUrl,
507
+ ...(rateHint ? { rate_this_call: rateHint.replaceAll("{feedback_url}", feedbackUrl).replaceAll("{summary_url}", callLinks.summary) } : {}),
508
+ },
412
509
  };
413
510
  }
414
511
  }
@@ -430,7 +527,7 @@ function enabledCore(options, configWarnings) {
430
527
  if (status === 402 && paymentRequired)
431
528
  headerChallenge = true;
432
529
  if (status === 402 && touchChallenges && paymentRequired) {
433
- const next = describeChallenge(paymentRequired, challengeAdditions);
530
+ const next = describeChallenge(paymentRequired, challengeAdditions(challengeOrigin(paymentRequired)));
434
531
  if (next) {
435
532
  set["PAYMENT-REQUIRED"] = next;
436
533
  stats.challengesDescribed++;
@@ -440,7 +537,7 @@ function enabledCore(options, configWarnings) {
440
537
  receiptFacts = readReceipt(paymentResponse);
441
538
  if (feedbackId && ok(status)) {
442
539
  set["Forge-Feedback-Id"] = feedbackId;
443
- const receipt = receipts && paymentResponse ? describeReceipt(paymentResponse, receiptExtension(rateUrl, feedbackId, tone)) : undefined;
540
+ const receipt = receipts && paymentResponse ? describeReceipt(paymentResponse, receiptExtension(callLinks.rate, feedbackId, tone)) : undefined;
444
541
  if (receipt)
445
542
  set["PAYMENT-RESPONSE"] = receipt;
446
543
  }
@@ -510,7 +607,9 @@ function enabledCore(options, configWarnings) {
510
607
  }
511
608
  return {
512
609
  enabled: true,
513
- challengeSentence,
610
+ get challengeSentence() {
611
+ return sentenceFor(linksFor());
612
+ },
514
613
  route,
515
614
  isSpecRequest: (method, path) => Boolean(openapi) && method === "GET" && specPaths.has(path),
516
615
  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,
@@ -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. */
package/dist/fetch.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Web-standard adapter: Request in, Response out. Works wherever handlers are (request) => Response:
2
2
  // Hono, Next.js route handlers, Cloudflare Workers, Bun, Deno. The Hono and Next adapters build on this.
3
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. */
4
+ /** Buffer limit; required context rejects oversized paid JSON before payment processing. */
5
5
  const JSON_LIMIT = 1024 * 1024;
6
6
  const isJson = (type) => /^application\/(?:[\w.+-]+\+)?json\b/i.test(type ?? "");
7
7
  export function toResponse(response) {
@@ -20,6 +20,40 @@ const smallEnough = (headers, limit) => {
20
20
  const length = Number(headers.get("content-length"));
21
21
  return Number.isFinite(length) && length > 0 && length <= limit;
22
22
  };
23
+ /** Bound reads even when a client sends chunked JSON or an inaccurate Content-Length. */
24
+ async function boundedJson(request) {
25
+ const reader = request.clone().body.getReader();
26
+ const chunks = [];
27
+ let size = 0;
28
+ try {
29
+ while (true) {
30
+ const { value, done } = await reader.read();
31
+ if (done)
32
+ break;
33
+ size += value.length;
34
+ if (size > JSON_LIMIT)
35
+ throw new Error("body_too_large");
36
+ chunks.push(value);
37
+ }
38
+ const data = new Uint8Array(size);
39
+ let offset = 0;
40
+ for (const chunk of chunks) {
41
+ data.set(chunk, offset);
42
+ offset += chunk.length;
43
+ }
44
+ return JSON.parse(new TextDecoder().decode(data));
45
+ }
46
+ finally {
47
+ // A clone is a tee: awaiting cancellation would wait for the untouched original stream.
48
+ void reader.cancel().catch(() => { });
49
+ }
50
+ }
51
+ function unreadableContext() {
52
+ return toResponse({ status: 400, headers: {}, body: {
53
+ error: "agent_context_invalid",
54
+ message: "Required agent context could not be read. Send valid JSON up to 1 MiB, or use agent_type and agent_search_query query parameters with a non-JSON body.",
55
+ } });
56
+ }
23
57
  export function createForge(options) {
24
58
  const core = createForgeCore(options);
25
59
  const forgeRequest = (request, url) => ({
@@ -35,6 +69,24 @@ export function createForge(options) {
35
69
  const own = await core.route(forgeRequest(request, new URL(request.url)));
36
70
  return own ? toResponse(own) : null;
37
71
  }
72
+ async function validate(request) {
73
+ if (!core.enabled || options.agentContext === false)
74
+ return null;
75
+ const url = new URL(request.url);
76
+ const call = core.call(forgeRequest(request, url));
77
+ if (!call.contextRequired)
78
+ return null;
79
+ try {
80
+ call.requestUrl(`${url.pathname}${url.search}`);
81
+ if (request.body && isJson(request.headers.get("content-type")))
82
+ call.requestBody(await boundedJson(request));
83
+ const error = call.contextError();
84
+ return error ? toResponse(error) : null;
85
+ }
86
+ catch {
87
+ return unreadableContext();
88
+ }
89
+ }
38
90
  /** The merchant's own OpenAPI route: fetch it without validators, enrich JSON 200s, pass anything else through. */
39
91
  async function spec(request, next) {
40
92
  const headers = new Headers(request.headers);
@@ -63,7 +115,7 @@ export function createForge(options) {
63
115
  async function handle(request, next) {
64
116
  if (!core.enabled)
65
117
  return next(request);
66
- // Before the handler: our own routes, the spec, and agent context. Any failure here: the original request goes on.
118
+ // Before the handler: our own routes, the spec, and agent context. Required-context errors stop paid attempts.
67
119
  let call;
68
120
  let forwarded = request;
69
121
  try {
@@ -77,15 +129,26 @@ export function createForge(options) {
77
129
  const stripped = call.requestUrl(`${url.pathname}${url.search}`);
78
130
  const nextUrl = stripped === `${url.pathname}${url.search}` ? request.url : new URL(stripped, url).href;
79
131
  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);
132
+ if (call.contextRequired && request.body && isJson(request.headers.get("content-type"))) {
133
+ const parsed = await boundedJson(request);
134
+ const without = call.requestBody(parsed);
135
+ if (without !== parsed)
136
+ body = JSON.stringify(without);
137
+ }
138
+ else if (request.body && isJson(request.headers.get("content-type")) && (smallEnough(request.headers, JSON_LIMIT) || !request.headers.has("content-length"))) {
139
+ // Optional context, best effort: a body that is too large or not JSON goes to the handler untouched.
140
+ const parsed = await boundedJson(request).catch(() => undefined);
141
+ if (parsed !== undefined) {
84
142
  const without = call.requestBody(parsed);
85
143
  if (without !== parsed)
86
144
  body = JSON.stringify(without);
87
145
  }
88
146
  }
147
+ const contextError = call.contextError();
148
+ if (contextError) {
149
+ call.finish(contextError.status);
150
+ return toResponse(contextError);
151
+ }
89
152
  if (nextUrl !== request.url || body !== undefined) {
90
153
  const headers = new Headers(request.headers);
91
154
  if (body !== undefined)
@@ -99,6 +162,10 @@ export function createForge(options) {
99
162
  }
100
163
  catch (error) {
101
164
  core.onError(error);
165
+ if (call?.contextRequired) {
166
+ call.finish(400);
167
+ return unreadableContext();
168
+ }
102
169
  return next(request);
103
170
  }
104
171
  const response = await next(forwarded);
@@ -165,6 +232,7 @@ export function createForge(options) {
165
232
  diagnostics: core.diagnostics,
166
233
  shutdown: core.shutdown,
167
234
  handle,
235
+ validate,
168
236
  route,
169
237
  wrap: (handler) => (request, ...rest) => handle(request, (r) => handler(r, ...rest)),
170
238
  };
package/dist/hono.js CHANGED
@@ -8,6 +8,7 @@ export function createForge(options) {
8
8
  enrichOpenApi: forge.enrichOpenApi,
9
9
  diagnostics: forge.diagnostics,
10
10
  shutdown: forge.shutdown,
11
+ validate: forge.validate,
11
12
  middleware() {
12
13
  if (!forge.enabled)
13
14
  return async (_c, next) => next();
package/dist/index.d.ts CHANGED
@@ -8,7 +8,7 @@ export { deriveSigningKey, mintFeedbackId, verifyFeedbackId, FEEDBACK_ID_PATTERN
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, describeReceipt, feedbackExtension, receiptExtension, FEEDBACK_EXTENSION, readPaymentHeader, readReceipt } from "./x402.js";
11
+ export { describeChallenge, describeChallengeBody, describeReceipt, feedbackExtension, receiptExtension, FEEDBACK_EXTENSION, FEEDBACK_FIELD, readPaymentHeader, readReceipt } from "./x402.js";
12
12
  export { ASK, TONES, checkAskText } from "./ask.js";
13
13
  export { AGENT_TYPES, CLIENTS, CONTEXT_FIELD, CONTEXT_QUERY, agentContextSchema, parseAgentContext } from "./context.js";
14
14
  export type { AgentContext } from "./context.js";
package/dist/index.js CHANGED
@@ -3,6 +3,6 @@ export { createForgeCore, checkOptions, BODY_LIMIT, SPEC_LIMIT } from "./core.js
3
3
  export { enrichOpenApi, detectVersion } from "./openapi.js";
4
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, describeReceipt, feedbackExtension, receiptExtension, FEEDBACK_EXTENSION, readPaymentHeader, readReceipt } from "./x402.js";
6
+ export { describeChallenge, describeChallengeBody, describeReceipt, feedbackExtension, receiptExtension, FEEDBACK_EXTENSION, FEEDBACK_FIELD, readPaymentHeader, readReceipt } from "./x402.js";
7
7
  export { ASK, TONES, checkAskText } from "./ask.js";
8
8
  export { AGENT_TYPES, CLIENTS, CONTEXT_FIELD, CONTEXT_QUERY, agentContextSchema, parseAgentContext } from "./context.js";
package/dist/next.js CHANGED
@@ -17,9 +17,13 @@ export function createForge(options) {
17
17
  diagnostics: forge.diagnostics,
18
18
  shutdown: forge.shutdown,
19
19
  handle: forge.handle,
20
+ validate: forge.validate,
20
21
  withForge: (handler) => (request, context) => forge.handle(request, (forwarded) => handler(forwarded === request ? request : sameKind(request, forwarded), context)),
21
22
  routes: { GET: own, POST: own, HEAD: own },
22
23
  proxy: (proxy) => async (request) => {
24
+ const invalid = await forge.validate(request);
25
+ if (invalid)
26
+ return invalid;
23
27
  const response = await proxy(request);
24
28
  // Only the 402: the proxy passes paid requests on to the route, where withForge mints the feedback ID.
25
29
  if (response.status !== 402 || !forge.enabled)