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

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,17 @@ 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
+ 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
+ }
65
78
  for (const key of ["feedback", "describeChallenges", "challengeExtension", "receiptExtension", "injectBody", "injectText", "strict"])
66
79
  fallback(key, boolean(raw[key]), "must be true or false");
67
80
  // The lines no wording may cross, whatever the merchant configures (see ask.ts).
@@ -97,6 +110,8 @@ export function checkOptions(input) {
97
110
  function disabledCore(errors, warnings) {
98
111
  const passThrough = {
99
112
  feedbackId: undefined,
113
+ contextRequired: false,
114
+ contextError: () => null,
100
115
  json: (_status, body) => body,
101
116
  text: (_status, _type, body) => body,
102
117
  headers: () => ({}),
@@ -144,17 +159,24 @@ export function createForgeCore(input) {
144
159
  }
145
160
  }
146
161
  function enabledCore(options, configWarnings) {
147
- const { apiKey, backendUrl, publicUrl } = options;
162
+ const { apiKey, publicUrl } = options;
163
+ const backendUrl = options.backendUrl ?? DEFAULT_BACKEND_URL;
148
164
  // Feedback IDs are signed with a key derived from the API key, never with the API key itself.
149
165
  const signingKey = deriveSigningKey(apiKey);
150
166
  const backendPath = new URL(backendUrl).pathname.replace(/\/+$/, "") || "/v1";
151
167
  const backend = (name) => new URL(`${backendPath}/${name}`, backendUrl).href;
152
168
  const basePath = (options.basePath ?? "/feedback").replace(/\/+$/, "");
153
169
  const ratePath = `${basePath}/rate`;
154
- const rateUrl = new URL(ratePath, publicUrl).href;
155
- const formUrl = new URL(basePath, publicUrl).href;
156
170
  const summaryPath = `${basePath}/summary`;
157
- 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
+ };
158
180
  const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
159
181
  const fetchImpl = options.fetch ?? fetch;
160
182
  const feedback = options.feedback !== false;
@@ -165,6 +187,7 @@ function enabledCore(options, configWarnings) {
165
187
  const contextOption = options.agentContext ?? true;
166
188
  const collectContext = contextOption !== false;
167
189
  const searchQuery = collectContext && (typeof contextOption !== "object" || contextOption.searchQuery !== false);
190
+ const requiredContext = collectContext;
168
191
  const receipts = options.receiptExtension ?? true;
169
192
  const rateHint = options.rateHint === false
170
193
  ? null
@@ -189,30 +212,48 @@ function enabledCore(options, configWarnings) {
189
212
  lastLogged = message;
190
213
  });
191
214
  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);
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);
202
241
  // Set once a document has been enriched; lets body injection respect strict response schemas.
203
242
  let allowInjection = null;
204
243
  let lastWarnings = "";
205
244
  function enrich(document) {
245
+ const links = linksFor();
246
+ const sentence = sentenceFor(links);
206
247
  const result = enrichOpenApi(document, {
207
- publicUrl,
248
+ publicUrl: explicitOrigin ?? registeredOrigin,
208
249
  feedback,
209
250
  basePath,
210
- sentence: challengeSentence,
211
- marker: challengeMarker,
251
+ sentence,
252
+ marker: describedBy(sentence, links.rate),
212
253
  isPaidOperation: openapi?.isPaidOperation,
213
254
  describeOperations: openapi?.describeOperations,
214
255
  hintField: Boolean(rateHint),
215
- agentContext: collectContext ? { searchQuery } : undefined,
256
+ agentContext: collectContext ? { searchQuery, required: requiredContext } : undefined,
216
257
  });
217
258
  stats.openapi = result.report;
218
259
  if (result.report.enriched)
@@ -227,30 +268,65 @@ function enabledCore(options, configWarnings) {
227
268
  // With interception or an async provider, that starts once the spec has been served.
228
269
  if (openapi && openapi.document !== undefined && typeof openapi.document !== "function")
229
270
  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
- },
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
+ };
247
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
+ }
248
324
  const reply = (status, body, headers = FEEDBACK_HEADERS) => body === undefined ? { status, headers } : { status, headers, body };
249
325
  const badRequest = (error) => reply(400, {
250
326
  recorded: false,
251
327
  error,
252
328
  allowed: { outcome: Object.keys(OUTCOMES), issue: Object.keys(ISSUES) },
253
- example: `${rateUrl}?feedback_id=FEEDBACK_ID&outcome=fully`,
329
+ example: `${linksFor().rate}?feedback_id=FEEDBACK_ID&outcome=fully`,
254
330
  });
255
331
  async function submit(request, input, via) {
256
332
  const parsed = parseSubmission(input, { allowNote: via === "POST" });
@@ -294,6 +370,7 @@ function enabledCore(options, configWarnings) {
294
370
  return reply(summaryCache.status, summaryCache.body, { "Cache-Control": "public, max-age=30" });
295
371
  }
296
372
  async function route(request) {
373
+ refreshRegisteredOrigin();
297
374
  try {
298
375
  return await handleRoute(request);
299
376
  }
@@ -307,7 +384,7 @@ function enabledCore(options, configWarnings) {
307
384
  const { method, path } = request;
308
385
  if (feedback && path === basePath) {
309
386
  if (method === "GET" || method === "HEAD")
310
- return reply(200, form);
387
+ return reply(200, formDoc());
311
388
  if (method === "POST") {
312
389
  let body;
313
390
  try {
@@ -342,6 +419,8 @@ function enabledCore(options, configWarnings) {
342
419
  }
343
420
  const passThrough = {
344
421
  feedbackId: undefined,
422
+ contextRequired: false,
423
+ contextError: () => null,
345
424
  json: (_status, body) => body,
346
425
  text: (_status, _type, body) => body,
347
426
  headers: () => ({}),
@@ -368,14 +447,20 @@ function enabledCore(options, configWarnings) {
368
447
  const interactionId = paymentHeader && !feedback ? randomUUID() : undefined;
369
448
  if (feedbackId)
370
449
  stats.minted++;
371
- 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=` : "";
372
454
  let bodyChallenge = false;
373
455
  let headerChallenge = false;
374
456
  let context;
457
+ let rawContext = {};
375
458
  let receiptFacts = {};
376
459
  const remember = (raw) => {
377
460
  if (!collectContext)
378
461
  return;
462
+ if (raw && typeof raw === "object" && !Array.isArray(raw))
463
+ rawContext = { ...rawContext, ...raw };
379
464
  const parsed = parseAgentContext(raw, { searchQuery });
380
465
  if (parsed)
381
466
  context = { ...context, ...parsed };
@@ -383,12 +468,24 @@ function enabledCore(options, configWarnings) {
383
468
  const ok = (status) => status >= 200 && status < 300;
384
469
  const handle = {
385
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
+ },
386
483
  json(status, body) {
387
484
  try {
388
485
  if (status === 402) {
389
486
  bodyChallenge = !!body && typeof body === "object" && "x402Version" in body;
390
487
  // v1 challenges (and v2 challenges echoed in the body) live in the JSON body.
391
- const described = touchChallenges ? describeChallengeBody(body, challengeAdditions) : undefined;
488
+ const described = touchChallenges ? describeChallengeBody(body, challengeAdditions(challengeOrigin(body))) : undefined;
392
489
  if (described) {
393
490
  stats.challengesDescribed++;
394
491
  return described;
@@ -400,15 +497,16 @@ function enabledCore(options, configWarnings) {
400
497
  body !== null &&
401
498
  typeof body === "object" &&
402
499
  Object.getPrototypeOf(body) === Object.prototype &&
403
- !("feedback_id" in body) &&
404
- !("feedback_url" in body) &&
405
- !(rateHint && "rate_this_call" in body) &&
500
+ !(FEEDBACK_FIELD in body) &&
406
501
  (allowInjection?.(request.method, request.path, status) ?? true)) {
502
+ // One namespaced object, so the merchant's own fields stay recognizably theirs.
407
503
  return {
408
504
  ...body,
409
- feedback_id: feedbackId,
410
- feedback_url: feedbackUrl,
411
- ...(rateHint ? { rate_this_call: rateHint.replaceAll("{feedback_url}", feedbackUrl).replaceAll("{summary_url}", summaryUrl) } : {}),
505
+ [FEEDBACK_FIELD]: {
506
+ feedback_id: feedbackId,
507
+ rate: feedbackUrl,
508
+ ...(rateHint ? { rate_this_call: rateHint.replaceAll("{feedback_url}", feedbackUrl).replaceAll("{summary_url}", callLinks.summary) } : {}),
509
+ },
412
510
  };
413
511
  }
414
512
  }
@@ -430,7 +528,7 @@ function enabledCore(options, configWarnings) {
430
528
  if (status === 402 && paymentRequired)
431
529
  headerChallenge = true;
432
530
  if (status === 402 && touchChallenges && paymentRequired) {
433
- const next = describeChallenge(paymentRequired, challengeAdditions);
531
+ const next = describeChallenge(paymentRequired, challengeAdditions(challengeOrigin(paymentRequired)));
434
532
  if (next) {
435
533
  set["PAYMENT-REQUIRED"] = next;
436
534
  stats.challengesDescribed++;
@@ -440,7 +538,7 @@ function enabledCore(options, configWarnings) {
440
538
  receiptFacts = readReceipt(paymentResponse);
441
539
  if (feedbackId && ok(status)) {
442
540
  set["Forge-Feedback-Id"] = feedbackId;
443
- const receipt = receipts && paymentResponse ? describeReceipt(paymentResponse, receiptExtension(rateUrl, feedbackId, tone)) : undefined;
541
+ const receipt = receipts && paymentResponse ? describeReceipt(paymentResponse, receiptExtension(callLinks.rate, feedbackId, tone)) : undefined;
444
542
  if (receipt)
445
543
  set["PAYMENT-RESPONSE"] = receipt;
446
544
  }
@@ -510,7 +608,9 @@ function enabledCore(options, configWarnings) {
510
608
  }
511
609
  return {
512
610
  enabled: true,
513
- challengeSentence,
611
+ get challengeSentence() {
612
+ return sentenceFor(linksFor());
613
+ },
514
614
  route,
515
615
  isSpecRequest: (method, path) => Boolean(openapi) && method === "GET" && specPaths.has(path),
516
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. */
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; enabled 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,7 +129,13 @@ 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)) {
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)) {
81
139
  const text = await request.clone().text();
82
140
  if (text.includes('"agent_context"')) {
83
141
  const parsed = JSON.parse(text);
@@ -86,6 +144,11 @@ export function createForge(options) {
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)